1 | <?php |
||
2 | /** |
||
3 | * @link https://github.com/paulzi/yii2-materialized-path |
||
4 | * @copyright Copyright (c) 2015 PaulZi <[email protected]> |
||
5 | * @license MIT (https://github.com/paulzi/yii2-materialized-path/blob/master/LICENSE) |
||
6 | */ |
||
7 | |||
8 | namespace paulzi\materializedPath; |
||
9 | |||
10 | use paulzi\sortable\SortableBehavior; |
||
11 | use Yii; |
||
12 | use yii\base\Behavior; |
||
13 | use yii\base\Exception; |
||
14 | use yii\base\NotSupportedException; |
||
15 | use yii\db\ActiveRecord; |
||
16 | use yii\db\Expression; |
||
17 | |||
18 | /** |
||
19 | * Materialized Path Behavior for Yii2 |
||
20 | * @author PaulZi <[email protected]> |
||
21 | * |
||
22 | * @property ActiveRecord $owner |
||
23 | */ |
||
24 | class MaterializedPathBehavior extends Behavior |
||
25 | { |
||
26 | const OPERATION_MAKE_ROOT = 1; |
||
27 | const OPERATION_PREPEND_TO = 2; |
||
28 | const OPERATION_APPEND_TO = 3; |
||
29 | const OPERATION_INSERT_BEFORE = 4; |
||
30 | const OPERATION_INSERT_AFTER = 5; |
||
31 | const OPERATION_DELETE_ALL = 6; |
||
32 | |||
33 | |||
34 | /** |
||
35 | * @var string |
||
36 | */ |
||
37 | public $pathAttribute = 'path'; |
||
38 | |||
39 | /** |
||
40 | * @var string |
||
41 | */ |
||
42 | public $depthAttribute = 'depth'; |
||
43 | |||
44 | /** |
||
45 | * @var string |
||
46 | */ |
||
47 | public $itemAttribute; |
||
48 | |||
49 | /** |
||
50 | * @var string|null |
||
51 | */ |
||
52 | public $treeAttribute; |
||
53 | |||
54 | /** |
||
55 | * @var array|false SortableBehavior config |
||
56 | */ |
||
57 | public $sortable = []; |
||
58 | |||
59 | /** |
||
60 | * @var string |
||
61 | */ |
||
62 | public $delimiter = '/'; |
||
63 | |||
64 | /** |
||
65 | * @var int Value of $depthAttribute for root node. |
||
66 | */ |
||
67 | public $rootDepthValue = 0; |
||
68 | |||
69 | /** |
||
70 | * @var int|null |
||
71 | */ |
||
72 | protected $operation; |
||
73 | |||
74 | /** |
||
75 | * @var ActiveRecord|self|null |
||
76 | */ |
||
77 | protected $node; |
||
78 | |||
79 | /** |
||
80 | * @var SortableBehavior |
||
81 | */ |
||
82 | protected $behavior; |
||
83 | |||
84 | /** |
||
85 | * @var bool |
||
86 | */ |
||
87 | protected $primaryKeyMode = false; |
||
88 | |||
89 | |||
90 | /** |
||
91 | * @inheritdoc |
||
92 | */ |
||
93 | 201 | public function events() |
|
94 | { |
||
95 | return [ |
||
96 | 201 | ActiveRecord::EVENT_BEFORE_INSERT => 'beforeSave', |
|
97 | ActiveRecord::EVENT_AFTER_INSERT => 'afterInsert', |
||
98 | ActiveRecord::EVENT_BEFORE_UPDATE => 'beforeSave', |
||
99 | ActiveRecord::EVENT_AFTER_UPDATE => 'afterUpdate', |
||
100 | ActiveRecord::EVENT_BEFORE_DELETE => 'beforeDelete', |
||
101 | ActiveRecord::EVENT_AFTER_DELETE => 'afterDelete', |
||
102 | ]; |
||
103 | } |
||
104 | |||
105 | /** |
||
106 | * @param ActiveRecord $owner |
||
107 | * @throws Exception |
||
108 | */ |
||
109 | 201 | public function attach($owner) |
|
110 | { |
||
111 | 201 | parent::attach($owner); |
|
112 | 201 | if ($this->itemAttribute === null) { |
|
113 | 201 | $primaryKey = $owner->primaryKey(); |
|
114 | 201 | if (!isset($primaryKey[0])) { |
|
115 | throw new Exception('"' . $owner->className() . '" must have a primary key.'); |
||
116 | } |
||
117 | 201 | $this->itemAttribute = $primaryKey[0]; |
|
118 | 201 | $this->primaryKeyMode = true; |
|
119 | } |
||
120 | 201 | if ($this->sortable !== false) { |
|
121 | 201 | $this->behavior = Yii::createObject(array_merge( |
|
122 | [ |
||
123 | 201 | 'class' => SortableBehavior::className(), |
|
124 | 'query' => function () { |
||
125 | 87 | return $this->getSortableQuery(); |
|
126 | 201 | }, |
|
127 | ], |
||
128 | 201 | $this->sortable |
|
129 | )); |
||
130 | 201 | $owner->attachBehavior('materialized-path-sortable', $this->behavior); |
|
131 | } |
||
132 | 201 | } |
|
133 | |||
134 | /** |
||
135 | * @param int|null $depth |
||
136 | * @return \yii\db\ActiveQuery |
||
137 | */ |
||
138 | 9 | public function getParents($depth = null) |
|
139 | { |
||
140 | 9 | $path = $this->getParentPath(); |
|
141 | 9 | if ($path !== null) { |
|
142 | 9 | $paths = explode($this->delimiter, $path); |
|
143 | 9 | if (!$this->primaryKeyMode) { |
|
144 | 9 | $path = null; |
|
145 | 9 | $paths = array_map( |
|
146 | 9 | function ($value) use (&$path) { |
|
147 | 9 | return $path = ($path !== null ? $path . $this->delimiter : '') . $value; |
|
148 | 9 | }, |
|
149 | 9 | $paths |
|
150 | ); |
||
151 | } |
||
152 | 9 | if ($depth !== null) { |
|
153 | 9 | $paths = array_slice($paths, -$depth); |
|
154 | } |
||
155 | } else { |
||
156 | 3 | $paths = []; |
|
157 | } |
||
158 | |||
159 | 9 | $tableName = $this->owner->tableName(); |
|
160 | 9 | $condition = ['and']; |
|
161 | 9 | if ($this->primaryKeyMode) { |
|
162 | 6 | $condition[] = ["{$tableName}.[[{$this->itemAttribute}]]" => $paths]; |
|
163 | } else { |
||
164 | 9 | $condition[] = ["{$tableName}.[[{$this->pathAttribute}]]" => $paths]; |
|
165 | } |
||
166 | |||
167 | 9 | $query = $this->owner->find() |
|
168 | 9 | ->andWhere($condition) |
|
169 | 9 | ->andWhere($this->treeCondition()) |
|
170 | 9 | ->addOrderBy(["{$tableName}.[[{$this->pathAttribute}]]" => SORT_ASC]); |
|
171 | 9 | $query->multiple = true; |
|
172 | |||
173 | 9 | return $query; |
|
174 | } |
||
175 | |||
176 | /** |
||
177 | * @return \yii\db\ActiveQuery |
||
178 | */ |
||
179 | 6 | public function getParent() |
|
180 | { |
||
181 | 6 | $query = $this->getParents(1)->limit(1); |
|
182 | 6 | $query->multiple = false; |
|
183 | 6 | return $query; |
|
184 | } |
||
185 | |||
186 | /** |
||
187 | * @return \yii\db\ActiveQuery |
||
188 | */ |
||
189 | 3 | public function getRoot() |
|
190 | { |
||
191 | 3 | $path = explode($this->delimiter, $this->owner->getAttribute($this->pathAttribute)); |
|
192 | 3 | $path = array_shift($path); |
|
193 | 3 | $tableName = $this->owner->tableName(); |
|
194 | 3 | $query = $this->owner->find() |
|
195 | 3 | ->andWhere(["{$tableName}.[[{$this->pathAttribute}]]" => $path]) |
|
196 | 3 | ->andWhere($this->treeCondition()) |
|
197 | 3 | ->limit(1); |
|
198 | 3 | $query->multiple = false; |
|
199 | 3 | return $query; |
|
200 | } |
||
201 | |||
202 | /** |
||
203 | * @param int|null $depth |
||
204 | * @param bool $andSelf |
||
205 | * @return \yii\db\ActiveQuery |
||
206 | */ |
||
207 | 27 | public function getDescendants($depth = null, $andSelf = false) |
|
208 | { |
||
209 | 27 | $tableName = $this->owner->tableName(); |
|
210 | 27 | $path = $this->owner->getAttribute($this->pathAttribute); |
|
211 | 27 | $query = $this->owner->find() |
|
212 | 27 | ->andWhere(['like', "{$tableName}.[[{$this->pathAttribute}]]", $this->getLike($path), false]); |
|
213 | |||
214 | 27 | if ($andSelf) { |
|
215 | 9 | $query->orWhere(["{$tableName}.[[{$this->pathAttribute}]]" => $path]); |
|
216 | } |
||
217 | |||
218 | 27 | if ($depth !== null) { |
|
219 | 21 | $query->andWhere(['<=', "{$tableName}.[[{$this->depthAttribute}]]", $this->owner->getAttribute($this->depthAttribute) + $depth]); |
|
220 | } |
||
221 | |||
222 | 27 | $orderBy = []; |
|
223 | 27 | $orderBy["{$tableName}.[[{$this->depthAttribute}]]"] = SORT_ASC; |
|
224 | 27 | View Code Duplication | if ($this->sortable !== false) { |
225 | 27 | $orderBy["{$tableName}.[[{$this->behavior->sortAttribute}]]"] = SORT_ASC; |
|
226 | } |
||
227 | 27 | $orderBy["{$tableName}.[[{$this->itemAttribute}]]"] = SORT_ASC; |
|
228 | |||
229 | $query |
||
230 | 27 | ->andWhere($this->treeCondition()) |
|
231 | 27 | ->addOrderBy($orderBy); |
|
232 | 27 | $query->multiple = true; |
|
233 | |||
234 | 27 | return $query; |
|
235 | } |
||
236 | |||
237 | /** |
||
238 | * @return \yii\db\ActiveQuery |
||
239 | */ |
||
240 | 12 | public function getChildren() |
|
241 | { |
||
242 | 12 | return $this->getDescendants(1); |
|
243 | } |
||
244 | |||
245 | /** |
||
246 | * @param int|null $depth |
||
247 | * @return \yii\db\ActiveQuery |
||
248 | */ |
||
249 | 3 | public function getLeaves($depth = null) |
|
250 | { |
||
251 | 3 | $tableName = $this->owner->tableName(); |
|
252 | $condition = [ |
||
253 | 3 | 'and', |
|
254 | 3 | ['like', "leaves.[[{$this->pathAttribute}]]", new Expression($this->concatExpression(["{$tableName}.[[{$this->pathAttribute}]]", ':delimiter']), [':delimiter' => $this->delimiter . '%'])], |
|
255 | ]; |
||
256 | |||
257 | 3 | if ($this->treeAttribute !== null) { |
|
258 | 3 | $condition[] = ["leaves.[[{$this->treeAttribute}]]" => new Expression("{$tableName}.[[{$this->treeAttribute}]]")]; |
|
259 | } |
||
260 | |||
261 | 3 | $query = $this->getDescendants($depth) |
|
262 | 3 | ->leftJoin("{$tableName} leaves", $condition) |
|
263 | 3 | ->andWhere(["leaves.[[{$this->pathAttribute}]]" => null]); |
|
264 | 3 | $query->multiple = true; |
|
265 | 3 | return $query; |
|
266 | } |
||
267 | |||
268 | /** |
||
269 | * @return \yii\db\ActiveQuery |
||
270 | * @throws NotSupportedException |
||
271 | */ |
||
272 | 3 | View Code Duplication | public function getPrev() |
273 | { |
||
274 | 3 | if ($this->sortable === false) { |
|
275 | throw new NotSupportedException('prev() not allow if not set sortable'); |
||
276 | } |
||
277 | 3 | $tableName = $this->owner->tableName(); |
|
278 | 3 | $query = $this->owner->find() |
|
279 | 3 | ->andWhere(['like', "{$tableName}.[[{$this->pathAttribute}]]", $this->getLike($this->getParentPath()), false]) |
|
280 | 3 | ->andWhere(["{$tableName}.[[{$this->depthAttribute}]]" => $this->owner->getAttribute($this->depthAttribute)]) |
|
281 | 3 | ->andWhere(['<', "{$tableName}.[[{$this->behavior->sortAttribute}]]", $this->owner->getSortablePosition()]) |
|
282 | 3 | ->andWhere($this->treeCondition()) |
|
283 | 3 | ->orderBy(["{$tableName}.[[{$this->behavior->sortAttribute}]]" => SORT_DESC]) |
|
284 | 3 | ->limit(1); |
|
285 | 3 | $query->multiple = false; |
|
286 | 3 | return $query; |
|
287 | } |
||
288 | |||
289 | /** |
||
290 | * @return \yii\db\ActiveQuery |
||
291 | * @throws NotSupportedException |
||
292 | */ |
||
293 | 6 | View Code Duplication | public function getNext() |
294 | { |
||
295 | 6 | if ($this->sortable === false) { |
|
296 | throw new NotSupportedException('prev() not allow if not set sortable'); |
||
297 | } |
||
298 | 6 | $tableName = $this->owner->tableName(); |
|
299 | 6 | $query = $this->owner->find() |
|
300 | 6 | ->andWhere(['like', "{$tableName}.[[{$this->pathAttribute}]]", $this->getLike($this->getParentPath()), false]) |
|
301 | 6 | ->andWhere(["{$tableName}.[[{$this->depthAttribute}]]" => $this->owner->getAttribute($this->depthAttribute)]) |
|
302 | 6 | ->andWhere(['>', "{$tableName}.[[{$this->behavior->sortAttribute}]]", $this->owner->getSortablePosition()]) |
|
303 | 6 | ->andWhere($this->treeCondition()) |
|
304 | 6 | ->orderBy(["{$tableName}.[[{$this->behavior->sortAttribute}]]" => SORT_ASC]) |
|
305 | 6 | ->limit(1); |
|
306 | 6 | $query->multiple = false; |
|
307 | 6 | return $query; |
|
308 | } |
||
309 | |||
310 | /** |
||
311 | * Returns all sibilings of node. |
||
312 | * |
||
313 | * @param bool $andSelf = false Include self node into result. |
||
314 | * @return \yii\db\ActiveQuery |
||
315 | */ |
||
316 | public function getSiblings($andSelf = false) |
||
317 | { |
||
318 | $tableName = $this->owner->tableName(); |
||
319 | $path = $this->getParentPath(); |
||
320 | $like = strtr($path . $this->delimiter, ['%' => '\%', '_' => '\_', '\\' => '\\\\']); |
||
321 | |||
322 | $query = $this->owner->find() |
||
323 | ->andWhere(['like', "{$tableName}.[[{$this->pathAttribute}]]", $like . '%', false]) |
||
324 | ->andWhere(['<=', "{$tableName}.[[{$this->depthAttribute}]]", $this->owner->{$this->depthAttribute}]); |
||
325 | |||
326 | if (!$andSelf) { |
||
327 | $query->andWhere(["!=", "{$tableName}.[[{$this->itemAttribute}]]", $this->owner->{$this->itemAttribute}]); |
||
328 | } |
||
329 | |||
330 | $orderBy = []; |
||
331 | $orderBy["{$tableName}.[[{$this->depthAttribute}]]"] = SORT_ASC; |
||
332 | View Code Duplication | if ($this->sortable !== false) { |
|
333 | $orderBy["{$tableName}.[[{$this->behavior->sortAttribute}]]"] = SORT_ASC; |
||
334 | } |
||
335 | $orderBy["{$tableName}.[[{$this->itemAttribute}]]"] = SORT_ASC; |
||
336 | |||
337 | $query |
||
338 | ->andWhere($this->treeCondition()) |
||
339 | ->addOrderBy($orderBy); |
||
340 | $query->multiple = true; |
||
341 | return $query; |
||
342 | } |
||
343 | |||
344 | /** |
||
345 | * @param bool $asArray = false |
||
346 | * @return null|string|array |
||
347 | */ |
||
348 | 66 | public function getParentPath($asArray = false) |
|
349 | { |
||
350 | 66 | return static::getParentPathInternal($this->owner->getAttribute($this->pathAttribute), $this->delimiter, $asArray); |
|
351 | } |
||
352 | |||
353 | /** |
||
354 | * Populate children relations for self and all descendants |
||
355 | * |
||
356 | * @param int $depth = null |
||
357 | * @param string|array $with = null |
||
358 | * @return static |
||
359 | */ |
||
360 | 3 | public function populateTree($depth = null, $with = null) |
|
361 | { |
||
362 | /** @var ActiveRecord[]|static[] $nodes */ |
||
363 | 3 | $query = $this->getDescendants($depth); |
|
364 | 3 | if ($with) { |
|
365 | $query->with($with); |
||
366 | } |
||
367 | 3 | $nodes = $query->all(); |
|
368 | |||
369 | 3 | $relates = []; |
|
370 | 3 | foreach ($nodes as $node) { |
|
371 | 3 | $path = $node->getParentPath(true); |
|
372 | 3 | $key = array_pop($path); |
|
373 | 3 | if (!isset($relates[$key])) { |
|
374 | 3 | $relates[$key] = []; |
|
375 | } |
||
376 | 3 | $relates[$key][] = $node; |
|
377 | } |
||
378 | |||
379 | 3 | $ownerDepth = $this->owner->getAttribute($this->depthAttribute); |
|
380 | 3 | $nodes[] = $this->owner; |
|
381 | 3 | foreach ($nodes as $node) { |
|
382 | 3 | $key = $node->getAttribute($this->itemAttribute); |
|
383 | 3 | if (isset($relates[$key])) { |
|
384 | 3 | $node->populateRelation('children', $relates[$key]); |
|
385 | 3 | } elseif ($depth === null || $ownerDepth + $depth > $node->getAttribute($this->depthAttribute)) { |
|
386 | 3 | $node->populateRelation('children', []); |
|
387 | } |
||
388 | } |
||
389 | |||
390 | 3 | return $this->owner; |
|
391 | } |
||
392 | |||
393 | /** |
||
394 | * @return bool |
||
395 | */ |
||
396 | 72 | public function isRoot() |
|
397 | { |
||
398 | 72 | return count(explode($this->delimiter, $this->owner->getAttribute($this->pathAttribute))) === 1; |
|
399 | } |
||
400 | |||
401 | /** |
||
402 | * @param ActiveRecord $node |
||
403 | * @return bool |
||
404 | */ |
||
405 | 96 | public function isChildOf($node) |
|
406 | { |
||
407 | 96 | if ($node->getIsNewRecord()) { |
|
408 | 30 | return false; |
|
409 | } |
||
410 | 66 | $nodePath = $node->getAttribute($this->pathAttribute) . $this->delimiter; |
|
411 | 66 | $result = substr($this->owner->getAttribute($this->pathAttribute), 0, strlen($nodePath)) === $nodePath; |
|
412 | |||
413 | 66 | if ($result && $this->treeAttribute !== null) { |
|
414 | 9 | $result = $this->owner->getAttribute($this->treeAttribute) === $node->getAttribute($this->treeAttribute); |
|
415 | } |
||
416 | |||
417 | 66 | return $result; |
|
418 | } |
||
419 | |||
420 | /** |
||
421 | * @return bool |
||
422 | */ |
||
423 | 3 | public function isLeaf() |
|
424 | { |
||
425 | 3 | return count($this->owner->children) === 0; |
|
426 | } |
||
427 | |||
428 | /** |
||
429 | * @return ActiveRecord |
||
430 | */ |
||
431 | 6 | public function makeRoot() |
|
432 | { |
||
433 | 6 | $this->operation = self::OPERATION_MAKE_ROOT; |
|
434 | 6 | return $this->owner; |
|
435 | } |
||
436 | |||
437 | /** |
||
438 | * @param ActiveRecord $node |
||
439 | * @return ActiveRecord |
||
440 | */ |
||
441 | 33 | public function prependTo($node) |
|
442 | { |
||
443 | 33 | $this->operation = self::OPERATION_PREPEND_TO; |
|
444 | 33 | $this->node = $node; |
|
445 | 33 | return $this->owner; |
|
446 | } |
||
447 | |||
448 | /** |
||
449 | * @param ActiveRecord $node |
||
450 | * @return ActiveRecord |
||
451 | */ |
||
452 | 33 | public function appendTo($node) |
|
453 | { |
||
454 | 33 | $this->operation = self::OPERATION_APPEND_TO; |
|
455 | 33 | $this->node = $node; |
|
456 | 33 | return $this->owner; |
|
457 | } |
||
458 | |||
459 | /** |
||
460 | * @param ActiveRecord $node |
||
461 | * @return ActiveRecord |
||
462 | */ |
||
463 | 30 | public function insertBefore($node) |
|
464 | { |
||
465 | 30 | $this->operation = self::OPERATION_INSERT_BEFORE; |
|
466 | 30 | $this->node = $node; |
|
467 | 30 | return $this->owner; |
|
468 | } |
||
469 | |||
470 | /** |
||
471 | * @param ActiveRecord $node |
||
472 | * @return ActiveRecord |
||
473 | */ |
||
474 | 30 | public function insertAfter($node) |
|
475 | { |
||
476 | 30 | $this->operation = self::OPERATION_INSERT_AFTER; |
|
477 | 30 | $this->node = $node; |
|
478 | 30 | return $this->owner; |
|
479 | } |
||
480 | |||
481 | /** |
||
482 | * Need for paulzi/auto-tree |
||
483 | */ |
||
484 | public function preDeleteWithChildren() |
||
485 | { |
||
486 | $this->operation = self::OPERATION_DELETE_ALL; |
||
487 | } |
||
488 | |||
489 | /** |
||
490 | * @return bool|int |
||
491 | * @throws \Exception |
||
492 | * @throws \yii\db\Exception |
||
493 | */ |
||
494 | 9 | public function deleteWithChildren() |
|
495 | { |
||
496 | 9 | $this->operation = self::OPERATION_DELETE_ALL; |
|
497 | 9 | if (!$this->owner->isTransactional(ActiveRecord::OP_DELETE)) { |
|
498 | $transaction = $this->owner->getDb()->beginTransaction(); |
||
499 | try { |
||
500 | $result = $this->deleteWithChildrenInternal(); |
||
501 | if ($result === false) { |
||
502 | $transaction->rollBack(); |
||
503 | } else { |
||
504 | $transaction->commit(); |
||
505 | } |
||
506 | return $result; |
||
507 | } catch (\Exception $e) { |
||
508 | $transaction->rollBack(); |
||
509 | throw $e; |
||
510 | } |
||
511 | } else { |
||
512 | 9 | $result = $this->deleteWithChildrenInternal(); |
|
513 | } |
||
514 | 6 | return $result; |
|
515 | } |
||
516 | |||
517 | /** |
||
518 | * @param bool $middle |
||
519 | * @return int |
||
520 | */ |
||
521 | 3 | public function reorderChildren($middle = true) |
|
522 | { |
||
523 | /** @var ActiveRecord|SortableBehavior $item */ |
||
524 | 3 | $item = count($this->owner->children) > 0 ? $this->owner->children[0] : null; |
|
525 | 3 | if ($item) { |
|
526 | 3 | return $item->reorder($middle); |
|
527 | } else { |
||
528 | return 0; |
||
529 | } |
||
530 | } |
||
531 | |||
532 | /** |
||
533 | * @throws Exception |
||
534 | * @throws NotSupportedException |
||
535 | */ |
||
536 | 138 | public function beforeSave() |
|
537 | { |
||
538 | 138 | if ($this->node !== null && !$this->node->getIsNewRecord()) { |
|
539 | 108 | $this->node->refresh(); |
|
540 | } |
||
541 | |||
542 | 138 | switch ($this->operation) { |
|
543 | 138 | case self::OPERATION_MAKE_ROOT: |
|
544 | 6 | $this->makeRootInternal(); |
|
545 | |||
546 | 6 | break; |
|
547 | 132 | case self::OPERATION_PREPEND_TO: |
|
548 | 33 | $this->insertIntoInternal(false); |
|
549 | |||
550 | 21 | break; |
|
551 | 99 | case self::OPERATION_APPEND_TO: |
|
552 | 33 | $this->insertIntoInternal(true); |
|
553 | |||
554 | 21 | break; |
|
555 | 66 | case self::OPERATION_INSERT_BEFORE: |
|
556 | 30 | $this->insertNearInternal(false); |
|
557 | |||
558 | 21 | break; |
|
559 | |||
560 | 36 | case self::OPERATION_INSERT_AFTER: |
|
561 | 30 | $this->insertNearInternal(true); |
|
562 | |||
563 | 18 | break; |
|
564 | |||
565 | default: |
||
566 | 6 | if ($this->owner->getIsNewRecord()) { |
|
567 | 3 | throw new NotSupportedException('Method "' . $this->owner->className() . '::insert" is not supported for inserting new nodes.'); |
|
568 | } |
||
569 | |||
570 | 3 | $item = $this->owner->getAttribute($this->itemAttribute); |
|
571 | 3 | $path = $this->getParentPath(); |
|
572 | 3 | $this->owner->setAttribute($this->pathAttribute, ($path !== null ? $path . $this->delimiter : null) . $item); |
|
573 | } |
||
574 | 90 | } |
|
575 | |||
576 | /** |
||
577 | * @throws Exception |
||
578 | */ |
||
579 | 33 | public function afterInsert() |
|
580 | { |
||
581 | 33 | if ($this->operation === self::OPERATION_MAKE_ROOT && $this->treeAttribute !== null && $this->owner->getAttribute($this->treeAttribute) === null) { |
|
582 | 3 | $id = $this->getPrimaryKeyValue(); |
|
583 | 3 | $this->owner->setAttribute($this->treeAttribute, $id); |
|
584 | |||
585 | 3 | $primaryKey = $this->owner->primaryKey(); |
|
586 | 3 | View Code Duplication | if (!isset($primaryKey[0])) { |
587 | throw new Exception('"' . $this->owner->className() . '" must have a primary key.'); |
||
588 | } |
||
589 | |||
590 | 3 | $this->owner->updateAll([$this->treeAttribute => $id], [$primaryKey[0] => $id]); |
|
591 | } |
||
592 | 33 | if ($this->owner->getAttribute($this->pathAttribute) === null) { |
|
593 | 33 | $primaryKey = $this->owner->primaryKey(); |
|
594 | 33 | View Code Duplication | if (!isset($primaryKey[0])) { |
595 | throw new Exception('"' . $this->owner->className() . '" must have a primary key.'); |
||
596 | } |
||
597 | 33 | $id = $this->getPrimaryKeyValue(); |
|
598 | 33 | if ($this->operation === self::OPERATION_MAKE_ROOT) { |
|
599 | 3 | $path = $id; |
|
600 | } else { |
||
601 | 30 | if ($this->operation === self::OPERATION_INSERT_BEFORE || $this->operation === self::OPERATION_INSERT_AFTER) { |
|
602 | 18 | $path = $this->node->getParentPath(); |
|
603 | } else { |
||
604 | 12 | $path = $this->node->getAttribute($this->pathAttribute); |
|
605 | } |
||
606 | 30 | $path = $path . $this->delimiter . $id; |
|
607 | } |
||
608 | 33 | $this->owner->setAttribute($this->pathAttribute, $path); |
|
609 | 33 | $this->owner->updateAll([$this->pathAttribute => $path], [$primaryKey[0] => $id]); |
|
610 | } |
||
611 | 33 | $this->operation = null; |
|
612 | 33 | $this->node = null; |
|
613 | 33 | } |
|
614 | |||
615 | /** |
||
616 | * @param \yii\db\AfterSaveEvent $event |
||
617 | */ |
||
618 | 57 | public function afterUpdate($event) |
|
619 | { |
||
620 | 57 | $this->moveNode($event->changedAttributes); |
|
621 | 57 | $this->operation = null; |
|
622 | 57 | $this->node = null; |
|
623 | 57 | } |
|
624 | |||
625 | /** |
||
626 | * @param \yii\base\ModelEvent $event |
||
627 | * @throws Exception |
||
628 | */ |
||
629 | 18 | public function beforeDelete($event) |
|
630 | { |
||
631 | 18 | if ($this->owner->getIsNewRecord()) { |
|
632 | 6 | throw new Exception('Can not delete a node when it is new record.'); |
|
633 | } |
||
634 | 12 | if ($this->isRoot() && $this->operation !== self::OPERATION_DELETE_ALL) { |
|
635 | 3 | throw new Exception('Method "'. $this->owner->className() . '::delete" is not supported for deleting root nodes.'); |
|
636 | } |
||
637 | 9 | $this->owner->refresh(); |
|
638 | 9 | if ($this->operation !== static::OPERATION_DELETE_ALL && !$this->primaryKeyMode) { |
|
639 | /** @var self $parent */ |
||
640 | 3 | $parent =$this->getParent()->one(); |
|
641 | 3 | $slugs1 = $parent->getChildren() |
|
642 | 3 | ->andWhere(['<>', $this->itemAttribute, $this->owner->getAttribute($this->itemAttribute)]) |
|
643 | 3 | ->select([$this->itemAttribute]) |
|
644 | 3 | ->column(); |
|
645 | 3 | $slugs2 = $this->getChildren() |
|
646 | 3 | ->select([$this->itemAttribute]) |
|
647 | 3 | ->column(); |
|
648 | 3 | if (array_intersect($slugs1, $slugs2)) { |
|
649 | $event->isValid = false; |
||
650 | } |
||
651 | } |
||
652 | 9 | } |
|
653 | |||
654 | /** |
||
655 | * |
||
656 | */ |
||
657 | 9 | public function afterDelete() |
|
658 | { |
||
659 | 9 | if ($this->operation !== static::OPERATION_DELETE_ALL) { |
|
660 | 3 | foreach ($this->owner->children as $child) { |
|
661 | /** @var self $child */ |
||
662 | 3 | if ($this->owner->next === null) { |
|
663 | $child->appendTo($this->owner->parent)->save(); |
||
664 | } else { |
||
665 | 3 | $child->insertBefore($this->owner->next)->save(); |
|
666 | } |
||
667 | } |
||
668 | } |
||
669 | 9 | $this->operation = null; |
|
670 | 9 | $this->node = null; |
|
671 | 9 | } |
|
672 | |||
673 | |||
674 | /** |
||
675 | * @return string |
||
676 | */ |
||
677 | 36 | protected function getPrimaryKeyValue() |
|
678 | { |
||
679 | 36 | $result = $this->owner->getPrimaryKey(true); |
|
680 | 36 | return reset($result); |
|
681 | } |
||
682 | |||
683 | /** |
||
684 | * @param bool $forInsertNear |
||
685 | * @throws Exception |
||
686 | */ |
||
687 | 126 | protected function checkNode($forInsertNear = false) |
|
688 | { |
||
689 | 126 | if ($forInsertNear && $this->node->isRoot()) { |
|
690 | 9 | throw new Exception('Can not move a node before/after root.'); |
|
691 | } |
||
692 | 117 | if ($this->node->getIsNewRecord()) { |
|
693 | 12 | throw new Exception('Can not move a node when the target node is new record.'); |
|
694 | } |
||
695 | |||
696 | 105 | if ($this->owner->equals($this->node)) { |
|
697 | 12 | throw new Exception('Can not move a node when the target node is same.'); |
|
698 | } |
||
699 | |||
700 | 93 | if ($this->node->isChildOf($this->owner)) { |
|
701 | 12 | throw new Exception('Can not move a node when the target node is child.'); |
|
702 | } |
||
703 | 81 | } |
|
704 | |||
705 | /** |
||
706 | * Make root operation internal handler |
||
707 | */ |
||
708 | 6 | protected function makeRootInternal() |
|
709 | { |
||
710 | 6 | $item = $this->owner->getAttribute($this->itemAttribute); |
|
711 | |||
712 | 6 | if ($item !== null) { |
|
713 | 6 | $this->owner->setAttribute($this->pathAttribute, $item); |
|
714 | } |
||
715 | |||
716 | 6 | if ($this->sortable !== false) { |
|
717 | 6 | $this->owner->setAttribute($this->behavior->sortAttribute, 0); |
|
718 | } |
||
719 | |||
720 | 6 | if ($this->treeAttribute !== null && !$this->owner->getDirtyAttributes([$this->treeAttribute]) && !$this->owner->getIsNewRecord()) { |
|
721 | 3 | $this->owner->setAttribute($this->treeAttribute, $this->getPrimaryKeyValue()); |
|
722 | } |
||
723 | |||
724 | 6 | $this->owner->setAttribute($this->depthAttribute, $this->rootDepthValue); |
|
725 | 6 | } |
|
726 | |||
727 | /** |
||
728 | * Append to operation internal handler |
||
729 | * @param bool $append |
||
730 | * @throws Exception |
||
731 | */ |
||
732 | 66 | View Code Duplication | protected function insertIntoInternal($append) |
733 | { |
||
734 | 66 | $this->checkNode(false); |
|
735 | 42 | $item = $this->owner->getAttribute($this->itemAttribute); |
|
736 | |||
737 | 42 | if ($item !== null) { |
|
738 | 42 | $path = $this->node->getAttribute($this->pathAttribute); |
|
739 | 42 | $this->owner->setAttribute($this->pathAttribute, $path . $this->delimiter . $item); |
|
740 | } |
||
741 | |||
742 | 42 | $this->owner->setAttribute($this->depthAttribute, $this->node->getAttribute($this->depthAttribute) + 1); |
|
743 | |||
744 | 42 | if ($this->treeAttribute !== null) { |
|
745 | 42 | $this->owner->setAttribute($this->treeAttribute, $this->node->getAttribute($this->treeAttribute)); |
|
746 | } |
||
747 | |||
748 | 42 | if ($this->sortable !== false) { |
|
749 | 42 | if ($append) { |
|
750 | 21 | $this->behavior->moveLast(); |
|
751 | } else { |
||
752 | 21 | $this->behavior->moveFirst(); |
|
753 | } |
||
754 | } |
||
755 | 42 | } |
|
756 | |||
757 | /** |
||
758 | * Insert operation internal handler |
||
759 | * @param bool $forward |
||
760 | * @throws Exception |
||
761 | */ |
||
762 | 60 | View Code Duplication | protected function insertNearInternal($forward) |
763 | { |
||
764 | 60 | $this->checkNode(true); |
|
765 | 39 | $item = $this->owner->getAttribute($this->itemAttribute); |
|
766 | |||
767 | 39 | if ($item !== null) { |
|
768 | 39 | $path = $this->node->getParentPath(); |
|
769 | 39 | $this->owner->setAttribute($this->pathAttribute, $path . $this->delimiter . $item); |
|
770 | } |
||
771 | |||
772 | 39 | $this->owner->setAttribute($this->depthAttribute, $this->node->getAttribute($this->depthAttribute)); |
|
773 | |||
774 | 39 | if ($this->treeAttribute !== null) { |
|
775 | 39 | $this->owner->setAttribute($this->treeAttribute, $this->node->getAttribute($this->treeAttribute)); |
|
776 | } |
||
777 | |||
778 | 39 | if ($this->sortable !== false) { |
|
779 | 39 | if ($forward) { |
|
780 | 18 | $this->behavior->moveAfter($this->node); |
|
781 | } else { |
||
782 | 21 | $this->behavior->moveBefore($this->node); |
|
783 | } |
||
784 | } |
||
785 | 39 | } |
|
786 | |||
787 | /** |
||
788 | * @return int |
||
789 | */ |
||
790 | 9 | protected function deleteWithChildrenInternal() |
|
791 | { |
||
792 | 9 | if (!$this->owner->beforeDelete()) { |
|
793 | return false; |
||
794 | } |
||
795 | 6 | $result = $this->owner->deleteAll($this->getDescendants(null, true)->where); |
|
796 | 6 | $this->owner->setOldAttributes(null); |
|
797 | 6 | $this->owner->afterDelete(); |
|
798 | 6 | return $result; |
|
799 | } |
||
800 | |||
801 | /** |
||
802 | * @param array $changedAttributes |
||
803 | * @throws Exception |
||
804 | */ |
||
805 | 57 | protected function moveNode($changedAttributes) |
|
806 | { |
||
807 | 57 | $path = isset($changedAttributes[$this->pathAttribute]) ? $changedAttributes[$this->pathAttribute] : $this->owner->getAttribute($this->pathAttribute); |
|
808 | 57 | $update = []; |
|
809 | $condition = [ |
||
810 | 57 | 'and', |
|
811 | 57 | ['like', "[[{$this->pathAttribute}]]", $this->getLike($path), false], |
|
812 | ]; |
||
813 | 57 | if ($this->treeAttribute !== null) { |
|
814 | 54 | $tree = isset($changedAttributes[$this->treeAttribute]) ? $changedAttributes[$this->treeAttribute] : $this->owner->getAttribute($this->treeAttribute); |
|
815 | 54 | $condition[] = [$this->treeAttribute => $tree]; |
|
816 | } |
||
817 | 57 | $params = []; |
|
818 | |||
819 | 57 | if (isset($changedAttributes[$this->pathAttribute])) { |
|
820 | 30 | $substringExpr = $this->substringExpression( |
|
821 | 30 | "[[{$this->pathAttribute}]]", |
|
822 | 30 | 'LENGTH(:pathOld) + 1', |
|
823 | 30 | "LENGTH([[{$this->pathAttribute}]]) - LENGTH(:pathOld)" |
|
824 | ); |
||
825 | 30 | $update[$this->pathAttribute] = new Expression($this->concatExpression([':pathNew', $substringExpr])); |
|
826 | 30 | $params[':pathOld'] = $path; |
|
827 | 30 | $params[':pathNew'] = $this->owner->getAttribute($this->pathAttribute); |
|
828 | } |
||
829 | |||
830 | 57 | if ($this->treeAttribute !== null && isset($changedAttributes[$this->treeAttribute])) { |
|
831 | 15 | $update[$this->treeAttribute] = $this->owner->getAttribute($this->treeAttribute); |
|
832 | } |
||
833 | |||
834 | 57 | if ($this->depthAttribute !== null && isset($changedAttributes[$this->depthAttribute])) { |
|
835 | 30 | $delta = $this->owner->getAttribute($this->depthAttribute) - $changedAttributes[$this->depthAttribute]; |
|
836 | 30 | $update[$this->depthAttribute] = new Expression("[[{$this->depthAttribute}]]" . sprintf('%+d', $delta)); |
|
837 | } |
||
838 | |||
839 | 57 | if (!empty($update)) { |
|
840 | 30 | $this->owner->updateAll($update, $condition, $params); |
|
841 | } |
||
842 | 57 | } |
|
843 | |||
844 | /** |
||
845 | * @param string $path |
||
846 | * @param string $delimiter |
||
847 | * @param bool $asArray = false |
||
848 | * @return null|string|array |
||
849 | */ |
||
850 | 66 | protected static function getParentPathInternal($path, $delimiter, $asArray = false) |
|
851 | { |
||
852 | 66 | $path = explode($delimiter, $path); |
|
853 | 66 | array_pop($path); |
|
854 | 66 | if ($asArray) { |
|
855 | 6 | return $path; |
|
856 | } |
||
857 | 63 | return count($path) > 0 ? implode($delimiter, $path) : null; |
|
858 | } |
||
859 | |||
860 | /** |
||
861 | * @return array |
||
862 | */ |
||
863 | 123 | protected function treeCondition() |
|
864 | { |
||
865 | 123 | $tableName = $this->owner->tableName(); |
|
866 | 123 | if ($this->treeAttribute === null) { |
|
867 | 123 | return []; |
|
868 | } else { |
||
869 | 123 | return ["{$tableName}.[[{$this->treeAttribute}]]" => $this->owner->getAttribute($this->treeAttribute)]; |
|
870 | } |
||
871 | } |
||
872 | |||
873 | /** |
||
874 | * @return \yii\db\ActiveQuery |
||
875 | */ |
||
876 | 87 | protected function getSortableQuery() |
|
877 | { |
||
878 | 87 | switch ($this->operation) { |
|
879 | 87 | case self::OPERATION_PREPEND_TO: |
|
880 | 66 | case self::OPERATION_APPEND_TO: |
|
881 | 42 | $path = $this->node->getAttribute($this->pathAttribute); |
|
882 | 42 | $depth = $this->node->getAttribute($this->depthAttribute) + 1; |
|
883 | 42 | break; |
|
884 | |||
885 | 45 | case self::OPERATION_INSERT_BEFORE: |
|
886 | 24 | case self::OPERATION_INSERT_AFTER: |
|
887 | 39 | $path = $this->node->getParentPath(); |
|
888 | 39 | $depth = $this->node->getAttribute($this->depthAttribute); |
|
889 | 39 | break; |
|
890 | |||
891 | default: |
||
892 | 6 | $path = $this->getParentPath(); |
|
893 | 6 | $depth = $this->owner->getAttribute($this->depthAttribute); |
|
894 | } |
||
895 | 87 | $tableName = $this->owner->tableName(); |
|
896 | |||
897 | 87 | return $this->owner->find() |
|
898 | 87 | ->andWhere($this->treeCondition()) |
|
899 | 87 | ->andWhere($path !== null ? ['like', "{$tableName}.[[{$this->pathAttribute}]]", $this->getLike($path), false] : '1=0') |
|
900 | 87 | ->andWhere(["{$tableName}.[[{$this->depthAttribute}]]" => $depth]); |
|
901 | } |
||
902 | |||
903 | /** |
||
904 | * @param string $path |
||
905 | * @return string |
||
906 | */ |
||
907 | 117 | protected function getLike($path) |
|
908 | { |
||
909 | 117 | return strtr($path . $this->delimiter, ['%' => '\%', '_' => '\_', '\\' => '\\\\']) . '%'; |
|
910 | } |
||
911 | |||
912 | /** |
||
913 | * @param array $items |
||
914 | * @return string |
||
915 | */ |
||
916 | 33 | protected function concatExpression($items) |
|
917 | { |
||
918 | 33 | if ($this->owner->getDb()->driverName === 'sqlite' || $this->owner->getDb()->driverName === 'pgsql') { |
|
919 | 22 | return implode(' || ', $items); |
|
920 | } |
||
921 | 11 | return 'CONCAT(' . implode(',', $items) . ')'; |
|
922 | } |
||
923 | |||
924 | 30 | protected function substringExpression($string, $from, $length) |
|
925 | { |
||
926 | 30 | if ($this->owner->getDb()->driverName === 'sqlite') { |
|
927 | 10 | return "SUBSTR({$string}, {$from}, {$length})"; |
|
928 | } |
||
929 | 20 | return "SUBSTRING({$string}, {$from}, {$length})"; |
|
930 | } |
||
931 | } |
||
932 |