Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
ManagesClosures
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
7 / 7
16
100.00% covered (success)
100.00%
1 / 1
 bootManagesClosures
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 checkIfDeletable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 checkIfParentIdIsValid
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 createSelfClosure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 attachSubtree
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 detachSubtree
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteClosures
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Baril\Bonsai\Concerns;
4
5use Baril\Bonsai\TreeException;
6
7trait ManagesClosures
8{
9    /**
10     * Create, update and delete the closures when the model is saved.
11     */
12    public static function bootManagesClosures()
13    {
14        static::saving(function ($item) {
15            $item->checkIfParentIdIsValid();
16        });
17
18        static::created(function ($item) {
19            $item->createSelfClosure();
20            $item->attachSubtree();
21        });
22
23        static::updated(function ($item) {
24            // If the parent has changed, update the closures
25            // for the node and its descendants:
26            if ($item->isDirty($item->getParentForeignKeyName())) {
27                $item->detachSubtree();
28                $item->attachSubtree();
29            }
30        });
31
32        static::deleting(function ($item) {
33            $item->checkIfDeletable();
34        });
35
36        static::deleted(function ($item) {
37            // Delete the node's closures:
38            // @todo replace with !static::isSoftDeletable()
39            if (!property_exists($item, 'forceDeleting') || $item->forceDeleting) {
40                $item->deleteClosures();
41            }
42        });
43    }
44
45    /**
46     * Check if the node can be deleted.
47     *
48     * @throws \Baril\Bonsai\TreeException
49     * @return void
50     */
51    protected function checkIfDeletable()
52    {
53        if ($this->children()->exists()) {
54            throw new TreeException('Can\'t delete an item with children!');
55        }
56    }
57
58    /**
59     * Check that the parent is not the model itself or one of
60     * its descendants (which triggers an exception).
61     *
62     * @throws \Baril\Bonsai\TreeException
63     * @return void
64     */
65    protected function checkIfParentIdIsValid()
66    {
67        if (is_null($parentKey = $this->getParentKey())) {
68            return;
69        }
70
71        if (!$this->parent()->exists()) {
72            throw new TreeException('The item\'s parent doesn\'t exist!');
73        }
74
75        if (
76            $parentKey == $this->getKey()
77            || $this->descendingClosures()->whereDescendant($parentKey)->exists()
78        ) {
79            throw new TreeException(
80                'Redundancy error! The item\'s parent can\'t be the item itself or one of its descendants.'
81            );
82        }
83    }
84
85    /**
86     * Insert the self-closure for the model.
87     *
88     * @return int
89     */
90    protected function createSelfClosure()
91    {
92        return $this->newClosureQuery()->insert([
93            'ancestor_id' => $this->getKey(),
94            'descendant_id' => $this->getKey(),
95            'depth' => 0,
96        ]);
97    }
98
99    /**
100     * Create the closures to attach the model and its subtree
101     * to the rest of the tree (ie. the model's ancestors).
102     * This assumes that the "internal" closures of the subtree
103     * already exist.
104     *
105     * @return int
106     */
107    protected function attachSubtree()
108    {
109        if (! $parentKey = $this->getParentKey()) {
110            return;
111        }
112
113        $connection = $this->getConnection();
114        $grammar = $connection->getQueryGrammar();
115
116        // The new closures are all the possible combinations
117        // between the model's descending closures (including the self-closure)
118        // and the new parent's ascending closures (including the self-closure),
119        // with a depth that's the sum of both depths + 1.
120
121        // INSERT INTO $closureTable (descendant_id, ancestor_id, depth)
122        // SELECT descendants.descendant_id, ancestors.ancestor_id, descendants.depth + ancestors.depth + 1
123        //     FROM $closureTable AS descendants
124        //     CROSS JOIN $closureTable AS ancestors
125        //     WHERE descendants.ancestor_id = $id
126        //         AND ancestors.descendant_id = $newParentId
127
128        $select = $this->newClosureQuery('descendants')
129            ->selfCrossJoin('ancestors')
130            ->where('descendants.ancestor_id', $this->getKey())
131            ->where('ancestors.descendant_id', $parentKey)
132            ->select(
133                'descendants.descendant_id',
134                'ancestors.ancestor_id',
135                $connection->raw(sprintf(
136                    '%s + %s + 1',
137                    $grammar->wrap('descendants.depth'),
138                    $grammar->wrap('ancestors.depth')
139                ))
140            );
141
142        return $this->newClosureQuery()
143            ->insertUsing(['descendant_id', 'ancestor_id', 'depth'], $select);
144    }
145
146    /**
147     * Delete the closures attaching the model and its subtree
148     * to the rest of the tree (ie. the model's ancestors),
149     * but not the "internal" closures of the subtree.
150     *
151     * @return int
152     */
153    protected function detachSubtree()
154    {
155        return $this->deleteClosures('>');
156    }
157
158    /**
159     * Delete the ascending closures of the model and optionally its
160     * descendants.
161     *
162     * @param  string|null  $operator  - '>' to detach the subtree (including the node) from the main tree
163     *                                 - '>=' to detach the descendants from the node
164     *                                 - null to delete all the subtree closures
165     * @return int
166     */
167    protected function deleteClosures($operator = null)
168    {
169        // DELETE FROM closures USING $closureTable AS closures
170        //     INNER JOIN $closureTable AS descendants
171        //         ON closures.descendant_id = descendants.descendant_id
172        //     WHERE descendants.ancestor_id = $id
173        //         AND closures.depth > descendants.depth
174
175        $query = $this->newClosureQuery('closures_to_delete')
176            ->selfJoin('descendants', 'descendant_id')
177            ->where('descendants.ancestor_id', $this->id)
178            // This condition preserves the internal closures:
179            ->when($operator, function ($query, $operator) {
180                $query->whereColumn('closures_to_delete.depth', $operator, 'descendants.depth');
181            });
182
183        return $query->delete();
184    }
185}