Passed
Push — 4-cactus ( 4b4223...fb764e )
by Dante
03:07
created

ObjectType::translatableProperty()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 3
nop 2
dl 0
loc 7
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\Entity;
15
16
use BEdita\Core\Utility\JsonApiSerializable;
17
use Cake\Datasource\EntityInterface;
18
use Cake\Datasource\Exception\RecordNotFoundException;
19
use Cake\Event\EventDispatcherInterface;
20
use Cake\Event\EventDispatcherTrait;
21
use Cake\ORM\Entity;
22
use Cake\ORM\Locator\LocatorAwareTrait;
23
use Cake\ORM\Table;
24
use Cake\Utility\Hash;
25
use Cake\Utility\Inflector;
26
use Generator;
27
28
/**
29
 * ObjectType Entity.
30
 *
31
 * @property int $id
32
 * @property string $name
33
 * @property string $singular
34
 * @property string $alias
35
 * @property string $description
36
 * @property string $plugin
37
 * @property string $model
38
 * @property string $table
39
 * @property array $associations
40
 * @property array $hidden
41
 * @property string[] $relations
42
 * @property bool $is_abstract
43
 * @property int $parent_id
44
 * @property int $tree_left
45
 * @property int $tree_right
46
 * @property string $parent_name
47
 * @property \Cake\I18n\Time $created
48
 * @property \Cake\I18n\Time $modified
49
 * @property bool $core_type
50
 * @property bool $enabled
51
 * @property array $translation_rules
52
 * @property bool $is_translatable
53
 * @property \BEdita\Core\Model\Entity\ObjectEntity[] $objects
54
 * @property \BEdita\Core\Model\Entity\Relation[] $left_relations
55
 * @property \BEdita\Core\Model\Entity\Relation[] $right_relations
56
 * @property \BEdita\Core\Model\Entity\Property[] $properties
57
 * @property \BEdita\Core\Model\Entity\ObjectType $parent
58
 * @property mixed $schema
59
 */
60
class ObjectType extends Entity implements JsonApiSerializable, EventDispatcherInterface
61
{
62
    use EventDispatcherTrait;
63
    use JsonApiModelTrait {
64
        listAssociations as protected jsonApiListAssociations;
65
    }
66
    use LocatorAwareTrait;
67
68
    /**
69
     * JSON Schema definition of nullable objects array.
70
     * Basic schema for categories, tags, date_ranges and other properties.
71
     *
72
     * @var array
73
     */
74
    public const NULLABLE_OBJECT_ARRAY = [
75
        'oneOf' => [
76
            [
77
                'type' => 'null',
78
            ],
79
            [
80
                'type' => 'array',
81
                'uniqueItems' => true,
82
                'items' => [
83
                    'type' => 'object',
84
                ],
85
            ],
86
        ],
87
    ];
88
89
    /**
90
     * List of associations represented as properties.
91
     *
92
     * @var array
93
     */
94
    public const ASSOC_PROPERTIES = ['Tags', 'Categories', 'DateRanges'];
95
96
    /**
97
     * @inheritDoc
98
     */
99
    protected $_accessible = [
100
        '*' => false,
101
        'name' => true,
102
        'singular' => true,
103
        'description' => true,
104
        'table' => true,
105
        'associations' => true,
106
        'hidden' => true,
107
        'is_abstract' => true,
108
        'parent_name' => true,
109
        'enabled' => true,
110
        'translation_rules' => true,
111
        'is_translatable' => true,
112
    ];
113
114
    /**
115
     * @inheritDoc
116
     */
117
    protected $_virtual = [
118
        'alias',
119
        'table',
120
        'parent_name',
121
        'relations',
122
    ];
123
124
    /**
125
     * @inheritDoc
126
     */
127
    protected $_hidden = [
128
        'objects',
129
        'model',
130
        'plugin',
131
        'properties',
132
        'parent_id',
133
        'tree_left',
134
        'tree_right',
135
    ];
136
137
    /**
138
     * Setter for property `name`.
139
     *
140
     * Force `name` field to be underscored via inflector.
141
     *
142
     * @param string $name Object type name.
143
     * @return string
144
     */
145
    protected function _setName(string $name): string
146
    {
147
        return Inflector::underscore($name);
148
    }
149
150
    /**
151
     * Getter for property `singular`.
152
     *
153
     * If `singular` field is not set or empty, use inflected form of `name`.
154
     *
155
     * @return string
156
     */
157
    protected function _getSingular(): ?string
158
    {
159
        if (!empty($this->_properties['singular'])) {
160
            return $this->_properties['singular'];
161
        }
162
163
        return Inflector::singularize((string)$this->name);
164
    }
165
166
    /**
167
     * Setter for property `singular`.
168
     *
169
     * Force `singular` field to be underscored via inflector.
170
     *
171
     * @param string|null $singular Object type singular name.
172
     * @return string
173
     */
174
    protected function _setSingular(?string $singular): string
175
    {
176
        return Inflector::underscore($singular);
177
    }
178
179
    /**
180
     * Getter for virtual property `alias`.
181
     *
182
     * @return string
183
     */
184
    protected function _getAlias(): string
185
    {
186
        return Inflector::camelize($this->name);
187
    }
188
189
    /**
190
     * Getter for virtual property `table`.
191
     *
192
     * @return string
193
     */
194
    protected function _getTable(): string
195
    {
196
        $table = $this->plugin . '.';
197
        if ($table == '.') {
198
            $table = '';
199
        }
200
201
        $table .= $this->model;
202
203
        return $table;
204
    }
205
206
    /**
207
     * Setter for virtual property `table`.
208
     *
209
     * @param string $table Full table name.
210
     * @return void
211
     */
212
    protected function _setTable(string $table): void
213
    {
214
        [$plugin, $model] = pluginSplit($table);
215
216
        $this->plugin = $plugin;
217
        $this->model = $model;
218
    }
219
220
    /**
221
     * Get parent object type, if set.
222
     *
223
     * @return self|null
224
     */
225
    public function getParent(): ?self
226
    {
227
        if ($this->parent_id === null) {
228
            return null;
229
        }
230
231
        if ($this->parent !== null) {
232
            return $this->parent;
233
        }
234
235
        /** @var \BEdita\Core\Model\Table\ObjectTypesTable $table */
236
        $table = $this->getTableLocator()->get($this->getSource());
237
238
        return $table->get($this->parent_id);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $table->get($this->parent_id) returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return BEdita\Core\Model\Entity\ObjectType|null.
Loading history...
239
    }
240
241
    /**
242
     * Iterate through full inheritance chain.
243
     *
244
     * @return \Generator|self[]
245
     */
246
    public function getFullInheritanceChain(): Generator
247
    {
248
        $objectType = $this;
249
        while ($objectType !== null) {
250
            yield $objectType;
251
252
            $objectType = $objectType->getParent();
253
        }
254
    }
255
256
    /**
257
     * Get all relations, including relations inherited from parent object types, indexed by their name.
258
     *
259
     * @param string $side Filter relations by side this object type stays on. Either `left`, `right` or `both`.
260
     * @return \BEdita\Core\Model\Entity\Relation[]
261
     */
262
    public function getRelations(string $side = 'both'): array
263
    {
264
        if ($side === 'both') {
265
            return $this->getRelations('left') + $this->getRelations('right');
266
        }
267
268
        $indexBy = 'name';
269
        if ($side === 'right') {
270
            $indexBy = 'inverse_name';
271
        }
272
273
        $property = sprintf('%s_relations', $side);
274
275
        return collection($this->getFullInheritanceChain())
276
            ->unfold(function (self $objectType) use ($property): Generator {
277
                yield from (array)$objectType->get($property);
278
            })
279
            ->indexBy($indexBy)
280
            ->toArray();
281
    }
282
283
    /**
284
     * Getter for virtual property `relations`.
285
     *
286
     * @return string[]|null
287
     */
288
    protected function _getRelations(): ?array
289
    {
290
        if (!$this->has('left_relations') || !$this->has('right_relations')) {
291
            return null;
292
        }
293
294
        return array_keys($this->getRelations());
295
    }
296
297
    /**
298
     * @inheritDoc
299
     */
300
    protected static function listAssociations(Table $Table, array $hidden = []): array
301
    {
302
        $associations = static::jsonApiListAssociations($Table, $hidden);
303
        $associations = array_diff($associations, ['relations']);
304
305
        return $associations;
306
    }
307
308
    /**
309
     * Getter for virtual property `parent_name`.
310
     *
311
     * @return string|null
312
     */
313
    protected function _getParentName(): ?string
314
    {
315
        $parent = $this->getParent();
316
        if ($parent === null) {
317
            return null;
318
        }
319
320
        return $parent->name;
321
    }
322
323
    /**
324
     * Setter for virtual property `parent_name`.
325
     *
326
     * @param string $parentName Parent object type name.
327
     * @return string
328
     */
329
    protected function _setParentName(string $parentName): ?string
330
    {
331
        try {
332
            /** @var \BEdita\Core\Model\Table\ObjectTypesTable $table */
333
            $table = $this->getTableLocator()->get($this->getSource());
334
            $objectType = $table->get($parentName);
335
            if (!$objectType->is_abstract || !$objectType->enabled) {
0 ignored issues
show
Bug introduced by
Accessing enabled on the interface Cake\Datasource\EntityInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
Bug introduced by
Accessing is_abstract on the interface Cake\Datasource\EntityInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
336
                return null;
337
            }
338
339
            if ($this->parent_id !== $objectType->id) {
340
                $this->parent = $objectType;
341
                $this->parent_id = $objectType->id;
342
            }
343
        } catch (RecordNotFoundException $e) {
344
            return null;
345
        }
346
347
        return $parentName;
348
    }
349
350
    /**
351
     * Getter for virtual property `schema`.
352
     *
353
     * @return mixed
354
     */
355
    protected function _getSchema()
356
    {
357
        if ($this->is_abstract || empty($this->id) || $this->enabled === false) {
358
            return false;
359
        }
360
361
        $associations = (array)$this->associations;
362
        $relations = static::objectTypeRelations($this->getRelations('right'), 'right') +
363
            static::objectTypeRelations($this->getRelations('left'), 'left');
364
365
        $schema = $this->objectTypeProperties() + compact('associations', 'relations');
366
        $event = $this->dispatchEvent('ObjectType.getSchema', ['schema' => $schema, 'objectType' => $this], $this);
367
        if ($event->isStopped()) {
368
            return false;
369
        }
370
371
        return $event->getResult() ?: $schema;
372
    }
373
374
    /**
375
     * Retrieve relation information as associative array:
376
     *  * relation name as key, direct or inverse
377
     *  * label, params and related types as values
378
     *
379
     * @param array $relations Relations array
380
     * @param string $side Relation side, 'left' or 'right'
381
     * @return array
382
     */
383
    protected static function objectTypeRelations(array $relations, string $side): array
384
    {
385
        if ($side === 'left') {
386
            $name = 'name';
387
            $label = 'label';
388
            $relTypes = 'right_object_types';
389
        } else {
390
            $name = 'inverse_name';
391
            $label = 'inverse_label';
392
            $relTypes = 'left_object_types';
393
        }
394
395
        $res = [];
396
        foreach ($relations as $relation) {
397
            $types = array_map(
398
                function ($t) {
399
                    return $t->get('name');
400
                },
401
                (array)$relation->get($relTypes)
402
            );
403
            sort($types);
404
            $res[$relation->get($name)] = [
405
                'label' => $relation->get($label),
406
                'params' => $relation->params,
407
            ] + compact('types');
408
        }
409
410
        return $res;
411
    }
412
413
    /**
414
     * Return object type properties in JSON SCHEMA format
415
     *
416
     * @return array
417
     */
418
    protected function objectTypeProperties(): array
419
    {
420
        /** @var \BEdita\Core\Model\Entity\Property[] $allProperties */
421
        // Fetch all properties, properties with `is_static` true at the end.
422
        // This way we can override default property type of a static property.
423
        $allProperties = $this->getTableLocator()->get('Properties')
424
            ->find('objectType', [$this->id])
425
            ->order(['is_static' => 'ASC'])
426
            ->toArray();
427
        /** @var \BEdita\Core\Model\Entity\ObjectEntity $entity */
428
        $entity = $this->getTableLocator()->get($this->name)->newEntity([]);
429
430
        $required = $translatable = [];
431
        $properties = $this->associationProperties();
432
        foreach ($allProperties as $property) {
433
            if (in_array($property->name, (array)$this->hidden)) {
434
                continue;
435
            }
436
            $accessMode = $this->accessMode($property, $entity);
437
            $properties[$property->name] = $property->getSchema($accessMode);
438
439
            if ($property->required && $accessMode === null) {
440
                $required[] = $property->name;
441
            }
442
            if ($this->is_translatable && $this->translatableProperty($property, $entity)) {
443
                $translatable[] = $property->name;
444
            }
445
        }
446
        sort($required);
447
        sort($translatable);
448
449
        return compact('properties', 'required', 'translatable');
450
    }
451
452
    /**
453
     * See if a property is translatable looking at property type (static or dynamic)
454
     * and using `translation_rules` data.
455
     *
456
     * @param \BEdita\Core\Model\Entity\Property $property The property
457
     * @param \BEdita\Core\Model\Entity\ObjectEntity $entity Default object entity
458
     * @return bool
459
     */
460
    protected function translatableProperty(Property $property, ObjectEntity $entity): bool
461
    {
462
        if (Hash::check((array)$this->translation_rules, $property->name)) {
463
            return (bool)Hash::get((array)$this->translation_rules, $property->name);
464
        }
465
466
        return $property->translatable && $entity->isFieldTranslatable($property->name);
0 ignored issues
show
Bug Best Practice introduced by
The property translatable does not exist on BEdita\Core\Model\Entity\Property. Since you implemented __get, consider adding a @property annotation.
Loading history...
467
    }
468
469
    /**
470
     * Get property access mode: can be `readOnly`, `writeOnly` or null if no access mode is set
471
     *
472
     * @param \BEdita\Core\Model\Entity\Property $property The property
473
     * @param \Cake\Datasource\EntityInterface $entity Default object entity
474
     * @return string|null
475
     */
476
    protected function accessMode(Property $property, EntityInterface $entity): ?string
477
    {
478
        if (!$entity->isAccessible($property->name)) {
479
            return 'readOnly';
480
        } elseif (in_array($property->name, $entity->getHidden())) {
481
            return 'writeOnly';
482
        }
483
484
        return null;
485
    }
486
487
    /**
488
     * Add internal associations as properties
489
     *
490
     * @return array
491
     */
492
    protected function associationProperties(): array
493
    {
494
        $assocProps = array_intersect(static::ASSOC_PROPERTIES, (array)$this->associations);
495
        $res = [];
496
        foreach ($assocProps as $assoc) {
497
            $name = Inflector::delimit($assoc);
498
            $res[$name] = self::NULLABLE_OBJECT_ARRAY + [
499
                '$id' => sprintf('/properties/%s', $name),
500
                'title' => $assoc,
501
            ];
502
        }
503
504
        return $res;
505
    }
506
507
    /** Check if an object type is child of another object type.
508
     *
509
     * @param \BEdita\Core\Model\Entity\ObjectType $ancestor Ancestor object type to test.
510
     * @return bool
511
     */
512
    public function isDescendantOf(self $ancestor): bool
513
    {
514
        foreach ($this->getFullInheritanceChain() as $objectType) {
515
            if ((int)$objectType->id === (int)$ancestor->id) {
516
                return true;
517
            }
518
        }
519
520
        return false;
521
    }
522
523
    /**
524
     * Get the closest common parent object type for a set of object types.
525
     *
526
     * @param \BEdita\Core\Model\Entity\ObjectType ...$objectTypes Object types to find common ancestor for.
527
     * @return static|null
528
     */
529
    public static function getClosestCommonAncestor(self ...$objectTypes): ?self
530
    {
531
        if (empty($objectTypes)) {
532
            return null;
533
        }
534
535
        $parent = array_shift($objectTypes);
536
        foreach ($parent->getFullInheritanceChain() as $commonAncestor) {
537
            $isCommonAncestor = array_reduce(
538
                $objectTypes,
539
                function (bool $store, ObjectType $item) use ($commonAncestor): bool {
540
                    return $store && $item->isDescendantOf($commonAncestor);
541
                },
542
                true
543
            );
544
            if ($isCommonAncestor) {
545
                return $commonAncestor;
546
            }
547
        }
548
549
        return null;
550
    }
551
}
552