Completed
Push — 4-cactus ( 3ebc54...5baef7 )
by Stefano
17s queued 15s
created

TreeBehavior::beforeDelete()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 7
rs 10
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2018 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\Core\Model\Behavior;
15
16
use Cake\Database\Expression\Comparison;
17
use Cake\Database\Expression\IdentifierExpression;
18
use Cake\Database\Expression\QueryExpression;
19
use Cake\Database\Query;
20
use Cake\Datasource\EntityInterface;
21
use Cake\Event\Event;
22
use Cake\ORM\Behavior\TreeBehavior as CakeTreeBehavior;
23
use Cake\ORM\Table;
24
25
/**
26
 * This behavior adds absolute positioning of nodes on top of CakePHP {@see \Cake\ORM\Behavior\TreeBehavior}.
27
 *
28
 * {@inheritDoc}
29
 */
30
class TreeBehavior extends CakeTreeBehavior
31
{
32
    /**
33
     * {@inheritDoc}
34
     *
35
     * @codeCoverageIgnore
36
     */
37
    public function __construct(Table $table, array $config = [])
38
    {
39
        $this->_defaultConfig['implementedMethods'] += [
40
            'getCurrentPosition' => 'getCurrentPosition',
41
            'moveAt' => 'moveAt',
42
            'checkIntegrity' => 'checkIntegrity',
43
        ];
44
45
        parent::__construct($table, $config);
46
    }
47
48
    /**
49
     * @inheritDoc
50
     */
51
    public function beforeDelete(Event $event, EntityInterface $entity)
52
    {
53
        // ensure to use actual left and right fields
54
        unset($entity[$this->getConfig('left')], $entity[$this->getConfig('right')]);
55
        $this->_ensureFields($entity);
56
57
        parent::beforeDelete($event, $entity);
58
    }
59
60
    /**
61
     * Get current position of a node within its parent.
62
     *
63
     * @param \Cake\Datasource\EntityInterface $node Node to get position for.
64
     * @return int
65
     */
66
    public function getCurrentPosition(EntityInterface $node)
67
    {
68
        return $this->_scope($this->getTable()->find())
69
            ->where(function (QueryExpression $exp) use ($node) {
70
                $parentField = $this->getConfig('parent');
71
                $leftField = $this->getConfig('left');
72
73
                if (!$node->has($parentField)) {
74
                    $exp = $exp->isNull($this->getTable()->aliasField($parentField));
75
                } else {
76
                    $exp = $exp->eq($this->getTable()->aliasField($parentField), $node->get($parentField));
77
                }
78
79
                return $exp
80
                    ->lte($this->getTable()->aliasField($leftField), $node->get($leftField));
81
            })
82
            ->count();
83
    }
84
85
    /**
86
     * Move a node at a specific position without changing the parent.
87
     *
88
     * @param \Cake\Datasource\EntityInterface $node Node to be moved.
89
     * @param int|string $position New position. Can be either an integer, or a string (`'first'` or `'last'`).
90
     *      Negative integers are interpreted as number of positions from the end of the list. 0 (zero) is not allowed.
91
     * @return \Cake\Datasource\EntityInterface|false
92
     */
93
    public function moveAt(EntityInterface $node, $position)
94
    {
95
        return $this->getTable()->getConnection()->transactional(function () use ($node, $position) {
96
            $position = static::validatePosition($position);
97
            if ($position === false) {
98
                return false;
99
            }
100
101
            // ensure to use actual left and right fields
102
            unset($node[$this->getConfig('left')]);
103
            unset($node[$this->getConfig('right')]);
104
            $this->_ensureFields($node);
105
106
            $currentPosition = $this->getCurrentPosition($node);
107
            if ($position === $currentPosition) {
108
                // Do not perform extra queries. Position must still be normalized, so we'll need to re-check later.
109
                return $node;
110
            }
111
112
            $childrenCount = $this->_scope($this->getTable()->find())
113
                ->where(function (QueryExpression $exp) use ($node) {
114
                    $parentField = $this->getConfig('parent');
115
116
                    if (!$node->has($parentField)) {
117
                        return $exp->isNull($this->getTable()->aliasField($parentField));
118
                    }
119
120
                    return $exp->eq($this->getTable()->aliasField($parentField), $node->get($parentField));
121
                })
122
                ->count();
123
124
            // Normalize position. Transform negative indexes, and apply bounds.
125
            if ($position < 0) {
126
                $position = $childrenCount + $position + 1;
127
            }
128
            $position = max(1, min($position, $childrenCount));
129
130
            if ($position === $currentPosition) {
131
                // Already OK.
132
                return $node;
133
            }
134
135
            if ($position > $currentPosition) {
136
                return $this->moveDown($node, $position - $currentPosition);
137
            }
138
139
            return $this->moveUp($node, $currentPosition - $position);
140
        });
141
    }
142
143
    /**
144
     * Validate a position.
145
     *
146
     * @param int|string $position Position to be validated.
147
     * @return int|false
148
     */
149
    protected static function validatePosition($position)
150
    {
151
        if ($position === 'first') {
152
            return 1;
153
        }
154
        if ($position === 'last') {
155
            return -1;
156
        }
157
158
        $position = filter_var($position, FILTER_VALIDATE_INT);
159
        if ($position === false || $position === 0) {
160
            return false;
161
        }
162
163
        return $position;
164
    }
165
166
    /**
167
     * Calls $this->_recoverTree() without transactional(...)
168
     * Warning: you should set up a transactional flow manually if you use this method!
169
     *
170
     * @return void
171
     * @codeCoverageIgnore
172
     */
173
    public function nonAtomicRecover(): void
174
    {
175
        $this->_recoverTree();
176
    }
177
178
    /**
179
     * Run queries to check tree integrity.
180
     *
181
     * @return string[]
182
     */
183
    public function checkIntegrity(): array
184
    {
185
        $table = $this->getTable();
186
        $pk = $table->aliasField($table->getPrimaryKey());
0 ignored issues
show
Bug introduced by
It seems like $table->getPrimaryKey() can also be of type string[]; however, parameter $field of Cake\ORM\Table::aliasField() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

186
        $pk = $table->aliasField(/** @scrutinizer ignore-type */ $table->getPrimaryKey());
Loading history...
187
        $left = $table->aliasField($this->getConfigOrFail('left'));
188
        $right = $table->aliasField($this->getConfigOrFail('right'));
189
        $parent = $table->aliasField($this->getConfigOrFail('parent'));
190
        $childAlias = sprintf('Child%s', $table->getAlias());
191
        $siblingAlias = sprintf('Sibling%s', $table->getAlias());
192
193
        $exists = function (Query $query): bool {
194
            return $query->select(['existing' => 1])->limit(1)->execute()->count() > 0;
195
        };
196
197
        $errors = [];
198
199
        // Check that for every record `lft < rght`.
200
        $query = $table->find()
201
            ->where(function (QueryExpression $exp) use ($left, $right): QueryExpression {
202
                return $exp->gte($left, new IdentifierExpression($right));
203
            });
204
        if ($exists($query)) {
205
            $errors[] = sprintf('Found record where %s >= %s', $this->getConfigOrFail('left'), $this->getConfigOrFail('right'));
206
        }
207
208
        // Check that for every parent, `parent.lft + 1 = MIN(children.lft)`
209
        $query = $table->find()
210
            ->innerJoin(
211
                [$childAlias => $table->getTable()],
212
                function (QueryExpression $exp) use ($pk, $childAlias): QueryExpression {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type Cake\Database\ExpressionInterface|array|string expected by parameter $conditions of Cake\Database\Query::innerJoin(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

212
                /** @scrutinizer ignore-type */ function (QueryExpression $exp) use ($pk, $childAlias): QueryExpression {
Loading history...
213
                    return $exp
214
                        ->equalFields($pk, sprintf('%s.%s', $childAlias, $this->getConfigOrFail('parent')));
215
                }
216
            )
217
            ->group([$pk, $left])
218
            ->having(function (QueryExpression $exp, Query $query) use ($childAlias, $left): QueryExpression {
219
                return $exp->notEq(
220
                    new Comparison($left, 1, null, '+'),
221
                    $query->func()->min(sprintf('%s.%s', $childAlias, $this->getConfigOrFail('left')))
222
                );
223
            });
224
        if ($exists($query)) {
225
            $errors[] = sprintf('Found record where parent.%s + 1 != MIN(children.%1$s)', $this->getConfigOrFail('left'));
226
        }
227
228
        // Check that for every parent, `parent.rght - 1 = MAX(children.rght)`
229
        $query = $table->find()
230
            ->innerJoin(
231
                [$childAlias => $table->getTable()],
232
                function (QueryExpression $exp) use ($pk, $childAlias): QueryExpression {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type Cake\Database\ExpressionInterface|array|string expected by parameter $conditions of Cake\Database\Query::innerJoin(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

232
                /** @scrutinizer ignore-type */ function (QueryExpression $exp) use ($pk, $childAlias): QueryExpression {
Loading history...
233
                    return $exp
234
                        ->equalFields($pk, sprintf('%s.%s', $childAlias, $this->getConfigOrFail('parent')));
235
                }
236
            )
237
            ->group([$pk, $right])
238
            ->having(function (QueryExpression $exp, Query $query) use ($childAlias, $right): QueryExpression {
239
                return $exp->notEq(
240
                    new Comparison($right, 1, null, '-'),
241
                    $query->func()->max(sprintf('%s.%s', $childAlias, $this->getConfigOrFail('right')))
242
                );
243
            });
244
        if ($exists($query)) {
245
            $errors[] = sprintf('Found record where parent.%s - 1 != MAX(children.%1$s)', $this->getConfigOrFail('right'));
246
        }
247
248
        // Check that for every node, `node.lft - 1 = MAX(sibling.rght)` where `sibling.lft <= node.lft`.
249
        $query = $table->find()
250
            ->innerJoin(
251
                [$siblingAlias => $table->getTable()],
252
                function (QueryExpression $exp) use ($table, $left, $parent, $pk, $siblingAlias): QueryExpression {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type Cake\Database\ExpressionInterface|array|string expected by parameter $conditions of Cake\Database\Query::innerJoin(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

252
                /** @scrutinizer ignore-type */ function (QueryExpression $exp) use ($table, $left, $parent, $pk, $siblingAlias): QueryExpression {
Loading history...
253
                    return $exp
254
                        ->add($exp->or(function (QueryExpression $exp) use ($parent, $siblingAlias): QueryExpression {
255
                            $siblingParent = sprintf('%s.%s', $siblingAlias, $this->getConfigOrFail('parent'));
256
257
                            return $exp
258
                                ->equalFields($parent, $siblingParent)
259
                                ->add($exp->and(function (QueryExpression $exp) use ($parent, $siblingParent): QueryExpression {
260
                                    return $exp->isNull($parent)->isNull($siblingParent);
261
                                }));
262
                        }))
263
                        ->gte($left, new IdentifierExpression(sprintf('%s.%s', $siblingAlias, $this->getConfigOrFail('left'))))
264
                        ->notEq($pk, new IdentifierExpression(sprintf('%s.%s', $siblingAlias, $table->getPrimaryKey())));
0 ignored issues
show
Bug introduced by
It seems like $table->getPrimaryKey() can also be of type string[]; however, parameter $values of sprintf() does only seem to accept double|integer|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

264
                        ->notEq($pk, new IdentifierExpression(sprintf('%s.%s', $siblingAlias, /** @scrutinizer ignore-type */ $table->getPrimaryKey())));
Loading history...
265
                }
266
            )
267
            ->group([$pk, $left])
268
            ->having(function (QueryExpression $exp, Query $query) use ($siblingAlias, $left): QueryExpression {
269
                return $exp->notEq(
270
                    new Comparison($left, 1, null, '-'),
271
                    $query->func()->max(sprintf('%s.%s', $siblingAlias, $this->getConfigOrFail('right')))
272
                );
273
            });
274
        if ($exists($query)) {
275
            $errors[] = sprintf('Found record where %s - 1 != MAX(previousSiblings.%s)', $this->getConfigOrFail('left'), $this->getConfigOrFail('right'));
276
        }
277
278
        return $errors;
279
    }
280
}
281