Passed
Push — 4-cactus ( 2e1862...171c9a )
by Stefano
02:30
created

FoldersTable::afterDelete()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 3
nop 3
dl 0
loc 8
rs 10
c 0
b 0
f 0
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
use Cake\ORM\TableRegistry;
24
25
/**
26
 * Folders Model
27
 *
28
 * @property \Cake\ORM\Association\HasOne $Trees
29
 * @property \Cake\ORM\Association\BelongsToMany $Children
30
 *
31
 * @method \BEdita\Core\Model\Entity\Folder get($primaryKey, $options = [])
32
 * @method \BEdita\Core\Model\Entity\Folder newEntity($data = null, array $options = [])
33
 * @method \BEdita\Core\Model\Entity\Folder[] newEntities(array $data, array $options = [])
34
 * @method \BEdita\Core\Model\Entity\Folder|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
35
 * @method \BEdita\Core\Model\Entity\Folder patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
36
 * @method \BEdita\Core\Model\Entity\Folder[] patchEntities($entities, array $data, array $options = [])
37
 * @method \BEdita\Core\Model\Entity\Folder findOrCreate($search, callable $callback = null, $options = [])
38
 *
39
 * @since 4.0.0
40
 *
41
 */
42
class FoldersTable extends ObjectsTable
43
{
44
45
    /**
46
     * {@inheritDoc}
47
     *
48
     * @codeCoverageIgnore
49
     */
50
    public function initialize(array $config)
51
    {
52
        parent::initialize($config);
53
54
        $this->setEntityClass(Folder::class);
55
56
        $this->belongsToMany('Children', [
57
            'className' => 'Objects',
58
            'through' => 'Trees',
59
            'foreignKey' => 'parent_id',
60
            'targetForeignKey' => 'object_id',
61
            'sort' => [
62
                'Trees.tree_left' => 'asc',
63
            ],
64
            'cascadeCallbacks' => true,
65
        ]);
66
67
        $this->hasMany('TreeParentNodes', [
68
            'className' => 'Trees',
69
            'foreignKey' => 'parent_id',
70
        ]);
71
    }
72
73
    /**
74
     * {@inheritDoc}
75
     *
76
     * @codeCoverageIgnore
77
     */
78
    public function buildRules(RulesChecker $rules)
79
    {
80
        $rules->add(
81
            [$this, 'hasAtMostOneParent'],
82
            'hasAtMostOneParent',
83
            [
84
                'errorField' => 'parents',
85
                'message' => __d('bedita', 'Folder can have at most one existing parent.'),
86
            ]
87
        );
88
89
        $rules->add(
90
            [$this, 'isFolderRestorable'],
91
            'isFolderRestorable',
92
            [
93
                'errorField' => 'deleted',
94
                'message' => __d('bedita', 'Folder can be restored only if its ancestors are not deleted.'),
95
            ]
96
        );
97
98
        return $rules;
99
    }
100
101
    /**
102
     * Custom rule for checking that entity has at most one parent.
103
     * The check is done on `parents` property
104
     *
105
     * @param Folder $entity The folder entity to check
106
     * @return bool
107
     */
108
    public function hasAtMostOneParent(Folder $entity)
109
    {
110
        if (empty($entity->parents)) {
111
            return true;
112
        }
113
114
        $rule = new ValidCount('parents');
115
116
        return $rule($entity, ['operator' => '==', 'count' => 1]) && !empty($entity->parent->id);
117
    }
118
119
    /**
120
     * Custom rule to check if the folder entity is restorable
121
     * i.e. if its parents have not been deleted.
122
     *
123
     * If entity is new or `deleted` is not dirty (no change) or it is equal to true (delete action) then return true.
124
     *
125
     * @param Folder $entity The entity to check
126
     * @return bool
127
     */
128
    public function isFolderRestorable(Folder $entity)
129
    {
130
        if ($entity->isNew() || !$entity->isDirty('deleted') || $entity->deleted === true) {
131
            return true;
132
        }
133
134
        $node = $this->TreeNodes
135
            ->find()
136
            ->where([$this->TreeNodes->aliasField('object_id') => $entity->id])
137
            ->firstOrFail();
138
139
        $deletedParents = $this->find()
140
            ->innerJoinWith('TreeNodes', function (Query $query) use ($node) {
141
                return $query->where(function (QueryExpression $exp) use ($node) {
142
                    return $exp
143
                        ->lt($this->TreeNodes->aliasField('tree_left'), $node->get('tree_left'))
144
                        ->gt($this->TreeNodes->aliasField('tree_right'), $node->get('tree_right'));
145
                });
146
            })
147
            ->where([$this->aliasField('deleted') => true])
148
            ->count();
149
150
        return $deletedParents === 0;
151
    }
152
153
    /**
154
     * Set `parents` as not dirty to prevent automatic save that could breaks the tree.
155
     * The tree is saved later in `afterSave()`
156
     *
157
     * @param Event $event The event
158
     * @param EntityInterface $entity The entity to save
159
     * @return void
160
     */
161
    public function beforeSave(Event $event, EntityInterface $entity)
162
    {
163
        $entity->dirty('parents', false);
164
    }
165
166
    /**
167
     * Update the tree setting the right parent.
168
     *
169
     * @param Event $event The event
170
     * @param EntityInterface $entity The folder entity persisted
171
     * @return void
172
     */
173
    public function afterSave(Event $event, EntityInterface $entity)
174
    {
175
        $this->updateChildrenDeletedField($entity);
176
177
        // no update on the tree
178
        if (!$entity->isNew() && !$entity->isParentSet()) {
179
            return;
180
        }
181
182
        $trees = TableRegistry::get('Trees');
183
184
        if ($entity->isNew()) {
185
            $node = $trees->newEntity([
186
                'object_id' => $entity->id,
187
                'parent_id' => $entity->parent_id,
188
            ]);
189
            $trees->saveOrFail($node);
190
191
            return;
192
        }
193
194
        $node = $trees->find()
195
            ->where(['object_id' => $entity->id])
196
            ->firstOrFail();
197
198
        // parent unchanged
199
        if ($entity->parent_id === $node->parent_id) {
200
            return;
201
        }
202
203
        $node->parent_id = $entity->parent_id;
204
        $trees->saveOrFail($node);
205
    }
206
207
    /**
208
     * Prepare all descendants of type "folders" in `$options` to delete later in `static::afterDelete()`.
209
     *
210
     * ### Options
211
     *
212
     * `_isDescendant` default empty. When not empty means that the deletion was cascading from a parent so no other action needed.
213
     *
214
     * @param \Cake\Event\Event $event The event
215
     * @param \Cake\Datasource\EntityInterface $entity The folder entity to delete
216
     * @param \ArrayObject $options Delete options
217
     * @return void
218
     */
219
    public function beforeDelete(Event $event, EntityInterface $entity, \ArrayObject $options)
220
    {
221
        if (!empty($options['_isDescendant'])) {
222
            return;
223
        }
224
225
        $options['descendants'] = $this
226
            ->find('ancestor', [$entity->get('id')])
227
            ->where([
228
                $this->aliasField('object_type_id') => $this->objectType()->id,
229
            ])
230
            ->toArray();
231
    }
232
233
    /**
234
     * Delete all descendants of type "folders" if exist.
235
     *
236
     * @param \Cake\Event\Event $event The event
237
     * @param \Cake\Datasource\EntityInterface $entity The folder entity to delete
238
     * @param \ArrayObject $options Delete options
239
     * @return void
240
     */
241
    public function afterDelete(Event $event, EntityInterface $entity, \ArrayObject $options)
242
    {
243
        if (empty($options['descendants'])) {
244
            return;
245
        }
246
247
        foreach ((array)$options['descendants'] as $subfolder) {
248
            $this->deleteOrFail($subfolder, ['_isDescendant' => true]);
249
        }
250
    }
251
252
    /**
253
     * Finder for root folders.
254
     *
255
     * @param Query $query Query object instance.
256
     * @return \Cake\ORM\Query
257
     */
258
    protected function findRoots(Query $query)
259
    {
260
        return $query
261
            ->innerJoinWith('TreeNodes', function (Query $query) {
262
                return $query->where(function (QueryExpression $exp) {
263
                    return $exp->isNull($this->TreeNodes->aliasField('parent_id'));
264
                });
265
            })
266
            ->order('TreeNodes.tree_left');
267
    }
268
269
    /**
270
     * Update the `deleted` field of children folders to parent value.
271
     * The update is executed only if parent folder `deleted` is dirty.
272
     *
273
     * @param Folder $folder The parent folder.
274
     * @return int
275
     */
276
    protected function updateChildrenDeletedField(Folder $folder)
277
    {
278
        if (!$folder->isDirty('deleted')) {
279
            return;
280
        }
281
282
        // use Trees table to build subquery and not `static::findAncestor()` custom finder because
283
        // the update fails on MySql when "attempts to select from and modify the same table within a single statement."
284
        // @see https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html#error_er_update_table_used
285
        $parentNode = $this->TreeNodes
286
            ->find()
287
            ->where([$this->TreeNodes->aliasField('object_id') => $folder->id])
288
            ->firstOrFail();
289
290
        $descendantsToUpdate = $this->TreeNodes
291
            ->find()
292
            ->select(['object_id'])
293
            ->where(function (QueryExpression $exp) use ($parentNode) {
294
                return $exp
295
                    ->gt($this->TreeNodes->aliasField('tree_left'), $parentNode->get('tree_left'))
296
                    ->lt($this->TreeNodes->aliasField('tree_right'), $parentNode->get('tree_right'));
297
            });
298
299
        // Update deleted field of descendants
300
        return $this->updateAll(
301
            [
302
                'deleted' => $folder->deleted,
303
                'modified' => $this->timestamp(null, true),
304
                'modified_by' => $this->userId(),
305
            ],
306
            [
307
                'id IN' => $descendantsToUpdate,
308
                'object_type_id' => $this->objectType()->id,
309
                'deleted IS NOT' => $folder->deleted,
310
            ]
311
        );
312
    }
313
}
314