├── LICENSE.txt ├── README.md ├── composer.json └── src ├── Model └── Behavior │ └── SequenceBehavior.php └── SequencePlugin.php /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) ADmad 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sequence plugin to maintain ordered list of records 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/ADmad/cakephp-sequence/ci.yml?branch=master&style=flat-square)](https://github.com/ADmad/cakephp-sequence/actions/workflows/ci.yml) 4 | [![Coverage](https://img.shields.io/codecov/c/github/ADmad/cakephp-sequence.svg?style=flat-square)](https://codecov.io/github/ADmad/cakephp-sequence) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/admad/cakephp-sequence.svg?style=flat-square)](https://packagist.org/packages/admad/cakephp-sequence) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) 7 | 8 | ## Installation 9 | 10 | Install this plugin into your CakePHP application using [composer](http://getcomposer.org): 11 | 12 | ``` 13 | composer require admad/cakephp-sequence 14 | ``` 15 | 16 | Then load the plugin by running CLI command: 17 | 18 | ```bash 19 | ./bin/cake plugin load ADmad/Sequence 20 | ``` 21 | 22 | ## How it works 23 | 24 | `SequenceBehavior` provided by this plugin maintains a contiguous sequence of 25 | integers in a selected column, for records in a table (optionally with grouping) 26 | when adding, editing (including moving groups) or deleting records. 27 | 28 | ## Usage 29 | 30 | Add the `SequenceBehavior` for your table and viola: 31 | 32 | ```php 33 | $this->addBehavior('ADmad/Sequence.Sequence'); 34 | ``` 35 | 36 | You can customize various options as shown: 37 | 38 | ```php 39 | $this->addBehavior('ADmad/Sequence.Sequence', [ 40 | 'sequenceField' => 'position', // Field to use to store integer sequence. Default "position". 41 | 'scope' => ['group_id'], // Array of field names to use for grouping records. Default []. 42 | 'startAt' => 1, // Initial value for sequence. Default 1. 43 | ]); 44 | ``` 45 | 46 | Now whenever a new record is added its `position` field will be automatically 47 | set to current largest value in sequence plus one. 48 | 49 | When editing records you can set the position to a new value and the position of 50 | other records in the list will be automatically updated to maintain proper 51 | sequence. 52 | 53 | When doing a find on the table an order clause is automatically added to the 54 | query to order by the position field if an order clause has not already been set. 55 | 56 | ### Methods 57 | 58 | #### moveUp(\Cake\Datasource\EntityInterface $entity) 59 | Move up record by one position: 60 | 61 | ```php 62 | $modelObject->moveUp($entity); 63 | ``` 64 | 65 | #### moveDown(\Cake\Datasource\EntityInterface $entity) 66 | Move down record by one position: 67 | 68 | ```php 69 | $modelObject->moveDown($entity); 70 | ``` 71 | 72 | #### setOrder(array $record) 73 | Set order for list of records provided. Records can be provided as array of 74 | entities or array of associative arrays like `[['id' => 1], ['id' => 2]]` or 75 | array of primary key values like `[1, 2]`. 76 | 77 | ## Acknowledgement 78 | 79 | Shout out to @neilcrookes for his wonderful Sequence Behavior for CakePHP 1.3 80 | which was the inspiration for this plugin. 81 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admad/cakephp-sequence", 3 | "description": "Sequence plugin for CakePHP to maintain ordered list of records", 4 | "keywords": ["cakephp", "orm", "sequence", "list"], 5 | "type": "cakephp-plugin", 6 | "license": "MIT", 7 | "require": { 8 | "cakephp/orm": "^5.0" 9 | }, 10 | "require-dev": { 11 | "cakephp/cakephp": "^5.0", 12 | "phpunit/phpunit": "^10.1" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "ADmad\\Sequence\\": "src" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "ADmad\\Sequence\\Test\\": "tests", 22 | "Cake\\Test\\": "./vendor/cakephp/cakephp/tests", 23 | "TestApp\\": "tests/test_app/src" 24 | } 25 | }, 26 | "config": { 27 | "sort-packages": true, 28 | "allow-plugins": { 29 | "dealerdirect/phpcodesniffer-composer-installer": true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Model/Behavior/SequenceBehavior.php: -------------------------------------------------------------------------------- 1 | = order of 37 | * inserted record i.e. D, E, F & G get incremented 38 | * - If editing existing record: 39 | * - If order not specified and scope not specified, or same 40 | * No Action 41 | * - If order not specified but scope specified and different: 42 | * Decrement order of all records whose order > old order in the old 43 | * scope, and change order to highest order of new scopes + 1 44 | * - If order specified: 45 | * - If new order < old order e.g. record E moves from 4 to 2 46 | * Increments order of all other records whose order > new order and 47 | * order < old order i.e. order of C & D get incremented 48 | * - If new order > old order e.g. record C moves from 2 to 4 49 | * Decrements order of all other records whose order > old order and 50 | * <= new order i.e. order of D & E get decremented 51 | * - If new order == old order 52 | * No action 53 | * - Delete 54 | * Decrement order of all records whose order > order of deleted record 55 | * 56 | * Inspired by Neil Crooke's Sequence behavior for CakePHP 1.3. Above description 57 | * has been "borrowed" from it :). 58 | * 59 | * @copyright 2015-Present ADmad 60 | * @link https://github.com/ADmad/cakephp-sequence 61 | * @license MIT License - http://www.opensource.org/licenses/mit-license.php 62 | */ 63 | class SequenceBehavior extends Behavior 64 | { 65 | /** 66 | * Default settings. 67 | * 68 | * @var array 69 | */ 70 | protected array $_defaultConfig = [ 71 | 'sequenceField' => 'position', 72 | 'scope' => [], 73 | 'startAt' => 1, 74 | ]; 75 | 76 | /** 77 | * Old values for the entity being deleted 78 | * 79 | * @var array|null 80 | */ 81 | protected ?array $_oldValues = null; 82 | 83 | /** 84 | * Normalize config options. 85 | * 86 | * @param array $config Configuration options include: 87 | * - sequenceField : The field name that stores the sequence number. 88 | * Defaults is "position". 89 | * - scope : Array of field names that identify a single group of records 90 | * that need to form a contiguous sequence. 91 | * Default is empty array, i.e. no scope fields. 92 | * - startAt : You can start your sequence numbers at 0 or 1 or any other. 93 | * Defaults is 1. 94 | * @return void 95 | */ 96 | public function initialize(array $config): void 97 | { 98 | if (!$this->_config['scope']) { 99 | return; 100 | } 101 | 102 | if (is_string($this->_config['scope'])) { 103 | $this->_config['scope'] = [$this->_config['scope']]; 104 | } 105 | } 106 | 107 | /** 108 | * Adds order value if not already set in query. 109 | * 110 | * @param \Cake\Event\EventInterface $event The beforeFind event that was fired. 111 | * @param \Cake\ORM\Query\SelectQuery $query The query object. 112 | * @param \ArrayObject $options The options passed to the find method. 113 | * @return void 114 | */ 115 | public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options): void 116 | { 117 | if (!$query->clause('order')) { 118 | $query->orderBy([$this->_table->aliasField($this->_config['sequenceField']) => 'ASC']); 119 | } 120 | } 121 | 122 | /** 123 | * Sets entity's order and updates order of other records when necessary. 124 | * 125 | * @param \Cake\Event\EventInterface $event The beforeSave event that was fired. 126 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. 127 | * @param \ArrayObject $options The options passed to the save method. 128 | * @return void 129 | */ 130 | public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void 131 | { 132 | $newScope = $this->_getScope($entity); 133 | if ($newScope === false) { 134 | return; 135 | } 136 | 137 | $orderField = $this->getConfig('sequenceField'); 138 | $newOrder = $entity->get($orderField); 139 | 140 | // Adding 141 | if ($entity->isNew()) { 142 | // Order not specified 143 | if ($newOrder === null) { 144 | // Insert at end of list 145 | $entity->set($orderField, $this->_getHighestOrder($newScope) + 1); 146 | // Order specified 147 | } else { 148 | // Increment order of records it's inserted before 149 | $this->_sync( 150 | [$orderField => $this->_getUpdateExpression('+')], 151 | [$orderField . ' >=' => $newOrder], 152 | $newScope 153 | ); 154 | } 155 | 156 | // Editing 157 | } else { 158 | // No action if no new order or scope specified 159 | if ($newOrder === null && !$newScope) { 160 | return; 161 | } 162 | 163 | [$oldOrder, $oldScope] = $this->_getOldValues($entity); 164 | 165 | // No action if new and old scope and order same 166 | if ( 167 | $newOrder == $oldOrder && 168 | $newScope == $oldScope 169 | ) { 170 | return; 171 | } 172 | 173 | // If changing scope 174 | if ($newScope && $newScope != $oldScope) { 175 | // Decrement records in old scope with higher order than moved record old order 176 | $this->_sync( 177 | [$orderField => $this->_getUpdateExpression('-')], 178 | [$orderField . ' >' => $oldOrder], 179 | $oldScope 180 | ); 181 | 182 | // Order not specified 183 | if ($newOrder === null) { 184 | // Insert at end of new scope 185 | $entity->set( 186 | $orderField, 187 | $this->_getHighestOrder($newScope) + 1 188 | ); 189 | 190 | // Order specified 191 | } else { 192 | // Increment records in new scope with higher order than moved record new order 193 | $this->_sync( 194 | [$orderField => $this->_getUpdateExpression('+')], 195 | [$orderField . ' >=' => $newOrder], 196 | $newScope 197 | ); 198 | } 199 | // Same scope 200 | } else { 201 | // If moving up 202 | if ($newOrder < $oldOrder) { 203 | // Increment order of those in between 204 | $this->_sync( 205 | [$orderField => $this->_getUpdateExpression('+')], 206 | [ 207 | $orderField . ' >=' => $newOrder, 208 | $orderField . ' <' => $oldOrder, 209 | ], 210 | $newScope 211 | ); 212 | 213 | // Moving down 214 | } else { 215 | // Decrement order of those in between 216 | $this->_sync( 217 | [$orderField => $this->_getUpdateExpression('-')], 218 | [ 219 | $orderField . ' >' => $oldOrder, 220 | $orderField . ' <=' => $newOrder, 221 | ], 222 | $newScope 223 | ); 224 | } 225 | } 226 | } 227 | } 228 | 229 | /** 230 | * When you delete a record from a set, you need to decrement the order of all 231 | * records that were after it in the set. 232 | * 233 | * This hook just stores all required values from the entity. Actual order 234 | * updation is done in "afterDelete" hook. 235 | * 236 | * @param \Cake\Event\EventInterface $event The beforeDelete event that was fired. 237 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. 238 | * @return void 239 | */ 240 | public function beforeDelete(EventInterface $event, EntityInterface $entity): void 241 | { 242 | $this->_oldValues = $this->_getOldValues($entity); 243 | } 244 | 245 | /** 246 | * When you delete a record from a set, you need to decrement the order of all 247 | * records that were after it in the set. 248 | * 249 | * @param \Cake\Event\EventInterface $event The beforeDelete event that was fired. 250 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. 251 | * @return void 252 | */ 253 | public function afterDelete(EventInterface $event, EntityInterface $entity): void 254 | { 255 | if (!$this->_oldValues) { 256 | return; 257 | } 258 | 259 | $orderField = $this->_config['sequenceField']; 260 | [$order, $scope] = $this->_oldValues; 261 | 262 | $this->_sync( 263 | [$orderField => $this->_getUpdateExpression('-')], 264 | [$orderField . ' >' => $order], 265 | $scope 266 | ); 267 | 268 | $this->_oldValues = null; 269 | } 270 | 271 | /** 272 | * Decrease the position of the entity on the list 273 | * 274 | * If a "higher" entity exists, this will also swap positions with it 275 | * 276 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. 277 | * @return bool 278 | */ 279 | public function moveUp(EntityInterface $entity): bool 280 | { 281 | return $this->_movePosition($entity, '-'); 282 | } 283 | 284 | /** 285 | * Increase the position of the entity on the list 286 | * 287 | * If a "lower" entity exists, this will also swap positions with it 288 | * 289 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. 290 | * @return bool 291 | */ 292 | public function moveDown(EntityInterface $entity): bool 293 | { 294 | return $this->_movePosition($entity, '+'); 295 | } 296 | 297 | /** 298 | * Change the position of the entity on the list by a single position 299 | * 300 | * If an entity that conflicts with the new position already exists, this 301 | * will also swap positions with it 302 | * 303 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. 304 | * @param string $direction Whether to increment or decrement the field. 305 | * @return bool 306 | */ 307 | protected function _movePosition(EntityInterface $entity, string $direction = '+'): bool 308 | { 309 | if ($entity->isNew()) { 310 | return false; 311 | } 312 | 313 | $scope = $this->_getScope($entity); 314 | if ($scope === false) { 315 | return false; 316 | } 317 | 318 | $config = $this->getConfig(); 319 | $table = $this->_table; 320 | 321 | $table->removeBehavior('Sequence'); 322 | 323 | $return = $table->getConnection()->transactional( 324 | function ($connection) use ($table, $entity, $config, $scope, $direction) { 325 | $orderField = $config['sequenceField']; 326 | // Nothing to do if trying to move up entity already at first position 327 | if ($direction === '-' && $entity->get($orderField) === $config['startAt']) { 328 | return true; 329 | } 330 | 331 | $oldOrder = $entity->get($orderField); 332 | $newOrder = $entity->get($orderField) - 1; 333 | if ($direction === '+') { 334 | $newOrder = $entity->get($orderField) + 1; 335 | } 336 | 337 | /** @var \Cake\Datasource\EntityInterface|null $previousEntity */ 338 | $previousEntity = $table->find() 339 | ->where(array_merge($scope, [$orderField => $newOrder])) 340 | ->first(); 341 | 342 | if ($previousEntity !== null) { 343 | $previousEntity->set($orderField, $oldOrder); 344 | if (!$table->save($previousEntity, ['atomic' => false, 'checkRules' => false])) { 345 | return false; 346 | } 347 | // Nothing to do if trying to move down entity already at last position 348 | } elseif ($direction === '+') { 349 | return true; 350 | } 351 | 352 | $entity->set($orderField, $newOrder); 353 | 354 | return $table->save($entity, ['atomic' => false, 'checkRules' => false]); 355 | } 356 | ); 357 | 358 | $table->addBehavior('ADmad/Sequence.Sequence', $config); 359 | 360 | return (bool)$return; 361 | } 362 | 363 | /** 364 | * Set order for list of records provided. 365 | * 366 | * Records can be provided as array of entities or array of associative 367 | * arrays like `[['id' => 1], ['id' => 2]]` or array of primary key values 368 | * like `[1, 2]`. 369 | * 370 | * @param array $records Records. 371 | * @return bool 372 | */ 373 | public function setOrder(array $records): bool 374 | { 375 | $config = $this->getConfig(); 376 | $table = $this->_table; 377 | 378 | $table->removeBehavior('Sequence'); 379 | 380 | $return = $table->getConnection()->transactional( 381 | function ($connection) use ($table, $records) { 382 | $order = $this->_config['startAt']; 383 | $field = $this->_config['sequenceField']; 384 | 385 | /** @var string $primaryKeyField */ 386 | $primaryKeyField = $table->getPrimaryKey(); 387 | foreach ($records as $record) { 388 | if (is_scalar($record)) { 389 | $record = [$primaryKeyField => $record]; 390 | } 391 | 392 | if (is_array($record)) { 393 | $record = $table->newEntity($record, [ 394 | 'fields' => array_keys($record), 395 | 'validate' => false, 396 | 'accessibleFields' => [ 397 | $primaryKeyField => true, 398 | ], 399 | ]); 400 | $record->setNew(false); 401 | $record->setDirty($primaryKeyField, false); 402 | } 403 | 404 | $record->setAccess($field, true); 405 | $record->set($field, $order++); 406 | 407 | $r = $table->save( 408 | $record, 409 | ['atomic' => false, 'checkRules' => false] 410 | ); 411 | if ($r === false) { 412 | return false; 413 | } 414 | } 415 | 416 | return true; 417 | } 418 | ); 419 | 420 | $table->addBehavior('ADmad/Sequence.Sequence', $config); 421 | 422 | return $return; 423 | } 424 | 425 | /** 426 | * Get old order and scope values. 427 | * 428 | * @param \Cake\Datasource\EntityInterface $entity Entity. 429 | * @return array 430 | */ 431 | protected function _getOldValues(EntityInterface $entity): array 432 | { 433 | $config = $this->getConfig(); 434 | $fields = array_merge($config['scope'], [$config['sequenceField']]); 435 | 436 | $values = []; 437 | foreach ($fields as $field) { 438 | if ($entity->isDirty($field)) { 439 | $values[$field] = $entity->getOriginal($field); 440 | } elseif ($entity->has($field)) { 441 | $values[$field] = $entity->get($field); 442 | } 443 | } 444 | 445 | if (count($fields) != count($values)) { 446 | /** 447 | * @psalm-suppress PossiblyInvalidArgument 448 | * @phpstan-ignore-next-line 449 | */ 450 | $primaryKey = $entity->get($this->_table->getPrimaryKey()); 451 | $entity = $this->_table->get($primaryKey, fields: $fields); 452 | $values = $entity->extract($fields); 453 | } 454 | 455 | $order = $values[$config['sequenceField']]; 456 | unset($values[$config['sequenceField']]); 457 | 458 | foreach ($values as $field => $value) { 459 | if (is_null($value)) { 460 | $values[$field . ' IS'] = $value; 461 | unset($values[$field]); 462 | } 463 | } 464 | 465 | return [$order, $values]; 466 | } 467 | 468 | /** 469 | * Get scope values. 470 | * 471 | * @param \Cake\Datasource\EntityInterface $entity Entity. 472 | * @return array|false 473 | */ 474 | protected function _getScope(EntityInterface $entity): array|false 475 | { 476 | $scope = []; 477 | $config = $this->getConfig(); 478 | 479 | // If scope are specified and data for all scope fields is not 480 | // provided we cannot calculate new order 481 | if ($config['scope']) { 482 | $scope = $entity->extract($config['scope']); 483 | if (count($scope) !== count($config['scope'])) { 484 | return false; 485 | } 486 | 487 | // Modify where clauses when NULL values are used 488 | foreach ($scope as $field => $value) { 489 | if (is_null($value)) { 490 | $scope[$field . ' IS'] = $value; 491 | unset($scope[$field]); 492 | } 493 | } 494 | } 495 | 496 | return $scope; 497 | } 498 | 499 | /** 500 | * Returns the current highest order of all records in the set. When a new 501 | * record is added to the set, it is added at the current highest order, plus 502 | * one. 503 | * 504 | * @param array $scope Array with scope field => scope values, used for conditions. 505 | * @return int Value of order field of last record in set 506 | */ 507 | protected function _getHighestOrder(array $scope): int 508 | { 509 | $orderField = $this->_config['sequenceField']; 510 | 511 | // Find the last record in the set 512 | $last = $this->_table->find() 513 | ->select([$orderField]) 514 | ->where($scope) 515 | ->orderBy([$orderField => 'DESC']) 516 | ->limit(1) 517 | ->enableHydration(false) 518 | ->first(); 519 | 520 | // If there is a last record (i.e. any) in the set, return the it's order 521 | if ($last) { 522 | return $last[$orderField]; 523 | } 524 | 525 | // If there isn't any records in the set, return the start number minus 1 526 | return (int)$this->_config['startAt'] - 1; 527 | } 528 | 529 | /** 530 | * Auxiliary function used to alter the value of order fields by a certain 531 | * amount that match the passed conditions. 532 | * 533 | * @param array $fields Fields to update. 534 | * @param array $conditions Conditions for matching rows. 535 | * @param array $scope Grouping scope that will be added to coditions. 536 | * @return int Count of rows updated. 537 | */ 538 | protected function _sync(array $fields, array $conditions, ?array $scope = null): int 539 | { 540 | if ($scope) { 541 | $conditions = array_merge($conditions, $scope); 542 | } 543 | 544 | return $this->_table->updateAll($fields, $conditions); 545 | } 546 | 547 | /** 548 | * Returns the update expression for the order field. 549 | * 550 | * @param string $direction Whether to increment or decrement the field. 551 | * @return \Cake\Database\Expression\QueryExpression QueryExpression to modify the order field 552 | */ 553 | protected function _getUpdateExpression(string $direction = '+'): QueryExpression 554 | { 555 | $field = $this->_config['sequenceField']; 556 | 557 | return $this->_table->selectQuery()->newExpr() 558 | ->add(new IdentifierExpression($field)) 559 | ->add('1') 560 | ->setConjunction($direction); 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /src/SequencePlugin.php: -------------------------------------------------------------------------------- 1 |