thunderwolf/sortable

Sortable for the Laravel Eloquent based on Propel sortable behavior

1.0.1 2023-02-25 18:12 UTC

This package is auto-updated.

Last update: 2024-04-25 20:32:12 UTC


README

The sortable behavior allows a model to become an ordered list, and provides numerous methods to traverse this list in an efficient way.

To work it requires Sortable trait in your Model with a configuration which in the most simple configuration just looks like this:

    public static function sortable(): array
    {
        return [];
    }

Sortable trait you will be using overrides default Builder what can be seen below:

    /**
     * Override -> Create a new Eloquent query builder for the model.
     * If you have more Behaviors using this kind on Override create own and use Trait SortableBuilderTrait
     *
     * @param  Builder  $query
     * @return SortableBuilder
     */
    public function newEloquentBuilder($query): SortableBuilder
    {
        return new SortableBuilder($query);
    }

If you are using more Traits or own Override please just use SortableBuilderTrait trait as the SortableBuilder class just look like this:

<?php

namespace Thunderwolf\EloquentSortable;

use Illuminate\Database\Eloquent\Builder;

class SortableBuilder extends Builder
{
    use SortableBuilderTrait;
}

Basic Usage

The most basic way to use this package is to Create Model with the Sortable trait in use like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Thunderwolf\EloquentSortable\Sortable;

class Task extends Model
{
    use Sortable;

    protected $table = 'tasks';

    protected $fillable = ['title'];

    public $timestamps = false;

    public static function sortable(): array
    {
        return [];
    }
}

After registering SortableServiceProvider you can also use Blueprints to create tables with a use of createSortable helper method similar to this:

$schema->create('tasks', function (Blueprint $table1) {
    $table1->increments('id');
    $table1->string('title');
    $table1->createSortable([]);
});

In similar way you will be working with migrations.

The model now has the ability to be inserted into an ordered list, as follows:

<?php
$t1 = new Task();
$t1->setAttribute('title', 'Wash the dishes');
$t1->save();
echo $t1->getSortableRank(); // 1, the first rank to be given (not 0)

$t2 = new Task();
$t2->setAttribute('title', 'Do the laundry');
$t2->save();
echo $t2->getSortableRank(); // 2

$t3 = new Task();
$t3->setAttribute('title', 'Rest a little');
$t3->save();
echo $t3->getSortableRank(); // 3

As long as you save new objects, Propel gives them the first available rank in the list.

The results returned by these methods are regular Propel model objects, with access to the properties and related models. The sortable behavior also adds inspection methods to objects:

<?php
echo $t2->isFirst();         // false
echo $t2->isLast();          // false
echo $t2->getSortableRank(); // 2

Once you have built an ordered list, you can traverse it using any of the methods added by the sortable behavior. For instance:

<?php
$firstTask = Task::query()->findOneByRank(1); // $t1
$secondTask = $firstTask->getNext();          // $t2
$lastTask = $secondTask->getNext();           // $t3
$secondTask = $lastTask->getPrevious();       // $t2

$allTasks = Task::query()->findList();                                // => collection($t1, $t2, $t3)
$allTasksInReverseOrder = Task::query()->orderByRank('desc')->get();  // => collection($t3, $t2, $t2)

Manipulating Objects In A List

You can move an object in the list using any of the moveUp(), moveDown(), moveToTop(), moveToBottom(), moveToRank(), and swapWith() methods. These operations are immediate and don’t require that you save the model afterwards:

<?php
$t1 = Task::query()->findOneByRank(1);
$t2 = Task::query()->findOneByRank(2);
// Initial list is: 1 - Wash the dishes, 2 - Do the laundry, 3 - Rest a little

$t2->moveToTop();    // will end with this order: 1 - Do the laundry,  2 - Wash the dishes, 3 - Rest a little
$t2->moveToBottom(); // will end with this order: 1 - Wash the dishes, 2 - Rest a little,   3 - Do the laundry
$t2->moveUp();       // will end with this order: 1 - Wash the dishes, 2 - Do the laundry,  3 - Rest a little
$t2->swapWith($t1);  // will end with this order: 1 - Do the laundry,  2 - Wash the dishes, 3 - Rest a little
$t2->moveToRank(3);  // will end with this order: 1 - Wash the dishes, 2 - Rest a little,   3 - Do the laundry
$t2->moveToRank(2);  // will end with this order: 1 - Wash the dishes, 2 - Do the laundry,  3 - Rest a little

By default, new objects are added at the bottom of the list. But you can also insert them at a specific position, using any of the insertAtTop(), insertAtBottom(), and insertAtRank() methods. Note that the insertAtXXX methods don’t save the object:

<?php
// Initial list is: 1 - Wash the dishes, 2 - Do the laundry, 3 - Rest a little

$t4 = new Task();
$t4->setAttribute('title', 'Clean windows');
$t4->insertAtRank(2);
$t4->save();  // The list is now  1 - Wash the dishes, 2 - Clean Windows, 3 - Do the laundry, 4 - Rest a little

Whenever you delete() an object, the ranks are rearranged to fill the gap:

<?php
$t4->delete();
// The list is now 1 - Wash the dishes, 2 - Do the laundry, 3 - Rest a little

Tip:

You can remove an object from the list without necessarily deleting it by calling removeFromList(). Don’t forget to save() it afterwards so that the other objects in the lists are rearranged to fill the gap.

Multiple Lists

When you need to store several lists for a single model - for instance, one task list for each user - use a scope for each list. This requires that you enable scope support in the behavior definition by setting the use_scope parameter to true and set scope_columns like in a below example. Create Model with the Sortable trait and a configuration like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Thunderwolf\EloquentSortable\Sortable;

class SingleScopedTask extends Model
{
    use Sortable;

    protected $table = 'single-scoped-tasks';

    protected $fillable = ['title'];

    public $timestamps = false;

    public static function sortable(): array
    {
        return ['use_scope' => true, 'scope_columns' => ['single_scoped_user_id']];
    }

    /**
     * When planning to use `Inserting Related Models` way of set user id we need to override setter - we are using 8.x
     *
     * @param $value
     * @return void
     */
    public function setSingleScopedUserIdAttribute($value)
    {
        $this->oldScope['single_scoped_user_id'] = $this->attributes['single_scoped_user_id']??null;
        $this->attributes['single_scoped_user_id'] = $value;
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(SingleScopedUser::class, 'single_scoped_user_id');
    }
}

With this example we are also using a SingleScopedUser model like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class SingleScopedUser extends Model
{
    protected $table = 'single-scoped-users';

    protected $fillable = ['username'];

    public $timestamps = false;

    public function tasks(): HasMany
    {
        return $this->hasMany(SingleScopedTask::class);
    }
}

For the upper models after registering SortableServiceProvider you can also use Blueprints to create tables with a use of createSortable helper method similar to this:

$schema->create('single-scoped-tasks', function (Blueprint $table2) {
    $table2->increments('id');
    $table2->unsignedInteger('single_scoped_user_id');
    $table2->string('title');
    $table2->createSortable(['use_scope' => true, 'scope_columns' => ['single_scoped_user_id']]);
});

$schema->create('single-scoped-users', function (Blueprint $table3) {
    $table3->increments('id');
    $table3->string('username');
});

In similar way you will be working with migrations.

For the upper example, you can have as many lists as required.

You can directly set Scope like bellow:

<?php
// We are assuming this table have those 2 records:
$paul = SingleScopedUser::query()->find(1); // paul
$john = SingleScopedUser::query()->find(2); // john

$t1 = new SingleScopedTask();
$t1->setAttribute('title', 'Wash the dishes');
$t1->setSortableScope('single_scoped_user_id', $paul->getKey());
$t1->save();
echo $t1->getSortableRank(); // 1

$t2 = new SingleScopedTask();
$t2->setAttribute('title', 'Do the laundry');
$t2->setSortableScope('single_scoped_user_id', $paul->getKey());
$t2->save();
echo $t2->getSortableRank(); // 2

$t3 = new SingleScopedTask();
$t3->setAttribute('title', 'Rest a little');
$t3->setSortableScope('single_scoped_user_id', $john->getKey());
$t3->save();
echo $t3->getSortableRank(); // 1, because John has his own task list

or you can use Relation like below:

// We are assuming this table have those 2 records:
$paul = SingleScopedUser::query()->find(1); // paul
$john = SingleScopedUser::query()->find(2); // john

$t1 = new SingleScopedTask(['title' => 'Wash the dishes']);
$paul->tasks()->save($t1);
echo $t1->getSortableRank(); // 1

$t2 = new SingleScopedTask(['title' => 'Do the laundry']);
$paul->tasks()->save($t2);
echo $t2->getSortableRank(); // 2

$t3 = new SingleScopedTask(['title' => 'Rest a little']);
$john->tasks()->save($t3);
echo $t3->getSortableRank(); // 1, because John has his own task list

The generated methods now accept a $scope parameter to restrict the query to a given scope:

<?php
$firstPaulTask = SingleScopedTask::query()->findOneByRank(1, ['single_scoped_user_id' => $paul->getKey()]); // $t1
$lastPaulTask = $firstPaulTask->getNext();                                                                  // $t2
$firstJohnTask = SingleScopedTask::query()->findOneByRank(1, ['single_scoped_user_id' => $john->getKey()]); // $t3

Models using the sortable behavior with scope benefit from one additional Builder method named inList():

<?php
$allPaulsTasks = SingleScopedTask::query()->inList(['single_scoped_user_id' => $paul->getKey()])->get();
// => collection($t1, $t2)

Multi-Column scopes

We can have multiple columns used for scope.

To achieve this just create Model with the Sortable trait and a configuration like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Thunderwolf\EloquentSortable\Sortable;

class MultiScopedTask extends Model
{
    use Sortable;

    protected $table = 'multi-scoped-tasks';

    protected $fillable = ['title'];

    public $timestamps = false;

    public static function sortable(): array
    {
        return ['use_scope' => true, 'scope_columns' => ['multi_scoped_user_id', 'multi_scoped_group_id']];
    }

    /**
     * When planning to use `Inserting Related Models` way of set user id we need to override setter - we are using 8.x
     *
     * @param $value
     * @return void
     */
    public function setMultiScopedUserIdAttribute($value)
    {
        $this->oldScope['multi_scoped_user_id'] = $this->attributes['multi_scoped_user_id']??null;
        $this->attributes['multi_scoped_user_id'] = $value;
    }

    /**
     * When planning to use `Inserting Related Models` way of set user id we need to override setter - we are using 8.x
     *
     * @param $value
     * @return void
     */
    public function setMultiScopedGroupIdAttribute($value)
    {
        $this->oldScope['multi_scoped_group_id'] = $this->attributes['multi_scoped_group_id']??null;
        $this->attributes['multi_scoped_group_id'] = $value;
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(MultiScopedUser::class, 'multi_scoped_user_id');
    }

    public function group(): BelongsTo
    {
        return $this->belongsTo(MultiScopedGroup::class, 'multi_scoped_group_id');
    }

}

With this example we are also using a MultiScopedUser model like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class MultiScopedUser extends Model
{
    protected $table = 'multi-scoped-users';

    protected $fillable = ['username'];

    public $timestamps = false;

    public function tasks(): HasMany
    {
        return $this->hasMany(MultiScopedTask::class);
    }
}

And a MultiScopedGroup model like this:

<?php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class MultiScopedGroup extends Model
{
    protected $table = 'multi-scoped-groups';

    protected $fillable = ['name'];

    public $timestamps = false;

    public function tasks(): HasMany
    {
        return $this->hasMany(MultiScopedTask::class);
    }
}

For the upper models after registering SortableServiceProvider you can also use Blueprints to create tables with a use of createSortable helper method similar to this:

$schema->create('multi-scoped-tasks', function (Blueprint $table4) {
    $table4->increments('id');
    $table4->unsignedInteger('multi_scoped_user_id');
    $table4->unsignedInteger('multi_scoped_group_id');
    $table4->string('title');
    $table4->createSortable(['use_scope' => true, 'scope_columns' => ['multi_scoped_user_id', 'multi_scoped_group_id']]);
});

$schema->create('multi-scoped-users', function (Blueprint $table5) {
    $table5->increments('id');
    $table5->string('username');
});

$schema->create('multi-scoped-groups', function (Blueprint $table6) {
    $table6->increments('id');
    $table6->string('name');
});

In similar way you will be working with migrations.

With the upper configuration Trait manages one sortable list of tasks per User per Group.

You can directly set User-Group Scope like bellow:

<?php
// We are assuming this table have those 2 records:
$paul = MultiScopedUser::query()->find(1); // paul
$john = MultiScopedUser::query()->find(2); // john

// We are assuming this table have those 2 records:
$adminGroup = MultiScopedGroup::query()->find(1); // admin
$userGroup = MultiScopedGroup::query()->find(2); // user

// now onto the tasks
$t1 = new MultiScopedTask();
$t1->setAttribute('title', 'Create permissions');
$t1->setSortableScope('multi_scoped_user_id', $paul->getKey());
$t1->setSortableScope('multi_scoped_group_id', $adminGroup->getKey());
$t1->save();
echo $t1->getSortableRank(); // 1

$t2 = new MultiScopedTask();
$t2->setAttribute('title', 'Grant permissions to users');
$t2->setSortableScope('multi_scoped_user_id', $paul->getKey());
$t2->setSortableScope('multi_scoped_group_id', $adminGroup->getKey());
$t2->save();
echo $t2->getSortableRank(); // 2

$t3 = new MultiScopedTask();
$t3->setAttribute('title', 'Install servers');
$t3->setSortableScope('multi_scoped_user_id', $john->getKey());
$t3->setSortableScope('multi_scoped_group_id', $adminGroup->getKey());
$t3->save();
echo $t3->getSortableRank(); // 1, because John has his own task list inside the admin-group

$t4 = new MultiScopedTask();
$t4->setAttribute('title', 'Manage content');
$t4->setSortableScope('multi_scoped_user_id', $john->getKey());
$t4->setSortableScope('multi_scoped_group_id', $userGroup->getKey());
$t4->save();
echo $t4->getSortableRank(); // 1, because John has his own task list inside the user-group

or you can use Relation like below:

// We are assuming this table have those 2 records:
$paul = MultiScopedUser::query()->find(1); // paul
$john = MultiScopedUser::query()->find(2); // john

// We are assuming this table have those 2 records:
$adminGroup = MultiScopedGroup::query()->find(1); // admin
$userGroup = MultiScopedGroup::query()->find(2); // user

// now onto the tasks
$t1 = new MultiScopedTask(['title' => 'Create permissions']);
$t1->user()->associate($paul);
$t1->group()->associate($adminGroup);
$t1->save();
echo $t1->getSortableRank(); // 1

$t2 = new MultiScopedTask(['title' => 'Grant permissions to users']);
$t2->user()->associate($paul);
$t2->group()->associate($adminGroup);
$t2->save();
echo $t2->getSortableRank(); // 2

$t3 = new MultiScopedTask(['title' => 'Install servers']);
$t3->user()->associate($john);
$t3->group()->associate($adminGroup);
$t3->save();
echo $t3->getSortableRank(); // 1, because John has his own task list inside the admin-group

$t4 = new MultiScopedTask(['title' => 'Manage content']);
$t4->user()->associate($john);
$t4->group()->associate($userGroup);
$t4->save();
echo $t4->getSortableRank(); // 1, because John has his own task list inside the user-group

The generated methods now accept one parameter per scoped column, to restrict the query to a given scope:

<?php
// $t1
$firstPaulAdminTask = MultiScopedTask::query()->findOneByRank(
    1,
    ['multi_scoped_user_id' => $paul->getKey(), 'multi_scoped_group_id' => $adminGroup->getKey()]
);

// $t2
$lastPaulTask = $firstPaulAdminTask->getNext();

// $t4
$firstJohnUserTask = MultiScopedTask::query()->findOneByRank(
    1,
    ['multi_scoped_user_id' => $john->getKey(), 'multi_scoped_group_id' => $userGroup->getKey()]
);

Models using the sortable behavior with scope benefit from one additional Builder method named inList():

<?php
$allJohnsUserTasks = MultiScopedTask::query()
    ->inList(['multi_scoped_user_id' => $john->getKey(), 'multi_scoped_group_id' => $userGroup->getKey()])
    ->get();
// => collection($t4)

Configuration

By default, with the configuration below:

    public static function sortable(): array
    {
        return [];
    }

There will be column sortable_rank added to the model. You can configure this with the configuration looking like this:

    public static function sortable(): array
    {
        return ['rank_column' => 'my_rank_column'];
    }

as was described in the upper chapter you can also use scope which can be a single lub multiple column based. Example configuration can look like below:

    public static function sortable(): array
    {
        return ['use_scope' => true, 'scope_columns' => ['multi_scoped_user_id', 'multi_scoped_group_id']];
    }

To use Scope you must set use_scope to true.

Whatever name you give to your columns, the sortable behavior always adds the following proxy methods, which are mapped to the correct column:

<?php
$task->getSortableRankName();          // returns name of the rank column
$task->getSortableRank();              // returns value of the rank column
$task->setSortableRank($rank);         // allows set rank value
$task->isSortableScopeUsed();          // returns use_scope configuration value
$task->getSortableScopeNames();        // returns scope_columns configuration value
$task->getSortableScope($key);         // returns value of the scope column by the scope column name
$task->getSortableScopes();            // returns array of scope values where key is scope column name and value scope column value
$task->setSortableScope($key, $scope); // allows set scope column value by the scope column name as a key

Tip

If you are planning to use Relation and Scope please check upper examples as you will need to override each scope column setter.

Complete API

Here is a list of the methods added by the behavior to the model objects:

<?php
// storage columns accessors
public function getSortableRankName(): string
public function getSortableRank(): int
public function setSortableRank(int $rank): void

// only for behavior with use_scope
public function isSortableScopeUsed(): bool
public function getSortableScopeNames(): array
public function getSortableScope(string $key): ?int
public function getSortableScopes(): array
public function setSortableScope(string $key, int $scope): void

// inspection methods
public function isFirst(): bool
public function isLast(): bool

// list traversal methods
public function getNext(): Model
public function getPrevious(): Model

// methods to insert an object in the list (require calling save() afterwards)
public function insertAtRank(int $rank): Model
public function insertAtBottom(): Model
public function insertAtTop(): Model

// methods to move an object in the list (immediate, no need to save() afterwards)
public function moveToRank(int $newRank): Model
public function swapWith(Model $object): Model
public function moveUp(): Model
public function moveDown(): Model
public function moveToTop(): Model
public function moveToBottom(): Model

// method to remove an object from the list (requires calling save() afterwards)
public function removeFromList(): Model

Here is a list of the methods added by the behavior to the Builder:

<?php
public function filterByRank(int $rank, array $scopes = []): self
public function orderByRank(string $order = 'asc'): self
public function findOneByRank(int $rank, array $scopes = []): Model
public function findList(array $scopes = []): ?Collection
public function countList(array $scopes = []): int
public function deleteList(array $scopes = []): int
public function getMaxRank(array $scopes = []): ?int
public function reorder(array $order): bool
// only for behavior with use_scope
public function inList(array $scopes): self