ObjectTypesTable::beforeSave()   C
last analyzed

Complexity

Conditions 12
Paths 14

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 12
nc 14
nop 2
dl 0
loc 18
rs 6.9666
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

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

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

325
    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...
326
    {
327
        if ($this->objectsExist($entity->get('id'))) {
328
            throw new ForbiddenException(__d('bedita', 'Objects of this type exist'));
329
        }
330
    }
331
332
    /**
333
     * Invalidate cache after deleting an object type.
334
     *
335
     * @return void
336
     */
337
    public function afterDelete()
338
    {
339
        Cache::clear(false, self::CACHE_CONFIG);
340
    }
341
342
    /**
343
     * @inheritDoc
344
     */
345
    public function findAll(Query $query, array $options): Query
346
    {
347
        return $query->contain(['LeftRelations', 'RightRelations']);
348
    }
349
350
    /**
351
     * Find object types having a parent by `name` or `id`
352
     *
353
     * @param \Cake\ORM\Query $query Query object.
354
     * @param array $options Additional options. The first element containing `id` or `name` is required.
355
     * @return \Cake\ORM\Query
356
     * @throws \BEdita\Core\Exception\BadFilterException When missing required parameters.
357
     */
358
    public function findParent(Query $query, array $options)
359
    {
360
        if (empty($options[0])) {
361
            throw new BadFilterException(__d('bedita', 'Missing required parameter "{0}"', 'parent'));
362
        }
363
        $parentId = $options[0];
364
        if (!is_numeric($parentId)) {
365
            $parentId = $this->get($parentId)->id;
366
        }
367
368
        return $query->where([$this->aliasField('parent_id') => $parentId]);
369
    }
370
371
    /**
372
     * Find allowed object types by relation name and side.
373
     *
374
     * This finder returns a list of object types that are allowed for the
375
     * relation specified by the required option `name`. You can specify the
376
     * side of the relation you want to retrieve allowed object types for by
377
     * passing an additional option `side` (default: `'right'`).
378
     *
379
     * If the specified relation name is actually the name of an inverse relation,
380
     * this finder automatically takes care of "swapping" sides, always returning
381
     * correct results.
382
     *
383
     * ### Example
384
     *
385
     * ```php
386
     * // Find object types allowed on the "right" side:
387
     * TableRegistry::getTableLocator()->get('ObjectTypes')
388
     *     ->find('byRelation', ['name' => 'my_relation']);
389
     *
390
     * // Find a list of object type names allowed on the "left" side of the inverse relation:
391
     * TableRegistry::getTableLocator()->get('ObjectTypes')
392
     *     ->find('byRelation', ['name' => 'my_inverse_relation', 'side' => 'left'])
393
     *     ->find('list')
394
     *     ->toArray();
395
     *
396
     * // Include also descendants of the allowed object types (e.g.: return **Images** whereas **Media** are allowed):
397
     * TableRegistry::getTableLocator()->get('ObjectTypes')
398
     *     ->find('byRelation', ['name' => 'my_relation', 'descendants' => true]);
399
     * ```
400
     *
401
     * @param \Cake\ORM\Query $query Query object.
402
     * @param array $options Additional options. The `name` key is required, while `side` is optional
403
     *      and assumed to be `'right'` by default.
404
     * @return \Cake\ORM\Query
405
     * @throws \LogicException When missing required parameters.
406
     */
407
    protected function findByRelation(Query $query, array $options = [])
408
    {
409
        if (empty($options['name'])) {
410
            throw new \LogicException(__d('bedita', 'Missing required parameter "{0}"', 'name'));
411
        }
412
        $name = Inflector::underscore($options['name']);
413
414
        $leftField = 'inverse_name';
415
        $rightField = 'name';
416
        if (!empty($options['side']) && $options['side'] !== 'right') {
417
            $leftField = 'name';
418
            $rightField = 'inverse_name';
419
        }
420
421
        // Build sub-queries to find object-types that lay on the left and right side of searched relationship, respectively.
422
        $leftSubQuery = $this->find()
423
            ->innerJoinWith('LeftRelations', function (Query $query) use ($name, $leftField) {
424
                return $query->where(function (QueryExpression $exp) use ($name, $leftField) {
425
                    return $exp->eq($this->LeftRelations->aliasField($leftField), $name);
426
                });
427
            });
428
        $rightSubQuery = $this->find()
429
            ->innerJoinWith('RightRelations', function (Query $query) use ($name, $rightField) {
430
                return $query->where(function (QueryExpression $exp) use ($name, $rightField) {
431
                    return $exp->eq($this->RightRelations->aliasField($rightField), $name);
432
                });
433
            });
434
435
        // Conditions builder that filters only object types returned by one of the two sub-queries.
436
        // This could be achieved more efficiently using two left joins, but if we need to find also
437
        // descendants it's simpler done this way.
438
        $conditionsBuilder = function (QueryExpression $exp) use ($leftSubQuery, $rightSubQuery) {
439
            return $exp->or(function (QueryExpression $exp) use ($leftSubQuery, $rightSubQuery) {
440
                return $exp
441
                    ->in($this->aliasField('id'), $leftSubQuery->select(['id']))
442
                    ->in($this->aliasField('id'), $rightSubQuery->select(['id']));
443
            });
444
        };
445
446
        if (!empty($options['descendants'])) { // We don't need only explicitly linked object types, but also their descendants!
447
            // Obtain Nested-Set-Model left and right counters for the explicitly-linked object types, that are obtained
448
            // using the `$conditionsBuilder` built before.
449
            $nsmCounters = $this->find()
450
                ->select(['tree_left', 'tree_right'])
451
                ->where($conditionsBuilder)
452
                ->enableHydration(false)
453
                ->all();
454
455
            // Replace `$conditionsBuilder` with a more complex one that returns not only the matching object types,
456
            // but also their descendants.
457
            $conditionsBuilder = function (QueryExpression $exp) use ($nsmCounters) {
458
                if ($nsmCounters->count() === 0) {
459
                    // No nodes found: relationship apparently does not exist, or has no linked types.
460
                    // Add contradiction to force empty results.
461
                    return $exp->add(new Comparison(1, 1, 'integer', '<>'));
462
                }
463
464
                // Find descendants for all found nodes using NSM rules.
465
                // If the nodes found are [l = 3, r = 8] and [l = 9, r = 10], the conditions will be built as follows:
466
                // ... WHERE (tree_left >= 3 AND tree_right <= 8) OR (tree_left >= 9 AND tree_right <= 10)
467
                return $exp->or(
468
                    $nsmCounters
469
                        ->map(function (array $row) use ($exp) {
470
                            return $exp->and(function (QueryExpression $exp) use ($row) {
471
                                return $exp
472
                                    ->gte($this->aliasField('tree_left'), $row['tree_left'])
473
                                    ->lte($this->aliasField('tree_right'), $row['tree_right']);
474
                            });
475
                        })
476
                        ->toArray()
477
                );
478
            };
479
        }
480
481
        // Everything is said and done by now. Fingers crossed!
482
        return $query->where($conditionsBuilder);
483
    }
484
485
    /**
486
     * Finder to get object type starting from object id or uname.
487
     *
488
     * @param \Cake\ORM\Query $query Query object.
489
     * @param array $options Additional options. The `id` key is required.
490
     * @return \Cake\ORM\Query
491
     * @throws \BEdita\Core\Exception\BadFilterException When missing required parameters.
492
     */
493
    protected function findObjectId(Query $query, array $options = [])
494
    {
495
        if (empty($options['id'])) {
496
            throw new BadFilterException(__d('bedita', 'Missing required parameter "{0}"', 'id'));
497
        }
498
499
        return $query->innerJoinWith('Objects', function (Query $query) use ($options) {
500
            if (!is_numeric($options['id'])) {
501
                return $query->where([$this->Objects->aliasField('uname') => $options['id']]);
502
            }
503
504
            return $query->where([$this->Objects->aliasField('id') => intval($options['id'])]);
505
        });
506
    }
507
}
508