Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.12% covered (success)
90.12%
73 / 81
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
OrderableBehavior
90.12% covered (success)
90.12%
73 / 81
66.67% covered (warning)
66.67%
2 / 3
23.51
0.00% covered (danger)
0.00%
0 / 1
 initialize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 reorder
86.21% covered (warning)
86.21%
50 / 58
0.00% covered (danger)
0.00%
0 / 1
21.05
 getTree
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Model\Behavior;
5
6use Cake\ORM\Behavior;
7use Cake\ORM\Exception\PersistenceFailedException;
8use InvalidArgumentException;
9use LogicException;
10use 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 */
29class 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}