Passed
Push — 4-cactus ( c243f7...5dfe85 )
by Paolo
03:01
created

ObjectTypesTable::findParent()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 2
dl 0
loc 11
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2016 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\Exception\BadFilterException;
17
use BEdita\Core\Model\Validation\ObjectTypesValidator;
18
use BEdita\Core\ORM\Rule\IsUniqueAmongst;
19
use Cake\Cache\Cache;
20
use Cake\Core\App;
21
use Cake\Database\Expression\Comparison;
22
use Cake\Database\Expression\QueryExpression;
23
use Cake\Database\Schema\TableSchema;
24
use Cake\Datasource\EntityInterface;
25
use Cake\Datasource\Exception\RecordNotFoundException;
26
use Cake\Event\Event;
27
use Cake\Network\Exception\BadRequestException;
28
use Cake\Network\Exception\ForbiddenException;
29
use Cake\ORM\Query;
30
use Cake\ORM\RulesChecker;
31
use Cake\ORM\Table;
32
use Cake\ORM\TableRegistry;
33
use Cake\Utility\Inflector;
34
35
/**
36
 * ObjectTypes Model
37
 *
38
 * @property \Cake\ORM\Association\HasMany $Objects
39
 * @property \Cake\ORM\Association\HasMany $Properties
40
 * @property \Cake\ORM\Association\BelongsToMany $LeftRelations
41
 * @property \Cake\ORM\Association\BelongsToMany $RightRelations
42
 *
43
 * @method \BEdita\Core\Model\Entity\ObjectType newEntity($data = null, array $options = [])
44
 * @method \BEdita\Core\Model\Entity\ObjectType[] newEntities(array $data, array $options = [])
45
 * @method \BEdita\Core\Model\Entity\ObjectType|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
46
 * @method \BEdita\Core\Model\Entity\ObjectType patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
47
 * @method \BEdita\Core\Model\Entity\ObjectType[] patchEntities($entities, array $data, array $options = [])
48
 * @method \BEdita\Core\Model\Entity\ObjectType findOrCreate($search, callable $callback = null, $options = [])
49
 *
50
 * @mixin \Cake\ORM\Behavior\TreeBehavior
51
 *
52
 * @since 4.0.0
53
 */
54
class ObjectTypesTable extends Table
55
{
56
    /**
57
     * Cache config name for object types.
58
     *
59
     * @var string
60
     */
61
    const CACHE_CONFIG = '_bedita_object_types_';
62
63
    /**
64
     * Default parent id 1 for `objects`.
65
     *
66
     * @var int
67
     */
68
    const DEFAULT_PARENT_ID = 1;
69
70
    /**
71
     * Default `plugin` if not specified.
72
     *
73
     * @var string
74
     */
75
    const DEFAULT_PLUGIN = 'BEdita/Core';
76
77
    /**
78
     * Default `model` if not specified.
79
     *
80
     * @var string
81
     */
82
    const DEFAULT_MODEL = 'Objects';
83
84
    /**
85
     * {@inheritDoc}
86
     */
87
    protected $_validatorClass = ObjectTypesValidator::class;
88
89
    /**
90
     * {@inheritDoc}
91
     *
92
     * @codeCoverageIgnore
93
     */
94
    public function initialize(array $config)
95
    {
96
        parent::initialize($config);
97
98
        $this->setTable('object_types');
99
        $this->setPrimaryKey('id');
100
        $this->setDisplayField('name');
101
102
        $this->hasMany('Objects', [
103
            'foreignKey' => 'object_type_id',
104
            'className' => 'Objects',
105
        ]);
106
107
        $this->hasMany('Properties', [
108
            'foreignKey' => 'property_type_id',
109
            'className' => 'Properties',
110
            'dependent' => true,
111
        ]);
112
113
        $through = TableRegistry::get('LeftRelationTypes', ['className' => 'RelationTypes']);
114
        $this->belongsToMany('LeftRelations', [
115
            'className' => 'Relations',
116
            'through' => $through->getRegistryAlias(),
117
            'foreignKey' => 'object_type_id',
118
            'targetForeignKey' => 'relation_id',
119
            'conditions' => [
120
                $through->aliasField('side') => 'left',
121
            ],
122
        ]);
123
        $through = TableRegistry::get('RightRelationTypes', ['className' => 'RelationTypes']);
124
        $this->belongsToMany('RightRelations', [
125
            'className' => 'Relations',
126
            'through' => $through->getRegistryAlias(),
127
            'foreignKey' => 'object_type_id',
128
            'targetForeignKey' => 'relation_id',
129
            'conditions' => [
130
                $through->aliasField('side') => 'right',
131
            ],
132
        ]);
133
134
        $this->belongsTo('Parent', [
135
            'foreign_key' => 'parent_id',
136
            'className' => 'ObjectTypes',
137
        ]);
138
        $this->addBehavior('Timestamp');
139
        $this->addBehavior('Tree', [
140
            'left' => 'tree_left',
141
            'right' => 'tree_right',
142
        ]);
143
        $this->addBehavior('BEdita/Core.Searchable', [
144
            'fields' => [
145
                'name' => 10,
146
                'singular' => 10,
147
                'description' => 5,
148
            ],
149
        ]);
150
    }
151
152
    /**
153
     * {@inheritDoc}
154
     *
155
     * @codeCoverageIgnore
156
     */
157
    public function buildRules(RulesChecker $rules)
158
    {
159
        $rules
160
            ->add(new IsUniqueAmongst(['name' => ['name', 'singular']]), '_isUniqueAmongst', [
161
                'errorField' => 'name',
162
                'message' => __d('cake', 'This value is already in use'),
163
            ])
164
            ->add(new IsUniqueAmongst(['singular' => ['name', 'singular']]), '_isUniqueAmongst', [
165
                'errorField' => 'singular',
166
                'message' => __d('cake', 'This value is already in use'),
167
            ]);
168
169
        return $rules;
170
    }
171
172
    /**
173
     * {@inheritDoc}
174
     *
175
     * @codeCoverageIgnore
176
     */
177
    protected function _initializeSchema(TableSchema $schema)
178
    {
179
        $schema->setColumnType('associations', 'json');
180
        $schema->setColumnType('hidden', 'json');
181
182
        return $schema;
183
    }
184
185
    /**
186
     * {@inheritDoc}
187
     *
188
     * @return \BEdita\Core\Model\Entity\ObjectType
189
     */
190
    public function get($primaryKey, $options = [])
191
    {
192
        if (is_string($primaryKey) && !is_numeric($primaryKey)) {
193
            $allTypes = array_flip(
194
                $this->find('list')
195
                    ->cache('map', self::CACHE_CONFIG)
196
                    ->toArray()
197
            );
198
            $allTypes += array_flip(
199
                $this->find('list', ['valueField' => 'singular'])
200
                    ->cache('map_singular', self::CACHE_CONFIG)
201
                    ->toArray()
202
            );
203
204
            $primaryKey = Inflector::underscore($primaryKey);
205
            if (!isset($allTypes[$primaryKey])) {
206
                throw new RecordNotFoundException(sprintf(
207
                    'Record not found in table "%s"',
208
                    $this->getTable()
209
                ));
210
            }
211
212
            $primaryKey = $allTypes[$primaryKey];
213
        }
214
215
        if (empty($options)) {
216
            $options = [
217
                'key' => sprintf('id_%d_rel', $primaryKey),
218
                'cache' => self::CACHE_CONFIG,
219
                'contain' => ['LeftRelations.RightObjectTypes', 'RightRelations.LeftObjectTypes'],
220
            ];
221
        }
222
223
        return parent::get($primaryKey, $options);
224
    }
225
226
    /**
227
     * Set default `parent_id`, `plugin` and `model` on creation if missing.
228
     * Prevent delete if:
229
     *  - type is abstract and a subtype exists
230
     *  - is a `core_type`, you may set `enabled` false in this case
231
     *
232
     * Controls are performed here insted of `beforeSave()` or `beforeDelete()`
233
     * in order to be executed before corresponding methods in `TreeBehavior`.
234
     *
235
     * @param \Cake\Event\Event $event The event dispatched
236
     * @param \Cake\Datasource\EntityInterface $entity The entity to save
237
     * @return void
238
     * @throws \Cake\Network\Exception\ForbiddenException if operation on entity is not allowed
239
     */
240
    public function beforeRules(Event $event, EntityInterface $entity)
241
    {
242
        if ($entity->isNew()) {
243
            if (empty($entity->get('parent_id'))) {
244
                $entity->set('parent_id', self::DEFAULT_PARENT_ID);
245
            }
246
            if (empty($entity->get('table'))) {
247
                $entity->set('table', self::DEFAULT_PLUGIN . '.' . self::DEFAULT_MODEL);
248
            }
249
        }
250
        if ($event->getData('operation') === 'delete') {
251
            if ($entity->get('is_abstract') && $this->childCount($entity) > 0) {
0 ignored issues
show
Bug introduced by
The method childCount() does not exist on BEdita\Core\Model\Table\ObjectTypesTable. 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

251
            if ($entity->get('is_abstract') && $this->/** @scrutinizer ignore-call */ childCount($entity) > 0) {
Loading history...
252
                throw new ForbiddenException(__d('bedita', 'Abstract type with existing subtypes'));
253
            }
254
            if ($entity->get('core_type')) {
255
                throw new ForbiddenException(__d('bedita', 'Core types are not removable'));
256
            }
257
        }
258
        if ($entity->isDirty('parent_id') && $this->objectsExist($entity->get('id'))) {
259
            throw new ForbiddenException(__d('bedita', 'Parent type change forbidden: objects of this type exist'));
260
        }
261
    }
262
263
    /**
264
     * Invalidate cache after saving an object type.
265
     * Recover Nested Set Model tree structure (tree_left, tree_right)
266
     *
267
     * @return void
268
     */
269
    public function afterSave()
270
    {
271
        Cache::clear(false, self::CACHE_CONFIG);
272
    }
273
274
    /**
275
     * Forbidden operations:
276
     *  - `is_abstract` set to `true` if at least an object of this type exists
277
     *  - `is_abstract` set to `false` if a subtype exist.
278
     *  - `enabled` is set to false and objects of this type or subtypes exist
279
     *  - `table` is not a valid table model class
280
     *
281
     * @param \Cake\Event\Event $event The beforeSave event that was fired
282
     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
283
     * @return void
284
     * @throws \Cake\Network\Exception\ForbiddenException|\Cake\Network\Exception\BadRequestException if entity is not saveable
285
     */
286
    public function beforeSave(Event $event, EntityInterface $entity)
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

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

286
    public function beforeSave(/** @scrutinizer ignore-unused */ Event $event, EntityInterface $entity)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
287
    {
288
        if ($entity->isDirty('is_abstract')) {
289
            if ($entity->get('is_abstract') && $this->objectsExist($entity->get('id'))) {
290
                throw new ForbiddenException(__d('bedita', 'Setting as abstract forbidden: objects of this type exist'));
291
            } elseif (!$entity->get('is_abstract') && $this->childCount($entity) > 0) {
292
                throw new ForbiddenException(__d('bedita', 'Setting as not abstract forbidden: subtypes exist'));
293
            }
294
        }
295
        if ($entity->isDirty('enabled') && !$entity->get('enabled')) {
296
            if ($this->objectsExist($entity->get('id'))) {
297
                throw new ForbiddenException(__d('bedita', 'Type disable forbidden: objects of this type exist'));
298
            } elseif ($this->childCount($entity) > 0) {
299
                throw new ForbiddenException(__d('bedita', 'Type disable forbidden: subtypes exist'));
300
            }
301
        }
302
        if ($entity->isDirty('table') && !App::className($entity->get('table'), 'Model/Table', 'Table')) {
303
            throw new BadRequestException(__d('bedita', '"{0}" is not a valid model table name', [$entity->get('table')]));
304
        }
305
    }
306
307
    /**
308
     * Check if objects of a certain type id exist
309
     *
310
     * @param int $typeId Object type id
311
     * @return bool True if at least an object exists, false otherwise
312
     */
313
    protected function objectsExist($typeId)
314
    {
315
        return TableRegistry::get('Objects')->exists(['object_type_id' => $typeId]);
316
    }
317
318
    /**
319
     * Don't allow delete actions if at least an object of this type exists.
320
     *
321
     * @param \Cake\Event\Event $event The beforeDelete event that was fired
322
     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be deleted
323
     * @return void
324
     * @throws \Cake\Network\Exception\ForbiddenException if entity is not deletable
325
     */
326
    public function beforeDelete(Event $event, EntityInterface $entity)
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

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

326
    public function beforeDelete(/** @scrutinizer ignore-unused */ Event $event, EntityInterface $entity)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
327
    {
328
        if ($this->objectsExist($entity->get('id'))) {
329
            throw new ForbiddenException(__d('bedita', 'Objects of this type exist'));
330
        }
331
    }
332
333
    /**
334
     * Invalidate cache after deleting an object type.
335
     *
336
     * @return void
337
     */
338
    public function afterDelete()
339
    {
340
        Cache::clear(false, self::CACHE_CONFIG);
341
    }
342
343
    /**
344
     * {@inheritDoc}
345
     */
346
    public function findAll(Query $query, array $options)
347
    {
348
        return $query->contain(['LeftRelations', 'RightRelations']);
349
    }
350
351
    /**
352
     * Find object types having a parent by `name` or `id`
353
     *
354
     * @param \Cake\ORM\Query $query Query object.
355
     * @param array $options Additional options. The first element containing `id` or `name` is required.
356
     * @return \Cake\ORM\Query
357
     * @throws \BEdita\Core\Exception\BadFilterException When missing required parameters.
358
     */
359
    public function findParent(Query $query, array $options)
360
    {
361
        if (empty($options[0])) {
362
            throw new BadFilterException(__d('bedita', 'Missing required parameter "{0}"', 'parent'));
363
        }
364
        $parentId = $options[0];
365
        if (!is_numeric($parentId)) {
366
            $parentId = $this->get($parentId)->id;
367
        }
368
369
        return $query->where([$this->aliasField('parent_id') => $parentId]);
370
    }
371
372
    /**
373
     * Find allowed object types by relation name and side.
374
     *
375
     * This finder returns a list of object types that are allowed for the
376
     * relation specified by the required option `name`. You can specify the
377
     * side of the relation you want to retrieve allowed object types for by
378
     * passing an additional option `side` (default: `'right'`).
379
     *
380
     * If the specified relation name is actually the name of an inverse relation,
381
     * this finder automatically takes care of "swapping" sides, always returning
382
     * correct results.
383
     *
384
     * ### Example
385
     *
386
     * ```php
387
     * // Find object types allowed on the "right" side:
388
     * TableRegistry::get('ObjectTypes')
389
     *     ->find('byRelation', ['name' => 'my_relation']);
390
     *
391
     * // Find a list of object type names allowed on the "left" side of the inverse relation:
392
     * TableRegistry::get('ObjectTypes')
393
     *     ->find('byRelation', ['name' => 'my_inverse_relation', 'side' => 'left'])
394
     *     ->find('list')
395
     *     ->toArray();
396
     *
397
     * // Include also descendants of the allowed object types (e.g.: return **Images** whereas **Media** are allowed):
398
     * TableRegistry::get('ObjectTypes')
399
     *     ->find('byRelation', ['name' => 'my_relation', 'descendants' => true]);
400
     * ```
401
     *
402
     * @param \Cake\ORM\Query $query Query object.
403
     * @param array $options Additional options. The `name` key is required, while `side` is optional
404
     *      and assumed to be `'right'` by default.
405
     * @return \Cake\ORM\Query
406
     * @throws \LogicException When missing required parameters.
407
     */
408
    protected function findByRelation(Query $query, array $options = [])
409
    {
410
        if (empty($options['name'])) {
411
            throw new \LogicException(__d('bedita', 'Missing required parameter "{0}"', 'name'));
412
        }
413
        $name = Inflector::underscore($options['name']);
414
415
        $leftField = 'inverse_name';
416
        $rightField = 'name';
417
        if (!empty($options['side']) && $options['side'] !== 'right') {
418
            $leftField = 'name';
419
            $rightField = 'inverse_name';
420
        }
421
422
        // Build sub-queries to find object-types that lay on the left and right side of searched relationship, respectively.
423
        $leftSubQuery = $this->find()
424
            ->innerJoinWith('LeftRelations', function (Query $query) use ($name, $leftField) {
425
                return $query->where(function (QueryExpression $exp) use ($name, $leftField) {
426
                    return $exp->eq($this->LeftRelations->aliasField($leftField), $name);
427
                });
428
            });
429
        $rightSubQuery = $this->find()
430
            ->innerJoinWith('RightRelations', function (Query $query) use ($name, $rightField) {
431
                return $query->where(function (QueryExpression $exp) use ($name, $rightField) {
432
                    return $exp->eq($this->RightRelations->aliasField($rightField), $name);
433
                });
434
            });
435
436
        // Conditions builder that filters only object types returned by one of the two sub-queries.
437
        // This could be achieved more efficiently using two left joins, but if we need to find also
438
        // descendants it's simpler done this way.
439
        $conditionsBuilder = function (QueryExpression $exp) use ($leftSubQuery, $rightSubQuery) {
440
            return $exp->or_(function (QueryExpression $exp) use ($leftSubQuery, $rightSubQuery) {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type string|array|Cake\Database\ExpressionInterface expected by parameter $conditions of Cake\Database\Expression\QueryExpression::or_(). ( Ignorable by Annotation )

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

440
            return $exp->or_(/** @scrutinizer ignore-type */ function (QueryExpression $exp) use ($leftSubQuery, $rightSubQuery) {
Loading history...
441
                return $exp
442
                    ->in($this->aliasField('id'), $leftSubQuery->select(['id']))
443
                    ->in($this->aliasField('id'), $rightSubQuery->select(['id']));
444
            });
445
        };
446
447
        if (!empty($options['descendants'])) { // We don't need only explicitly linked object types, but also their descendants!
448
            // Obtain Nested-Set-Model left and right counters for the explicitly-linked object types, that are obtained
449
            // using the `$conditionsBuilder` built before.
450
            $nsmCounters = $this->find()
451
                ->select(['tree_left', 'tree_right'])
452
                ->where($conditionsBuilder)
453
                ->enableHydration(false)
454
                ->all();
455
456
            // Replace `$conditionsBuilder` with a more complex one that returns not only the matching object types,
457
            // but also their descendants.
458
            $conditionsBuilder = function (QueryExpression $exp) use ($nsmCounters) {
459
                if ($nsmCounters->count() === 0) {
460
                    // No nodes found: relationship apparently does not exist, or has no linked types.
461
                    // Add contradiction to force empty results.
462
                    return $exp->add(new Comparison(1, 1, 'integer', '<>'));
463
                }
464
465
                // Find descendants for all found nodes using NSM rules.
466
                // If the nodes found are [l = 3, r = 8] and [l = 9, r = 10], the conditions will be built as follows:
467
                // ... WHERE (tree_left >= 3 AND tree_right <= 8) OR (tree_left >= 9 AND tree_right <= 10)
468
                return $exp->or_(
469
                    $nsmCounters
470
                        ->map(function (array $row) use ($exp) {
471
                            return $exp->and_(function (QueryExpression $exp) use ($row) {
0 ignored issues
show
Bug introduced by
function(...) { /* ... */ } of type callable is incompatible with the type string|array|Cake\Database\ExpressionInterface expected by parameter $conditions of Cake\Database\Expression\QueryExpression::and_(). ( Ignorable by Annotation )

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

471
                            return $exp->and_(/** @scrutinizer ignore-type */ function (QueryExpression $exp) use ($row) {
Loading history...
472
                                return $exp
473
                                    ->gte($this->aliasField('tree_left'), $row['tree_left'])
474
                                    ->lte($this->aliasField('tree_right'), $row['tree_right']);
475
                            });
476
                        })
477
                        ->toArray()
478
                );
479
            };
480
        }
481
482
        // Everything is said and done by now. Fingers crossed!
483
        return $query->where($conditionsBuilder);
484
    }
485
486
    /**
487
     * Finder to get object type starting from object id or uname.
488
     *
489
     * @param \Cake\ORM\Query $query Query object.
490
     * @param array $options Additional options. The `id` key is required.
491
     * @return \Cake\ORM\Query
492
     * @throws \BEdita\Core\Exception\BadFilterException When missing required parameters.
493
     */
494
    protected function findObjectId(Query $query, array $options = [])
495
    {
496
        if (empty($options['id'])) {
497
            throw new BadFilterException(__d('bedita', 'Missing required parameter "{0}"', 'id'));
498
        }
499
500
        return $query->innerJoinWith('Objects', function (Query $query) use ($options) {
501
            return $query->where(function (QueryExpression $exp) use ($options) {
502
                return $exp->or_([
503
                    $this->Objects->aliasField('id') => $options['id'],
504
                    $this->Objects->aliasField('uname') => $options['id'],
505
                ]);
506
            });
507
        });
508
    }
509
}
510