Passed
Push — 4-cactus ( b2c7a3...687d36 )
by
unknown
09:17
created

FoldersTable::getSort()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 17
rs 9.2222
c 0
b 0
f 0
cc 6
nc 6
nop 1
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\Table;
15
16
use BEdita\Core\Model\Entity\Folder;
17
use Cake\Database\Expression\QueryExpression;
18
use Cake\Datasource\EntityInterface;
19
use Cake\Event\Event;
20
use Cake\ORM\Query;
21
use Cake\ORM\RulesChecker;
22
use Cake\ORM\Rule\ValidCount;
23
24
/**
25
 * Folders Model
26
 *
27
 * @property \BEdita\Core\Model\Table\TreesTable|\Cake\ORM\Association\HasOne $TreeParentNodes
28
 * @property \BEdita\Core\Model\Table\ObjectsTable|\Cake\ORM\Association\BelongsToMany $Children
29
 * @method \BEdita\Core\Model\Entity\Folder get($primaryKey, $options = [])
30
 * @method \BEdita\Core\Model\Entity\Folder newEntity($data = null, array $options = [])
31
 * @method \BEdita\Core\Model\Entity\Folder[] newEntities(array $data, array $options = [])
32
 * @method \BEdita\Core\Model\Entity\Folder|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
33
 * @method \BEdita\Core\Model\Entity\Folder patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
34
 * @method \BEdita\Core\Model\Entity\Folder[] patchEntities($entities, array $data, array $options = [])
35
 * @method \BEdita\Core\Model\Entity\Folder findOrCreate($search, callable $callback = null, $options = [])
36
 * @since 4.0.0
37
 */
38
class FoldersTable extends ObjectsTable
39
{
40
    /**
41
     * {@inheritDoc}
42
     *
43
     * @codeCoverageIgnore
44
     */
45
    public function initialize(array $config): void
46
    {
47
        parent::initialize($config);
48
49
        $this->setEntityClass(Folder::class);
50
51
        $this->belongsToMany('Children', [
52
            'className' => 'Objects',
53
            'through' => 'Trees',
54
            'foreignKey' => 'parent_id',
55
            'targetForeignKey' => 'object_id',
56
            'finder' => 'available',
57
            'sort' => [
58
                'Trees.tree_left' => 'asc',
59
            ],
60
            'cascadeCallbacks' => true,
61
        ]);
62
63
        $this->hasMany('TreeParentNodes', [
64
            'className' => 'Trees',
65
            'foreignKey' => 'parent_id',
66
        ]);
67
    }
68
69
    /**
70
     * {@inheritDoc}
71
     *
72
     * @codeCoverageIgnore
73
     */
74
    public function buildRules(RulesChecker $rules): RulesChecker
75
    {
76
        $rules = parent::buildRules($rules);
77
78
        $rules->add(
79
            [$this, 'hasAtMostOneParent'],
80
            'hasAtMostOneParent',
81
            [
82
                'errorField' => 'parents',
83
                'message' => __d('bedita', 'Folder can have at most one existing parent.'),
84
            ]
85
        );
86
87
        $rules->add(
88
            [$this, 'isFolderRestorable'],
89
            'isFolderRestorable',
90
            [
91
                'errorField' => 'deleted',
92
                'message' => __d('bedita', 'Folder can be restored only if its ancestors are not deleted.'),
93
            ]
94
        );
95
96
        return $rules;
97
    }
98
99
    /**
100
     * Custom rule for checking that entity has at most one parent.
101
     * The check is done on `parents` property
102
     *
103
     * @param \BEdita\Core\Model\Entity\Folder $entity The folder entity to check
104
     * @return bool
105
     */
106
    public function hasAtMostOneParent(Folder $entity)
107
    {
108
        if (empty($entity->parents)) {
109
            return true;
110
        }
111
112
        $rule = new ValidCount('parents');
113
114
        return $rule($entity, ['operator' => '==', 'count' => 1]) && !empty($entity->parent->id);
115
    }
116
117
    /**
118
     * Custom rule to check if the folder entity is restorable
119
     * i.e. if its parents have not been deleted.
120
     *
121
     * If entity is new or `deleted` is not dirty (no change) or it is equal to true (delete action) then return true.
122
     *
123
     * @param \BEdita\Core\Model\Entity\Folder $entity The entity to check
124
     * @return bool
125
     */
126
    public function isFolderRestorable(Folder $entity)
127
    {
128
        if ($entity->isNew() || !$entity->isDirty('deleted') || $entity->deleted === true) {
129
            return true;
130
        }
131
132
        $node = $this->TreeNodes
133
            ->find()
134
            ->where([$this->TreeNodes->aliasField('object_id') => $entity->id])
135
            ->firstOrFail();
136
137
        $deletedParents = $this->find()
138
            ->innerJoinWith('TreeNodes', function (Query $query) use ($node) {
139
                return $query->where(function (QueryExpression $exp) use ($node) {
140
                    return $exp
141
                        ->lt($this->TreeNodes->aliasField('tree_left'), $node->get('tree_left'))
142
                        ->gt($this->TreeNodes->aliasField('tree_right'), $node->get('tree_right'));
143
                });
144
            })
145
            ->where([$this->aliasField('deleted') => true])
146
            ->count();
147
148
        return $deletedParents === 0;
149
    }
150
151
    /**
152
     * Set `parents` as not dirty to prevent automatic save that could breaks the tree.
153
     * The tree is saved later in `afterSave()`
154
     *
155
     * @param \Cake\Event\Event $event The event
156
     * @param \Cake\Datasource\EntityInterface $entity The entity to save
157
     * @return void
158
     */
159
    public function beforeSave(Event $event, EntityInterface $entity)
160
    {
161
        parent::beforeSave($event, $entity);
162
163
        $entity->setDirty('parents', false);
164
    }
165
166
    /**
167
     * Update the tree setting the right parent.
168
     *
169
     * @param \Cake\Event\Event $event The event
170
     * @param \BEdita\Core\Model\Entity\Folder $entity The folder entity persisted
171
     * @return void
172
     */
173
    public function afterSave(Event $event, Folder $entity)
174
    {
175
        $this->updateChildrenDeletedField($entity);
176
177
        // no update on the tree
178
        if (!$entity->isNew() && !$entity->isParentSet()) {
179
            return;
180
        }
181
182
        if ($entity->isNew()) {
183
            $node = $this->TreeNodes->newEntity([
184
                'object_id' => $entity->id,
185
                'parent_id' => $entity->parent_id,
186
            ]);
187
            $this->TreeNodes->saveOrFail($node);
188
189
            return;
190
        }
191
192
        /** @var \BEdita\Core\Model\Entity\Tree $node */
193
        $node = $this->TreeNodes->find()
194
            ->where(['object_id' => $entity->id])
195
            ->firstOrFail();
196
197
        // parent unchanged
198
        if ($entity->parent_id === $node->parent_id) {
199
            return;
200
        }
201
202
        $node->parent_id = $entity->parent_id;
203
        $this->TreeNodes->saveOrFail($node);
204
    }
205
206
    /**
207
     * Prepare all descendants of type "folders" in `$options` to delete later in `static::afterDelete()`.
208
     *
209
     * ### Options
210
     *
211
     * `_isDescendant` default empty. When not empty means that the deletion was cascading from a parent so no other action needed.
212
     *
213
     * @param \Cake\Event\Event $event The event
214
     * @param \Cake\Datasource\EntityInterface $entity The folder entity to delete
215
     * @param \ArrayObject $options Delete options
216
     * @return void
217
     */
218
    public function beforeDelete(Event $event, EntityInterface $entity, \ArrayObject $options)
219
    {
220
        if (!empty($options['_isDescendant'])) {
221
            return;
222
        }
223
224
        $options['descendants'] = $this
225
            ->find('ancestor', [$entity->get('id')])
226
            ->where([
227
                $this->aliasField('object_type_id') => $this->objectType()->id,
0 ignored issues
show
Bug introduced by
The property id does not seem to exist on Cake\ORM\Query.
Loading history...
Bug introduced by
The method objectType() does not exist on BEdita\Core\Model\Table\FoldersTable. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

227
                $this->aliasField('object_type_id') => $this->/** @scrutinizer ignore-call */ objectType()->id,
Loading history...
228
            ])
229
            ->toArray();
230
    }
231
232
    /**
233
     * Delete all descendants of type "folders" if exist.
234
     *
235
     * @param \Cake\Event\Event $event The event
236
     * @param \Cake\Datasource\EntityInterface $entity The folder entity to delete
237
     * @param \ArrayObject $options Delete options
238
     * @return void
239
     */
240
    public function afterDelete(Event $event, EntityInterface $entity, \ArrayObject $options)
241
    {
242
        if (empty($options['descendants'])) {
243
            return;
244
        }
245
246
        foreach ((array)$options['descendants'] as $subfolder) {
247
            $this->deleteOrFail($subfolder, ['_isDescendant' => true]);
248
        }
249
    }
250
251
    /**
252
     * Finder for root folders.
253
     *
254
     * @param \Cake\ORM\Query $query Query object instance.
255
     * @return \Cake\ORM\Query
256
     */
257
    protected function findRoots(Query $query)
258
    {
259
        return $query
260
            ->innerJoinWith('TreeNodes', function (Query $query) {
261
                return $query->where(function (QueryExpression $exp) {
262
                    return $exp->isNull($this->TreeNodes->aliasField('parent_id'));
263
                });
264
            })
265
            ->order('TreeNodes.tree_left');
266
    }
267
268
    /**
269
     * Update the `deleted` field of children folders to parent value.
270
     * The update is executed only if parent folder `deleted` is dirty.
271
     *
272
     * @param \BEdita\Core\Model\Entity\Folder $folder The parent folder.
273
     * @return void
274
     */
275
    protected function updateChildrenDeletedField(Folder $folder)
276
    {
277
        if (!$folder->isDirty('deleted')) {
278
            return;
279
        }
280
281
        // use Trees table to build subquery and not `static::findAncestor()` custom finder because
282
        // the update fails on MySql when "attempts to select from and modify the same table within a single statement."
283
        // @see https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html#error_er_update_table_used
284
        $parentNode = $this->TreeNodes
285
            ->find()
286
            ->where([$this->TreeNodes->aliasField('object_id') => $folder->id])
287
            ->firstOrFail();
288
289
        $descendantsToUpdate = $this->TreeNodes
290
            ->find()
291
            ->select(['object_id'])
292
            ->where(function (QueryExpression $exp) use ($parentNode) {
293
                return $exp
294
                    ->gt($this->TreeNodes->aliasField('tree_left'), $parentNode->get('tree_left'))
295
                    ->lt($this->TreeNodes->aliasField('tree_right'), $parentNode->get('tree_right'));
296
            });
297
298
        // Update deleted field of descendants
299
        $this->updateAll(
300
            [
301
                'deleted' => $folder->deleted,
302
                'modified' => $this->timestamp(null, true),
0 ignored issues
show
Bug introduced by
The method timestamp() does not exist on BEdita\Core\Model\Table\FoldersTable. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

302
                'modified' => $this->/** @scrutinizer ignore-call */ timestamp(null, true),
Loading history...
303
                'modified_by' => $this->userId(),
0 ignored issues
show
Bug introduced by
The method userId() does not exist on BEdita\Core\Model\Table\FoldersTable. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

303
                'modified_by' => $this->/** @scrutinizer ignore-call */ userId(),
Loading history...
304
            ],
305
            [
306
                'id IN' => $descendantsToUpdate,
307
                'object_type_id' => $this->objectType()->id,
0 ignored issues
show
Bug introduced by
The property id does not seem to exist on Cake\ORM\Query.
Loading history...
308
                'deleted IS NOT' => $folder->deleted,
309
            ]
310
        );
311
    }
312
313
    /**
314
     * Get sort by object ID.
315
     * Default 'Trees.tree_left' => 'asc'
316
     *
317
     * @param int $id The tree object ID
318
     * @return array
319
     */
320
    public function getSort(int $id): array
321
    {
322
        /** @var \BEdita\Core\Model\Entity\Folder $entity */
323
        $entity = $this->get($id);
324
        $order = $entity->get('children_order');
325
        if (empty($order) || $order === 'position') {
326
            return ['Trees.tree_left' => 'asc'];
327
        }
328
        if ($order === '-position') {
329
            return ['Trees.tree_left' => 'desc'];
330
        }
331
        $sign = substr($order, 0, 1);
332
        $direction = $sign === '-' ? 'desc' : 'asc';
333
        $field = $sign === '-' ? substr($order, 1) : $order;
334
        $key = sprintf('Children.%s', $field);
335
336
        return [$key => $direction];
337
    }
338
}
339