Completed
Push — master ( 026f51...318521 )
by ARCANEDEV
10:18
created

QueryBuilder::descendantsAndSelf()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
c 0
b 0
f 0
ccs 2
cts 2
cp 1
rs 10
cc 1
eloc 2
nc 1
nop 2
crap 1
1
<?php namespace Arcanedev\LaravelNestedSet\Eloquent;
2
3
use Arcanedev\LaravelNestedSet\Utilities\NestedSet;
4
use Arcanedev\LaravelNestedSet\Utilities\TreeHelper;
5
use Illuminate\Database\Eloquent\Builder;
6
use Illuminate\Database\Eloquent\ModelNotFoundException;
7
use Illuminate\Database\Query\Builder as Query;
8
use Illuminate\Database\Query\Expression;
9
use LogicException;
10
11
/**
12
 * Class     QueryBuilder
13
 *
14
 * @package  Arcanedev\LaravelNestedSet\Eloquent
15
 * @author   ARCANEDEV <[email protected]>
16
 *
17
 * @method  static  \Arcanedev\LaravelNestedSet\Eloquent\QueryBuilder  whereIn(string $column, mixed $values, string $boolean = 'and', bool $not = false)
18
 */
19
class QueryBuilder extends Builder
20
{
21
    /* ------------------------------------------------------------------------------------------------
22
     |  Properties
23
     | ------------------------------------------------------------------------------------------------
24
     */
25
    /**
26
     * The model being queried.
27
     *
28
     * @var \Arcanedev\LaravelNestedSet\Contracts\Nodeable
29
     */
30
    protected $model;
31
32
    /* ------------------------------------------------------------------------------------------------
33
     |  Main Functions
34
     | ------------------------------------------------------------------------------------------------
35
     */
36
    /**
37
     * Get node's `lft` and `rgt` values.
38
     *
39
     * @param  mixed  $id
40
     * @param  bool   $required
41
     *
42
     * @return array
43
     */
44 120
    public function getNodeData($id, $required = false)
45
    {
46 120
        $query = $this->toBase();
47
48 120
        $query->where($this->model->getKeyName(), '=', $id);
49
50 120
        $data  = $query->first([
51 120
            $this->model->getLftName(),
52 120
            $this->model->getRgtName(),
53 40
        ]);
54
55 120
        if ( ! $data && $required) {
56 6
            throw new ModelNotFoundException;
57
        }
58
59 114
        return (array) $data;
60
    }
61
62
    /**
63
     * Get plain node data.
64
     *
65
     * @param  mixed  $id
66
     * @param  bool   $required
67
     *
68
     * @return array
69
     */
70 78
    public function getPlainNodeData($id, $required = false)
71
    {
72 78
        return array_values($this->getNodeData($id, $required));
73
    }
74
75
    /**
76
     * Scope limits query to select just root node.
77
     *
78
     * @return self
79
     */
80 30
    public function whereIsRoot()
81
    {
82 30
        $this->query->whereNull($this->model->getParentIdName());
83
84 30
        return $this;
85
    }
86
87
    /**
88
     * Limit results to ancestors of specified node.
89
     *
90
     * @param  mixed  $id
91
     *
92
     * @return self
93
     */
94 30
    public function whereAncestorOf($id)
95
    {
96 30
        $keyName = $this->model->getKeyName();
97
98 30
        if (NestedSet::isNode($id)) {
99 24
            $value = '?';
100
101 24
            $this->query->addBinding($id->getLft());
102
103 24
            $id = $id->getKey();
104 8
        } else {
105 6
            $valueQuery = $this->model
106 6
                ->newQuery()
107 6
                ->toBase()
108 6
                ->select("_.".$this->model->getLftName())
109 6
                ->from($this->model->getTable().' as _')
110 6
                ->where($keyName, '=', $id)
111 6
                ->limit(1);
112
113 6
            $this->query->mergeBindings($valueQuery);
114
115 6
            $value = '(' . $valueQuery->toSql() . ')';
116
        }
117
118 30
        list($lft, $rgt) = $this->wrappedColumns();
119
120 30
        $this->query->whereRaw("{$value} between {$lft} and {$rgt}");
121
122
        // Exclude the node
123 30
        $this->where($keyName, '<>', $id);
124
125 30
        return $this;
126
    }
127
128
    /**
129
     * Get ancestors of specified node.
130
     *
131
     * @param  mixed  $id
132
     * @param  array  $columns
133
     *
134
     * @return self
135
     */
136 18
    public function ancestorsOf($id, array $columns = ['*'])
137
    {
138 18
        return $this->whereAncestorOf($id)->get($columns);
139
    }
140
141
    /**
142
     * Add node selection statement between specified range.
143
     *
144
     * @param  array   $values
145
     * @param  string  $boolean
146
     * @param  bool    $not
147
     *
148
     * @return self
149
     */
150 72
    public function whereNodeBetween($values, $boolean = 'and', $not = false)
151
    {
152 72
        $this->query->whereBetween($this->model->getLftName(), $values, $boolean, $not);
153
154 72
        return $this;
155
    }
156
157
    /**
158
     * Add node selection statement between specified range joined with `or` operator.
159
     *
160
     * @param  array  $values
161
     *
162
     * @return self
163
     */
164
    public function orWhereNodeBetween($values)
165
    {
166
        return $this->whereNodeBetween($values, 'or');
167
    }
168
169
    /**
170
     * @param  mixed  $id
171
     *
172
     * @return self
173
     */
174
    public function whereNotDescendantOf($id)
175
    {
176
        return $this->whereDescendantOf($id, 'and', true);
177
    }
178
179
    /**
180
     * @param  mixed  $id
181
     *
182
     * @return self
183
     */
184 6
    public function orWhereDescendantOf($id)
185
    {
186 6
        return $this->whereDescendantOf($id, 'or');
187
    }
188
189
    /**
190
     * @param  mixed  $id
191
     *
192
     * @return self
193
     */
194
    public function orWhereNotDescendantOf($id)
195
    {
196
        return $this->whereDescendantOf($id, 'or', true);
197
    }
198
199
    /**
200
     * Add constraint statement to descendants of specified node or self.
201
     *
202
     * @param  mixed   $id
203
     * @param  string  $boolean
204
     * @param  bool    $not
205
     *
206
     * @return self
207
     */
208
    public function whereDescendantOrSelf($id, $boolean = 'and', $not = false)
209
    {
210
        return $this->whereDescendantOf($id, $boolean, $not, true);
211
    }
212
213
    /**
214
     * Add constraint statement to descendants of specified node.
215
     *
216
     * @param  mixed   $id
217
     * @param  string  $boolean
218
     * @param  bool    $not
219
     * @param  bool    $andSelf
220
     *
221
     * @return self
222
     */
223 78
    public function whereDescendantOf($id, $boolean = 'and', $not = false, $andSelf = false)
224
    {
225 78
        $data = NestedSet::isNode($id)
226 74
            ? $id->getBounds()
227 78
            : $this->model->newNestedSetQuery()->getPlainNodeData($id, true);
228
229 72
        if ( ! $andSelf) ++$data[0];
230
231 72
        return $this->whereNodeBetween($data, $boolean, $not);
232
    }
233
234
    /**
235
     * Get descendants of specified node.
236
     *
237
     * @param  mixed  $id
238
     * @param  array  $columns
239
     * @param  bool   $andSelf
240
     *
241
     * @return \Arcanedev\LaravelNestedSet\Eloquent\Collection
242
     */
243 6
    public function descendantsOf($id, array $columns = ['*'], $andSelf = false)
244
    {
245
        try {
246 6
            return $this->whereDescendantOf($id, 'and', false, $andSelf)->get($columns);
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->whereDescendantOf...ndSelf)->get($columns); of type Illuminate\Database\Eloq...base\Eloquent\Builder[] adds the type Illuminate\Database\Eloquent\Builder[] to the return on line 246 which is incompatible with the return type documented by Arcanedev\LaravelNestedS...yBuilder::descendantsOf of type Arcanedev\LaravelNestedSet\Eloquent\Collection.
Loading history...
247
        }
248
        catch (ModelNotFoundException $e) {
249
            return $this->model->newCollection();
250
        }
251
    }
252
253
    /**
254
     * Get descendants of self node.
255
     *
256
     * @param  mixed  $id
257
     * @param  array  $columns
258
     *
259
     * @return \Arcanedev\LaravelNestedSet\Eloquent\Collection
260
     */
261 6
    public function descendantsAndSelf($id, array $columns = ['*'])
262
    {
263 6
        return $this->descendantsOf($id, $columns, true);
264
    }
265
266
    /**
267
     * @param  mixed   $id
268
     * @param  string  $operator
269
     * @param  string  $boolean
270
     *
271
     * @return self
272
     */
273
    protected function whereIsBeforeOrAfter($id, $operator, $boolean)
274
    {
275
        if (NestedSet::isNode($id)) {
276
            $value = '?';
277
278
            $this->query->addBinding($id->getLft());
279
        } else {
280
            $valueQuery = $this->model
281
                ->newQuery()
282
                ->toBase()
283
                ->select('_n.'.$this->model->getLftName())
284
                ->from($this->model->getTable().' as _n')
285
                ->where('_n.'.$this->model->getKeyName(), '=', $id);
286
287
            $this->query->mergeBindings($valueQuery);
288
289
            $value = '('.$valueQuery->toSql().')';
290
        }
291
292
        list($lft,) = $this->wrappedColumns();
293
294
        $this->query->whereRaw("{$lft} {$operator} {$value}", [ ], $boolean);
295
296
        return $this;
297
    }
298
299
    /**
300
     * Constraint nodes to those that are after specified node.
301
     *
302
     * @param  mixed   $id
303
     * @param  string  $boolean
304
     *
305
     * @return self
306
     */
307
    public function whereIsAfter($id, $boolean = 'and')
308
    {
309
        return $this->whereIsBeforeOrAfter($id, '>', $boolean);
310
    }
311
312
    /**
313
     * Constraint nodes to those that are before specified node.
314
     *
315
     * @param  mixed   $id
316
     * @param  string  $boolean
317
     *
318
     * @return self
319
     */
320
    public function whereIsBefore($id, $boolean = 'and')
321
    {
322
        return $this->whereIsBeforeOrAfter($id, '<', $boolean);
323
    }
324
325
    /**
326
     * Include depth level into the result.
327
     *
328
     * @param  string  $as
329
     *
330
     * @return self
331
     */
332 24
    public function withDepth($as = 'depth')
333
    {
334 24
        if ($this->query->columns === null) {
335 24
            $this->query->columns = ['*'];
336 8
        }
337
338 24
        $table = $this->wrappedTable();
339
340 24
        list($lft, $rgt) = $this->wrappedColumns();
341
342 24
        $query = $this->model
343 24
            ->newScopedQuery('_d')
344 24
            ->toBase()
345 24
            ->selectRaw('count(1) - 1')
346 24
            ->from($this->model->getTable().' as _d')
347 24
            ->whereRaw("{$table}.{$lft} between _d.{$lft} and _d.{$rgt}");
348
349 24
        $this->query->selectSub($query, $as);
350
351 24
        return $this;
352
    }
353
354
    /**
355
     * Get wrapped `lft` and `rgt` column names.
356
     *
357
     * @return array
358
     */
359 72
    protected function wrappedColumns()
360
    {
361 72
        $grammar = $this->query->getGrammar();
362
363
        return [
364 72
            $grammar->wrap($this->model->getLftName()),
365 72
            $grammar->wrap($this->model->getRgtName()),
366 24
        ];
367
    }
368
369
    /**
370
     * Get a wrapped table name.
371
     *
372
     * @return string
373
     */
374 42
    protected function wrappedTable()
375
    {
376 42
        return $this->query->getGrammar()->wrapTable($this->getQuery()->from);
377
    }
378
379
    /**
380
     * Wrap model's key name.
381
     *
382
     * @return string
383
     */
384 18
    protected function wrappedKey()
385
    {
386 18
        return $this->query->getGrammar()->wrap($this->model->getKeyName());
387
    }
388
389
    /**
390
     * Exclude root node from the result.
391
     *
392
     * @return self
393
     */
394 12
    public function withoutRoot()
395
    {
396 12
        $this->query->whereNotNull($this->model->getParentIdName());
397
398 12
        return $this;
399
    }
400
401
    /**
402
     * Order by node position.
403
     *
404
     * @param  string  $dir
405
     *
406
     * @return self
407
     */
408 78
    public function defaultOrder($dir = 'asc')
409
    {
410 78
        $this->query->orders = [];
411 78
        $this->query->orderBy($this->model->getLftName(), $dir);
412
413 78
        return $this;
414
    }
415
416
    /**
417
     * Order by reversed node position.
418
     *
419
     * @return self
420
     */
421 6
    public function reversed()
422
    {
423 6
        return $this->defaultOrder('desc');
424
    }
425
426
    /**
427
     * Move a node to the new position.
428
     *
429
     * @param  mixed  $key
430
     * @param  int    $position
431
     *
432
     * @return int
433
     */
434 60
    public function moveNode($key, $position)
435
    {
436 60
        list($lft, $rgt) = $this->model->newNestedSetQuery()
437 60
                                       ->getPlainNodeData($key, true);
438
439
        // @codeCoverageIgnoreStart
440
        if ($lft < $position && $position <= $rgt) {
441
            throw new LogicException('Cannot move node into itself.');
442
        }
443
        // @codeCoverageIgnoreEnd
444
445
        // Get boundaries of nodes that should be moved to new position
446 60
        $from     = min($lft, $position);
447 60
        $to       = max($rgt, $position - 1);
448
449
        // The height of node that is being moved
450 60
        $height   = $rgt - $lft + 1;
451
452
        // The distance that our node will travel to reach it's destination
453 60
        $distance = $to - $from + 1 - $height;
454
455
        // If no distance to travel, just return
456 60
        if ($distance === 0) {
457
            return 0;
458
        }
459
460 60
        if ($position > $lft) {
461 48
            $height *= -1;
462 16
        }
463
        else {
464 12
            $distance *= -1;
465
        }
466
467 60
        $boundary = [$from, $to];
468
        $query    = $this->toBase()->where(function (Query $inner) use ($boundary) {
469 60
            $inner->whereBetween($this->model->getLftName(), $boundary);
470 60
            $inner->orWhereBetween($this->model->getRgtName(), $boundary);
471 60
        });
472
473 60
        return $query->update($this->patch(
474 60
            compact('lft', 'rgt', 'from', 'to', 'height', 'distance')
475 20
        ));
476
    }
477
478
    /**
479
     * Make or remove gap in the tree. Negative height will remove gap.
480
     *
481
     * @param  int  $cut
482
     * @param  int  $height
483
     *
484
     * @return int
485
     */
486 72
    public function makeGap($cut, $height)
487
    {
488
        $query = $this->toBase()->whereNested(function (Query $inner) use ($cut) {
489 72
            $inner->where($this->model->getLftName(), '>=', $cut);
490 72
            $inner->orWhere($this->model->getRgtName(), '>=', $cut);
491 72
        });
492
493 72
        return $query->update($this->patch(
494 72
            compact('cut', 'height')
495 24
        ));
496
    }
497
498
    /**
499
     * Get patch for columns.
500
     *
501
     * @param  array  $params
502
     *
503
     * @return array
504
     */
505 126
    protected function patch(array $params)
506
    {
507 126
        $grammar = $this->query->getGrammar();
508 126
        $columns = [];
509
510 126
        foreach ([$this->model->getLftName(), $this->model->getRgtName()] as $col) {
511 126
            $columns[$col] = $this->columnPatch($grammar->wrap($col), $params);
512 42
        }
513
514 126
        return $columns;
515
    }
516
517
    /**
518
     * Get patch for single column.
519
     *
520
     * @param  string  $col
521
     * @param  array   $params
522
     *
523
     * @return string
524
     */
525 126
    protected function columnPatch($col, array $params)
526
    {
527
        /**
528
         * @var int $height
529
         * @var int $distance
530
         * @var int $lft
531
         * @var int $rgt
532
         * @var int $from
533
         * @var int $to
534
         */
535 126
        extract($params);
536
537 126
        if ($height > 0) $height = '+'.$height;
538
539 126
        if (isset($cut)) {
540 72
            return new Expression("case when {$col} >= {$cut} then {$col}{$height} else {$col} end");
541
        }
542
543 60
        if ($distance > 0) {
544 48
            $distance = '+'.$distance;
545 16
        }
546
547 60
        return new Expression(
548
            "case ".
549 60
            "when {$col} between {$lft} and {$rgt} then {$col}{$distance} ". // Move the node
550 60
            "when {$col} between {$from} and {$to} then {$col}{$height} ". // Move other nodes
551 60
            "else {$col} end"
552 20
        );
553
    }
554
555
    /**
556
     * Get statistics of errors of the tree.
557
     *
558
     * @return array
559
     */
560 18
    public function countErrors()
561
    {
562
        $checks = [
563 18
            'oddness'        => $this->getOddnessQuery(),      // Check if lft and rgt values are ok
564 18
            'duplicates'     => $this->getDuplicatesQuery(),   // Check if lft and rgt values are unique
565 18
            'wrong_parent'   => $this->getWrongParentQuery(),  // Check if parent_id is set correctly
566 18
            'missing_parent' => $this->getMissingParentQuery() // Check for nodes that have missing parent
567 6
        ];
568
569 18
        $query = $this->query->newQuery();
570
571 18
        foreach ($checks as $key => $inner) {
572
            /** @var \Illuminate\Database\Query\Builder $inner */
573 18
            $inner->selectRaw('count(1)');
574
575 18
            $query->selectSub($inner, $key);
576 6
        }
577
578 18
        return (array) $query->first();
579
    }
580
581
    /**
582
     * Get the oddness errors query.
583
     *
584
     * @return \Illuminate\Database\Query\Builder
585
     */
586 18
    protected function getOddnessQuery()
587
    {
588 18
        return $this->model
589 18
            ->newNestedSetQuery()
590 18
            ->toBase()
591
            ->whereNested(function (Query $inner) {
592 18
                list($lft, $rgt) = $this->wrappedColumns();
593
594 18
                $inner->whereRaw("{$lft} >= {$rgt}")
595 18
                      ->orWhereRaw("({$rgt} - {$lft}) % 2 = 0");
596 18
            });
597
    }
598
599
    /**
600
     * Get the duplicates errors query.
601
     *
602
     * @return \Arcanedev\LaravelNestedSet\Eloquent\QueryBuilder|\Illuminate\Database\Query\Builder
603
     */
604 18
    protected function getDuplicatesQuery()
605
    {
606 18
        $table = $this->wrappedTable();
607
608 18
        $query = $this->model
609 18
            ->newNestedSetQuery('c1')
610 18
            ->toBase()
611 18
            ->from($this->query->raw("{$table} c1, {$table} c2"))
612 18
            ->whereRaw("c1.id < c2.id")
613
            ->whereNested(function (Query $inner) {
614 18
                list($lft, $rgt) = $this->wrappedColumns();
615
616 18
                $inner->orWhereRaw("c1.{$lft}=c2.{$lft}")
617 18
                      ->orWhereRaw("c1.{$rgt}=c2.{$rgt}")
618 18
                      ->orWhereRaw("c1.{$lft}=c2.{$rgt}")
619 18
                      ->orWhereRaw("c1.{$rgt}=c2.{$lft}");
620 18
            });
621
622 18
        return $this->model->applyNestedSetScope($query, 'c2');
623
    }
624
625
    /**
626
     * Get the wrong parent query.
627
     *
628
     * @return \Illuminate\Database\Query\Builder
629
     */
630 18
    protected function getWrongParentQuery()
631
    {
632 18
        $table        = $this->wrappedTable();
633 18
        $keyName      = $this->wrappedKey();
634 18
        $parentIdName = $this->query->raw($this->model->getParentIdName());
635 18
        $query        = $this->model->newNestedSetQuery('c')
636 18
            ->toBase()
637 18
            ->from($this->query->raw("{$table} c, {$table} p, $table m"))
638 18
            ->whereRaw("c.{$parentIdName}=p.{$keyName}")
639 18
            ->whereRaw("m.{$keyName} <> p.{$keyName}")
640 18
            ->whereRaw("m.{$keyName} <> c.{$keyName}")
641
            ->whereNested(function (Query $inner) {
642 18
                list($lft, $rgt) = $this->wrappedColumns();
643
644 18
                $inner->whereRaw("c.{$lft} not between p.{$lft} and p.{$rgt}")
645 18
                      ->orWhereRaw("c.{$lft} between m.{$lft} and m.{$rgt}")
646 18
                      ->whereRaw("m.{$lft} between p.{$lft} and p.{$rgt}");
647 18
            });
648
649 18
        $this->model->applyNestedSetScope($query, 'p');
650 18
        $this->model->applyNestedSetScope($query, 'm');
651
652 18
        return $query;
653
    }
654
655
    /**
656
     * Get the missing parent query.
657
     *
658
     * @return \Illuminate\Database\Query\Builder
659
     */
660 18
    protected function getMissingParentQuery()
661
    {
662 18
        return $this->model
663 18
            ->newNestedSetQuery()
664 18
            ->toBase()
665 18
            ->whereNested(function (Query $inner) {
666 18
                $table = $this->wrappedTable();
667 18
                $keyName = $this->wrappedKey();
668 18
                $parentIdName = $this->query->raw($this->model->getParentIdName());
669
670 18
                $query = $this->model
671 18
                    ->newNestedSetQuery()
672 18
                    ->toBase()
673 18
                    ->selectRaw('1')
674 18
                    ->from($this->query->raw("{$table} p"))
675 18
                    ->whereRaw("{$table}.{$parentIdName} = p.{$keyName}")
676 18
                    ->limit(1);
677
678 18
                $this->model->applyNestedSetScope($query, 'p');
679
680 18
                $inner->whereRaw("{$parentIdName} is not null")
681 18
                      ->addWhereExistsQuery($query, 'and', true);
682 18
            });
683
    }
684
685
    /**
686
     * Get the number of total errors of the tree.
687
     *
688
     * @return int
689
     */
690 12
    public function getTotalErrors()
691
    {
692 12
        return array_sum($this->countErrors());
693
    }
694
695
    /**
696
     * Get whether the tree is broken.
697
     *
698
     * @return bool
699
     */
700 12
    public function isBroken()
701
    {
702 12
        return $this->getTotalErrors() > 0;
703
    }
704
705
    /**
706
     * Fixes the tree based on parentage info.
707
     * Nodes with invalid parent are saved as roots.
708
     *
709
     * @return  int  The number of fixed nodes
710
     */
711 6
    public function fixTree()
712
    {
713 6
        $dictionary = $this->defaultOrder()
714 6
            ->get([
715 6
                $this->model->getKeyName(),
716 6
                $this->model->getParentIdName(),
717 6
                $this->model->getLftName(),
718 6
                $this->model->getRgtName(),
719 2
            ])
720 6
            ->groupBy($this->model->getParentIdName())
721 6
            ->all();
722
723 6
        return TreeHelper::fixNodes($dictionary);
724
    }
725
726
    /**
727
     * Rebuild the tree based on raw data.
728
     * If item data does not contain primary key, new node will be created.
729
     *
730
     * @param  array  $data
731
     * @param  bool   $delete  Whether to delete nodes that exists but not in the data array
732
     *
733
     * @return int
734
     */
735 18
    public function rebuildTree(array $data, $delete = false)
736
    {
737 18
        $existing = $this->get()->getDictionary();
738
739 18
        return TreeHelper::rebuild($data, $existing, $this->model, $delete);
740
    }
741
742
    /**
743
     * Get the root node.
744
     *
745
     * @param  array  $columns
746
     *
747
     * @return \Illuminate\Database\Eloquent\Model|\Arcanedev\LaravelNestedSet\Contracts\Nodeable|null
748
     */
749 24
    public function root(array $columns = ['*'])
750
    {
751 24
        return $this->whereIsRoot()->first($columns);
752
    }
753
}
754