Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.12% |
73 / 81 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| OrderableBehavior | |
90.12% |
73 / 81 |
|
66.67% |
2 / 3 |
23.51 | |
0.00% |
0 / 1 |
| initialize | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| reorder | |
86.21% |
50 / 58 |
|
0.00% |
0 / 1 |
21.05 | |||
| getTree | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | namespace App\Model\Behavior; |
| 5 | |
| 6 | use Cake\ORM\Behavior; |
| 7 | use Cake\ORM\Exception\PersistenceFailedException; |
| 8 | use InvalidArgumentException; |
| 9 | use LogicException; |
| 10 | use RuntimeException; |
| 11 | |
| 12 | /** |
| 13 | * OrderableBehavior provides hierarchical ordering capabilities for CakePHP models. |
| 14 | * |
| 15 | * This behavior automatically includes and configures the Tree behavior, providing |
| 16 | * methods to manage hierarchical data structures with ordering capabilities. |
| 17 | * It allows for: |
| 18 | * - Moving items between different levels of hierarchy |
| 19 | * - Reordering items within the same level |
| 20 | * - Root level and nested level ordering |
| 21 | * |
| 22 | * Required table columns (configurable via TreeBehavior): |
| 23 | * - parent_id (integer|null): References the parent record |
| 24 | * - lft (integer): Left value for nested set model |
| 25 | * - rght (integer): Right value for nested set model |
| 26 | * |
| 27 | * @property \Cake\ORM\Table $_table The table instance this behavior is attached to |
| 28 | */ |
| 29 | class OrderableBehavior extends Behavior |
| 30 | { |
| 31 | /** |
| 32 | * Default configuration for the behavior. |
| 33 | * |
| 34 | * Configuration options: |
| 35 | * - treeConfig: Configuration array for the Tree behavior |
| 36 | * - parent: The foreign key column for the parent (default: 'parent_id') |
| 37 | * - left: The column for the left value (default: 'lft') |
| 38 | * - right: The column for the right value (default: 'rght') |
| 39 | * - displayField: The field to use for display in the tree (default: null, uses table's displayField) |
| 40 | * |
| 41 | * @var array<string, mixed> |
| 42 | */ |
| 43 | protected array $_defaultConfig = [ |
| 44 | 'treeConfig' => [ |
| 45 | 'parent' => 'parent_id', |
| 46 | 'left' => 'lft', |
| 47 | 'right' => 'rght', |
| 48 | ], |
| 49 | 'displayField' => null, |
| 50 | ]; |
| 51 | |
| 52 | /** |
| 53 | * Initializes the behavior. |
| 54 | * |
| 55 | * Sets up the Tree behavior with merged configuration settings if it's not |
| 56 | * already present on the table. This ensures all necessary tree functionality |
| 57 | * is available for ordering operations. |
| 58 | * |
| 59 | * @param array $config Configuration array with optional 'treeConfig' key for |
| 60 | * customizing the Tree behavior settings. |
| 61 | * @return void |
| 62 | */ |
| 63 | public function initialize(array $config): void |
| 64 | { |
| 65 | parent::initialize($config); |
| 66 | |
| 67 | // Merge any provided tree configuration with defaults |
| 68 | $treeConfig = array_merge( |
| 69 | $this->_defaultConfig['treeConfig'], |
| 70 | $config['treeConfig'] ?? [], |
| 71 | ); |
| 72 | |
| 73 | // Add the Tree behavior if it's not already added |
| 74 | if (!$this->_table->hasBehavior('Tree')) { |
| 75 | $this->_table->addBehavior('Tree', $treeConfig); |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Reorders a model record within a hierarchical structure. |
| 81 | * |
| 82 | * This method handles both the parent reassignment and sibling reordering |
| 83 | * in a single operation, wrapped in a database transaction. It supports: |
| 84 | * - Moving an item to the root level |
| 85 | * - Moving an item under a new parent |
| 86 | * - Reordering items within their current level |
| 87 | * |
| 88 | * The method ensures proper tree structure maintenance through the Tree behavior |
| 89 | * and updates the nested set values accordingly. |
| 90 | * |
| 91 | * @param array $data An associative array containing: |
| 92 | * - 'id' (int|string): The ID of the record to be reordered. |
| 93 | * - 'newParentId' (mixed): The ID of the new parent record, or 'root' to move to the root level. |
| 94 | * - 'newIndex' (int): The new position index among siblings (zero-based). |
| 95 | * @throws \InvalidArgumentException If the provided data is missing required keys or has invalid types. |
| 96 | * @throws \Cake\Datasource\Exception\RecordNotFoundException If the record to move or target parent is not found. |
| 97 | * @throws \Cake\ORM\Exception\PersistenceFailedException If the record cannot be saved during parent update. |
| 98 | * @throws \RuntimeException If moving the item up/down fails. |
| 99 | * @throws \LogicException If the item cannot be found among its new siblings after parent move. |
| 100 | * @return bool Returns true on successful reordering. |
| 101 | */ |
| 102 | public function reorder(array $data): bool |
| 103 | { |
| 104 | $result = $this->_table->getConnection()->transactional(function () use ($data) { |
| 105 | if ( |
| 106 | !isset($data['id'], $data['newParentId'], $data['newIndex']) || |
| 107 | (!is_numeric($data['id']) && !is_string($data['id'])) || // ID can be int or string (e.g. UUID) |
| 108 | !is_numeric($data['newIndex']) |
| 109 | ) { |
| 110 | throw new InvalidArgumentException( |
| 111 | 'Required data (id, newParentId, newIndex) missing or newIndex is not numeric for reorder.', |
| 112 | ); |
| 113 | } |
| 114 | if ( |
| 115 | $data['newParentId'] !== 'root' |
| 116 | && !is_numeric($data['newParentId']) |
| 117 | && !is_string($data['newParentId']) |
| 118 | ) { |
| 119 | throw new InvalidArgumentException('newParentId must be "root", numeric, or string.'); |
| 120 | } |
| 121 | |
| 122 | $itemId = $data['id']; |
| 123 | $newParentIdentifier = $data['newParentId']; |
| 124 | $newIndex = (int)$data['newIndex']; |
| 125 | |
| 126 | /** @var \Cake\ORM\Entity&\Cake\ORM\Behavior\Tree\NodeInterface $item */ |
| 127 | $item = $this->_table->get($itemId); // Throws RecordNotFoundException if not found |
| 128 | |
| 129 | $actualNewParentId = $newParentIdentifier === 'root' ? null : $newParentIdentifier; |
| 130 | $parentField = $this->getConfig('treeConfig.parent'); |
| 131 | |
| 132 | if ($item->get($parentField) !== $actualNewParentId) { |
| 133 | if ($actualNewParentId !== null) { |
| 134 | // Ensure the new parent exists (unless it's root) |
| 135 | $this->_table->get($actualNewParentId); // Throws RecordNotFoundException |
| 136 | $item->set($parentField, $actualNewParentId); |
| 137 | } else { |
| 138 | $item->set($parentField, null); |
| 139 | } |
| 140 | |
| 141 | if (!$this->_table->save($item, ['checkRules' => false])) { |
| 142 | throw new PersistenceFailedException($item, ['Save failed during parent update.']); |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // Adjust position among new siblings |
| 147 | $siblingsQuery = null; |
| 148 | $parentFieldValue = $item->get($parentField); // Get parent_id value |
| 149 | |
| 150 | if ($parentFieldValue === null) { // Item is now a root node |
| 151 | $siblingsQuery = $this->_table->find() |
| 152 | ->where([$this->_table->aliasField($parentField) . ' IS' => null]); |
| 153 | } else { // Item has a parent |
| 154 | $siblingsQuery = $this->_table->find( |
| 155 | 'children', |
| 156 | for: $parentFieldValue, // Use named argument 'for' |
| 157 | direct: true, // Use named argument 'direct' |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | $leftField = $this->getConfig('treeConfig.left'); |
| 162 | $siblings = $siblingsQuery->orderBy([$this->_table->aliasField($leftField) => 'ASC']) |
| 163 | ->all() |
| 164 | ->toArray(); |
| 165 | |
| 166 | $currentPosition = false; |
| 167 | $primaryKeyField = (array)$this->_table->getPrimaryKey(); // getPrimaryKey can return string or array |
| 168 | $primaryKeyField = reset($primaryKeyField); // Use the first primary key |
| 169 | |
| 170 | foreach ($siblings as $index => $sibling) { |
| 171 | if ($sibling->get($primaryKeyField) == $item->get($primaryKeyField)) { |
| 172 | $currentPosition = $index; |
| 173 | break; |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | if ($currentPosition === false) { |
| 178 | throw new LogicException("Moved item not found among its new siblings. Item ID: {$itemId}"); |
| 179 | } |
| 180 | |
| 181 | $targetPosition = $newIndex; |
| 182 | |
| 183 | if ($currentPosition !== $targetPosition) { |
| 184 | $distance = abs($targetPosition - $currentPosition); |
| 185 | if ($targetPosition > $currentPosition) { // Moving down the list |
| 186 | // TreeBehavior's moveDown moves it $number positions *lower* (larger lft) |
| 187 | if (!$this->_table->moveDown($item, $distance)) { |
| 188 | throw new RuntimeException("Failed to move item ID {$itemId} down by {$distance} positions."); |
| 189 | } |
| 190 | } else { // Moving up the list |
| 191 | // TreeBehavior's moveUp moves it $number positions *higher* (smaller lft) |
| 192 | if (!$this->_table->moveUp($item, $distance)) { |
| 193 | throw new RuntimeException("Failed to move item ID {$itemId} up by {$distance} positions."); |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | return true; // Transactional callback succeeded |
| 199 | }); |
| 200 | |
| 201 | return $result; |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * Gets a hierarchical tree structure of records. |
| 206 | * |
| 207 | * Retrieves records in a threaded format, including essential fields for tree structure |
| 208 | * and any additional conditions specified. |
| 209 | * |
| 210 | * @param array $additionalConditions Additional conditions to apply to the query. |
| 211 | * @param array $fields Additional fields to select (beyond id, parent_id, and displayField). |
| 212 | * @return array<\Cake\Datasource\EntityInterface> Array of entities in threaded format. |
| 213 | */ |
| 214 | public function getTree(array $additionalConditions = [], array $fields = []): array |
| 215 | { |
| 216 | $displayField = $this->getConfig('displayField') ?? $this->_table->getDisplayField(); |
| 217 | $parentFieldKey = $this->getConfig('treeConfig.parent'); |
| 218 | $leftFieldKey = $this->getConfig('treeConfig.left'); |
| 219 | $primaryKey = (array)$this->_table->getPrimaryKey(); |
| 220 | $primaryKeyField = reset($primaryKey); |
| 221 | |
| 222 | // Base fields that are always needed for tree structure |
| 223 | $baseFields = [ |
| 224 | $this->_table->aliasField($primaryKeyField), |
| 225 | $this->_table->aliasField($parentFieldKey), |
| 226 | $this->_table->aliasField($displayField), |
| 227 | ]; |
| 228 | |
| 229 | // Merge base fields with any additional fields |
| 230 | $selectFields = array_unique(array_merge($baseFields, $fields)); |
| 231 | |
| 232 | $query = $this->_table->find() |
| 233 | ->select($selectFields) |
| 234 | ->where($additionalConditions) |
| 235 | ->orderBy([$this->_table->aliasField($leftFieldKey) => 'ASC']); |
| 236 | |
| 237 | return $query->find('threaded')->toArray(); |
| 238 | } |
| 239 | } |