Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 100 additions & 21 deletions src/Database/Traits/HasSortableRelations.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

use Exception;

use Winter\Sorm\Database\Model;
use Winter\Storm\Database\Model;
use Winter\Storm\Database\Models\DeferredBinding;

/**
* HasSortableRelations trait
Expand All @@ -17,7 +18,7 @@
*
* To set orders:
*
* $model->setSortableRelationOrder($relationName, $recordIds, $recordOrders);
* $model->setRelationOrder($relationName, $recordIds, $recordOrders);
*
*/
trait HasSortableRelations
Expand All @@ -41,8 +42,20 @@ public function initializeHasSortableRelations() : void
if (array_key_exists($relationName, $sortableRelations)) {
$column = $this->getRelationSortOrderColumn($relationName);

// If the records were attached with an explicit sort order (e.g. when committing
// deferred bindings that already carry a pivot sort order), keep it - otherwise
// auto-append the records to the end of the relation. $data is the list of pivot
// insert rows, one per attached record.
if (is_array($data)) {
foreach ($data as $row) {
if (is_array($row) && isset($row[$column])) {
return;
}
Comment thread
LukeTowers marked this conversation as resolved.
}
}

foreach ($attached as $id) {
$this->updateRelationOrder($relationName, $id, $column);
$this->updateRelationOrder($relationName, $id, $column, null);
}
}
});
Expand All @@ -52,39 +65,62 @@ public function initializeHasSortableRelations() : void
if (array_key_exists($relationName, $sortableRelations)) {
$column = $this->getRelationSortOrderColumn($relationName);

$this->updateRelationOrder($relationName, $relatedModel->getKey(), $column);
// No order - auto-append to the end of the relation.
$this->updateRelationOrder($relationName, $relatedModel->getKey(), $column, null);
}
});

foreach ($sortableRelations as $relationName => $column) {
$relationType = $this->getRelationType($relationName);
if (!in_array($relationType, ['belongsToMany', 'morphToMany'])) {
if (!in_array($relationType, ['belongsToMany', 'morphToMany', 'morphedByMany'])) {
continue;
}
$definition = $this->getRelationDefinition($relationName);
$pivot = array_wrap(array_get($definition, 'pivot', []));

// Make sure the sort order column is available as pivot data.
$pivot = array_wrap(array_get($definition, 'pivot', []));
if (!in_array($column, $pivot)) {
// Make sure the sort order column is available as pivot data.
$pivot[] = $column;
$definition['pivot'] = $pivot;
$this->$relationType[$relationName] = $definition;
}

// Auto-add an ordering clause so the relation is returned in sort order by default.
// Qualify with the pivot table name to avoid colliding with a column of the same
// name on the related model (which would silently order by the wrong column).
if (!array_key_exists('order', $definition)) {
$pivotTable = array_get($definition, 'table');
$definition['order'] = ($pivotTable ? $pivotTable . '.' : '') . $column . ' asc';
}

$this->$relationType[$relationName] = $definition;
}
}

/**
* Set the sort order of records to the specified orders. If the orders is
* undefined, the record identifier is used.
* Set the sort order of records to the specified orders. If the orders are
* undefined, a sequential 1..N order is assigned in the given id order.
*
* When a $sessionKey is provided the relation is operating in deferred mode:
* the sort order is written to the pivot_data of the matching deferred_bindings
* records instead of the pivot table. The caller (e.g. RelationController) is
* responsible for passing the sessionKey only when in deferred mode.
*/
public function setRelationOrder(string $relationName, string|int|array $itemIds, array $itemOrders = []) : void
{
public function setRelationOrder(
string $relationName,
string|int|array $itemIds,
array $itemOrders = [],
?string $sessionKey = null
) : void {
if (!is_array($itemIds)) {
$itemIds = [$itemIds];
}

if (empty($itemIds)) {
return;
}

if (empty($itemOrders)) {
$itemOrders = $itemIds;
$itemOrders = range(1, count($itemIds));
}

if (count($itemIds) != count($itemOrders)) {
Expand All @@ -93,21 +129,56 @@ public function setRelationOrder(string $relationName, string|int|array $itemIds

$column = $this->getRelationSortOrderColumn($relationName);

/*
* Deferred mode - update pivot_data on the deferred_bindings records.
* Batch the lookup into a single query, then save each binding (each row
* carries its own JSON pivot_data so individual saves are required).
*/
if ($sessionKey) {
$bindings = DeferredBinding::where('master_type', get_class($this))
->where('master_field', $relationName)
->whereIn('slave_id', $itemIds)
->where('session_key', $sessionKey)
->where('is_bind', 1)
->get()
->keyBy('slave_id');

foreach ($itemIds as $index => $id) {
if ($binding = $bindings->get($id)) {
$pivotData = $binding->pivot_data ?: [];
$pivotData[$column] = (int) $itemOrders[$index];
$binding->pivot_data = $pivotData;
$binding->save();
}
}

return;
}

foreach ($itemIds as $index => $id) {
$order = (int)$itemOrders[$index];
$this->updateRelationOrder($relationName, $id, $column, $order);
// Pass the explicit order (which may legitimately be 0) so it is not mistaken
// for the "auto-append" case.
$this->updateRelationOrder($relationName, $id, $column, (int) $itemOrders[$index]);
}
}

/**
* Update relation record sort_order.
* Update relation record sort_order. A null $order auto-appends the record to the end
* of the relation; an explicit integer (including 0) is written as-is.
*/
protected function updateRelationOrder(string $relationName, string|int|Model $id, string $column, int $order = 0) : void
protected function updateRelationOrder(string $relationName, string|int|Model $id, string $column, ?int $order = null) : void
{
$relation = $this->{$relationName}();

if (!$order) {
$order = $relation->count();
if ($order === null) {
// Append to the end of the relation. For pivot-based relations use the pivot
// query directly so a defined "order" clause on the relation cannot interfere
// with the aggregate, and so non-contiguous sort orders are handled correctly.
if (method_exists($relation, 'newPivotQuery')) {
$order = ((int) $relation->newPivotQuery()->max($column)) + 1;
} else {
$order = $relation->count() + 1;
}
}
if (method_exists($relation, 'updateExistingPivot')) {
$relation->updateExistingPivot($id, [ $column => (int)$order ]);
Expand All @@ -123,7 +194,15 @@ protected function updateRelationOrder(string $relationName, string|int|Model $i
}

/**
* Get the name of the "sort_order" column.
* Returns true if the given relation is configured as a sortable relation.
*/
public function isSortableRelation(string $relationName) : bool
{
return array_key_exists($relationName, $this->getSortableRelations());
}

/**
* Get the name of the "sort_order" column for the given relation.
*/
public function getRelationSortOrderColumn(string $relationName) : string
{
Expand All @@ -133,7 +212,7 @@ public function getRelationSortOrderColumn(string $relationName) : string
/**
* Return all configured sortable relations.
*/
protected function getSortableRelations() : array
public function getSortableRelations() : array
{
if (property_exists($this, 'sortableRelations')) {
return $this->sortableRelations;
Expand Down
74 changes: 74 additions & 0 deletions tests/Database/Fixtures/SortableArticle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Winter\Storm\Tests\Database\Fixtures;

use Illuminate\Database\Schema\Builder;
use Winter\Storm\Database\Model;
use Winter\Storm\Database\Traits\HasSortableRelations;

class SortableArticle extends Model
{
use MigratesForTesting;
use HasSortableRelations;

/**
* @var string The database table used by the model.
*/
public $table = 'database_tester_sortable_articles';

/**
* @var array Guarded fields
*/
protected $guarded = [];

/**
* @var array Sortable relations and their sort order pivot column.
*/
public $sortableRelations = [
'authors' => 'sort_order',
];

/**
* @var array Relations
*/
public $belongsToMany = [
'authors' => [
Author::class,
'table' => 'database_tester_sortable_article_author',
'key' => 'article_id',
'otherKey' => 'author_id',
],
];

public static function migrateUp(Builder $builder): void
{
if ($builder->hasTable('database_tester_sortable_articles')) {
return;
}

$builder->create('database_tester_sortable_articles', function ($table) {
$table->engine = 'InnoDB';
$table->increments('id');
$table->string('title')->nullable();
$table->timestamps();
});

$builder->create('database_tester_sortable_article_author', function ($table) {
$table->engine = 'InnoDB';
$table->integer('article_id')->unsigned();
$table->integer('author_id')->unsigned();
$table->integer('sort_order')->default(0);
$table->primary(['article_id', 'author_id']);
});
}

public static function migrateDown(Builder $builder): void
{
if (!$builder->hasTable('database_tester_sortable_articles')) {
return;
}

$builder->dropIfExists('database_tester_sortable_article_author');
$builder->dropIfExists('database_tester_sortable_articles');
}
}
Loading
Loading