Completed
Push — master ( 240190...ff35ff )
by ARCANEDEV
05:15
created

src/Eloquent/QueryBuilder.php (1 issue)

Severity

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
641
642 12
                $inner->whereRaw("{$parentIdName} is not null")
643 12
                      ->addWhereExistsQuery($existsCheck, 'and', true);
644 12
            });
645
    }
646
647
    /**
648
     * Get the number of total errors of the tree.
649
     *
650
     * @return int
651
     */
652 8
    public function getTotalErrors()
653
    {
654 8
        return array_sum($this->countErrors());
655
    }
656
657
    /**
658
     * Get whether the tree is broken.
659
     *
660
     * @return bool
661
     */
662 8
    public function isBroken()
663
    {
664 8
        return $this->getTotalErrors() > 0;
665
    }
666
667
    /**
668
     * Fixes the tree based on parentage info.
669
     * Nodes with invalid parent are saved as roots.
670
     *
671
     * @return int The number of fixed nodes
672
     */
673 4
    public function fixTree()
674
    {
675
        $columns   = [
676 4
            $this->model->getKeyName(),
677 4
            $this->model->getParentIdName(),
678 4
            $this->model->getLftName(),
679 4
            $this->model->getRgtName(),
680 3
        ];
681
682 4
        $dictionary = $this->defaultOrder()
683 4
                ->get($columns)
684 4
                ->groupBy($this->model->getParentIdName())
685 4
                ->all();
686
687 4
        return TreeHelper::fixNodes($dictionary);
688
    }
689
690
    /**
691
     * Rebuild the tree based on raw data.
692
     * If item data does not contain primary key, new node will be created.
693
     *
694
     * @param  array  $data
695
     * @param  bool   $delete  Whether to delete nodes that exists but not in the data array
696
     *
697
     * @return int
698
     */
699 8
    public function rebuildTree(array $data, $delete = false)
700
    {
701 8
        $existing   = $this->get()->getDictionary();
702 8
        $dictionary = [];
703 8
        $this->buildRebuildDictionary($dictionary, $data, $existing);
704
705 4
        if ( ! empty($existing)) {
706 4
            if ($delete) {
707 4
                $this->model
708 4
                    ->newScopedQuery()
709 4
                    ->whereIn($this->model->getKeyName(), array_keys($existing))
710 4
                    ->forceDelete();
711 3
            } else {
712
                /** @var NodeTrait $model */
713
                foreach ($existing as $model) {
714
                    $dictionary[$model->getParentId()][] = $model;
715
                }
716
            }
717 3
        }
718
719 4
        return TreeHelper::fixNodes($dictionary);
720
    }
721
722
    /**
723
     * @param  array  $dictionary
724
     * @param  array  $data
725
     * @param  array  $existing
726
     * @param  mixed  $parentId
727
     */
728 8
    protected function buildRebuildDictionary(
729
        array &$dictionary,
730
        array $data,
731
        array &$existing,
732
        $parentId = null
733
    ) {
734 8
        $keyName = $this->model->getKeyName();
735
736 8
        foreach ($data as $itemData) {
737 8
            if ( ! isset($itemData[$keyName])) {
738 4
                $model = $this->model->newInstance();
739
                // We will save it as raw node since tree will be fixed
740 4
                $model->rawNode(0, 0, $parentId);
741 3
            } else {
742 4
                if ( ! isset($existing[$key = $itemData[$keyName]])) {
743 4
                    throw new ModelNotFoundException;
744
                }
745
                $model = $existing[$key];
746
                unset($existing[$key]);
747
            }
748
749 4
            $model->fill($itemData)->save();
750 4
            $dictionary[$parentId][] = $model;
751
752 4
            if ( ! isset($itemData['children'])) {
753 4
                continue;
754
            }
755
756
            $this->buildRebuildDictionary(
757
                $dictionary,
758
                $itemData['children'],
759
                $existing,
760
                $model->getKey()
761
            );
762 3
        }
763 4
    }
764
765
    /**
766
     * @param  string|null  $table
767
     *
768
     * @return self
769
     */
770
    public function applyNestedSetScope($table = null)
771
    {
772
        return $this->model->applyNestedSetScope($this, $table);
773
    }
774
775
    /**
776
     * Get the root node.
777
     *
778
     * @param  array  $columns
779
     *
780
     * @return self
781
     */
782 16
    public function root(array $columns = ['*'])
783
    {
784 16
        return $this->whereIsRoot()->first($columns);
785
    }
786
}
787