DocumentSchema::getCompositions()   B
last analyzed

Complexity

Conditions 7
Paths 5

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 15
rs 8.2222
c 0
b 0
f 0
cc 7
eloc 8
nc 5
nop 1
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ODM\Schemas;
9
10
use Doctrine\Common\Inflector\Inflector;
11
use Spiral\Models\AccessorInterface;
12
use Spiral\Models\Exceptions\AccessorExceptionInterface;
13
use Spiral\Models\Reflections\ReflectionEntity;
14
use Spiral\ODM\Configs\MutatorsConfig;
15
use Spiral\ODM\Document;
16
use Spiral\ODM\DocumentEntity;
17
use Spiral\ODM\Entities\DocumentInstantiator;
18
use Spiral\ODM\Exceptions\SchemaException;
19
use Spiral\ODM\Schemas\Definitions\AggregationDefinition;
20
use Spiral\ODM\Schemas\Definitions\CompositionDefinition;
21
use Spiral\ODM\Schemas\Definitions\IndexDefinition;
22
23
class DocumentSchema implements SchemaInterface
24
{
25
    /**
26
     * @var ReflectionEntity
27
     */
28
    private $reflection;
29
30
    /**
31
     * @invisible
32
     *
33
     * @var MutatorsConfig
34
     */
35
    private $mutatorsConfig;
36
37
    /**
38
     * @param ReflectionEntity $reflection
39
     * @param MutatorsConfig   $mutators
40
     */
41
    public function __construct(ReflectionEntity $reflection, MutatorsConfig $mutators)
42
    {
43
        $this->reflection = $reflection;
44
        $this->mutatorsConfig = $mutators;
45
    }
46
47
    /**
48
     * @return string
49
     */
50
    public function getClass(): string
51
    {
52
        return $this->reflection->getName();
53
    }
54
55
    /**
56
     * @return ReflectionEntity
57
     */
58
    public function getReflection(): ReflectionEntity
59
    {
60
        return $this->reflection;
61
    }
62
63
    /**
64
     * @return string
65
     */
66
    public function getInstantiator(): string
67
    {
68
        return $this->reflection->getProperty('instantiator') ?? DocumentInstantiator::class;
69
    }
70
71
    /**
72
     * {@inheritdoc}
73
     */
74
    public function isEmbedded(): bool
75
    {
76
        return !$this->reflection->isSubclassOf(Document::class)
77
            && $this->reflection->isSubclassOf(DocumentEntity::class);
78
    }
79
80
    /**
81
     * {@inheritdoc}
82
     */
83
    public function getDatabase()
84
    {
85
        if ($this->isEmbedded()) {
86
            throw new SchemaException(
87
                "Unable to get database name for embedded model {$this->reflection}"
88
            );
89
        }
90
91
        $database = $this->reflection->getProperty('database');
92
        if (empty($database)) {
93
            //Empty database to be used
94
            return null;
95
        }
96
97
        return $database;
98
    }
99
100
    /**
101
     * {@inheritdoc}
102
     */
103
    public function getCollection(): string
104
    {
105
        if ($this->isEmbedded()) {
106
            throw new SchemaException(
107
                "Unable to get collection name for embedded model {$this->reflection}"
108
            );
109
        }
110
111
        $collection = $this->reflection->getProperty('collection');
112
        if (empty($collection)) {
113
            //We have to use parent collection when extended
114
            $class = $this->reflection;
115
            while ($class->getParentClass()->getName() != Document::class) {
116
                $class = $class->getParentClass();
117
            }
118
119
            //Generate collection using short class name
120
            $collection = Inflector::camelize($class->getShortName());
121
            $collection = Inflector::pluralize($collection);
122
        }
123
124
        return $collection;
125
    }
126
127
    /**
128
     * Get every embedded entity field (excluding declarations of aggregations).
129
     *
130
     * @return array
131
     */
132
    public function getFields(): array
133
    {
134
        $fields = $this->reflection->getSchema();
135
136
        foreach ($fields as $field => $type) {
137
            if ($this->isAggregation($type)) {
138
                unset($fields[$field]);
139
            }
140
        }
141
142
        return $fields;
143
    }
144
145
    /**
146
     * Default defined values.
147
     *
148
     * @return array
149
     */
150
    public function getDefaults(): array
151
    {
152
        return $this->reflection->getProperty('defaults') ?? [];
153
    }
154
155
    /**
156
     * {@inheritdoc}
157
     */
158
    public function getIndexes(): array
159
    {
160
        if ($this->isEmbedded()) {
161
            throw new SchemaException(
162
                "Unable to get indexes for embedded model {$this->reflection}"
163
            );
164
        }
165
166
        $indexes = $this->reflection->getProperty('indexes', true);
167
        if (empty($indexes) || !is_array($indexes)) {
168
            return [];
169
        }
170
171
        $result = [];
172
        foreach ($indexes as $index) {
173
            $options = [];
174
            if (isset($index['@options'])) {
175
                $options = $index['@options'];
176
                unset($index['@options']);
177
            }
178
179
            $result[] = new IndexDefinition($index, $options);
180
        }
181
182
        return array_unique($result);
183
    }
184
185
    /**
186
     * @return AggregationDefinition[]
187
     */
188
    public function getAggregations(): array
189
    {
190
        $result = [];
191
        foreach ($this->reflection->getSchema() as $field => $type) {
192
            if ($this->isAggregation($type)) {
193
                $aggregationType = isset($type[Document::ONE]) ? Document::ONE : Document::MANY;
194
195
                $result[$field] = new AggregationDefinition(
196
                    $aggregationType,        //Aggregation type
197
                    $type[$aggregationType], //Class name
198
                    array_pop($type)         //Query template
199
                );
200
            }
201
        }
202
203
        return $result;
204
    }
205
206
    /**
207
     * Find all composition definitions, attention method require builder instance in order to
208
     * properly check that embedded class exists.
209
     *
210
     * @param SchemaBuilder $builder
211
     *
212
     * @return CompositionDefinition[]
213
     */
214
    public function getCompositions(SchemaBuilder $builder): array
215
    {
216
        $result = [];
217
        foreach ($this->reflection->getSchema() as $field => $type) {
218
            if (is_string($type) && $builder->hasSchema($type)) {
219
                $result[$field] = new CompositionDefinition(DocumentEntity::ONE, $type);
220
            }
221
222
            if (is_array($type) && isset($type[0]) && $builder->hasSchema($type[0])) {
223
                $result[$field] = new CompositionDefinition(DocumentEntity::MANY, $type[0]);
224
            }
225
        }
226
227
        return $result;
228
    }
229
230
    /**
231
     * Generate set of mutators associated with entity fields using user defined and automatic
232
     * mutators.
233
     *
234
     * @see MutatorsConfig
235
     * @return array
236
     */
237
    public function getMutators(): array
238
    {
239
        $mutators = $this->reflection->getMutators();
240
241
        //Trying to resolve mutators based on field type
242
        foreach ($this->getFields() as $field => $type) {
243
            //Resolved mutators
244
            $resolved = [];
245
246
            if (
247
                is_array($type)
248
                && is_scalar($type[0])
249
                && $filter = $this->mutatorsConfig->getMutators('array::' . $type[0])
250
            ) {
251
                //Mutator associated to array with specified type
252
                $resolved += $filter;
253
            } elseif (is_array($type) && $filter = $this->mutatorsConfig->getMutators('array')) {
254
                //Default array mutator
255
                $resolved += $filter;
256
            } elseif (!is_array($type) && $filter = $this->mutatorsConfig->getMutators($type)) {
257
                //Mutator associated with type directly
258
                $resolved += $filter;
259
            }
260
261
            //Merging mutators and default mutators
262
            foreach ($resolved as $mutator => $filter) {
263
                if (!array_key_exists($field, $mutators[$mutator])) {
264
                    $mutators[$mutator][$field] = $filter;
265
                }
266
            }
267
        }
268
269
        return $mutators;
270
    }
271
272
    /**
273
     * {@inheritdoc}
274
     */
275
    public function resolvePrimary(SchemaBuilder $builder): string
276
    {
277
        //Let's define a way how to separate one model from another based on given fields
278
        $helper = new InheritanceHelper($this, $builder->getSchemas());
279
280
        return $helper->findPrimary();
281
    }
282
283
    /**
284
     * {@inheritdoc}
285
     */
286
    public function packSchema(SchemaBuilder $builder): array
287
    {
288
        return [
289
            //Instantion options and behaviour (if any)
290
            DocumentEntity::SH_INSTANTIATION => $this->instantiationOptions($builder),
291
292
            //Default entity state (builder is needed to resolve recursive defaults)
293
            DocumentEntity::SH_DEFAULTS      => $this->packDefaults($builder),
294
295
            //Entity behaviour
296
            DocumentEntity::SH_SECURED       => $this->reflection->getSecured(),
297
            DocumentEntity::SH_FILLABLE      => $this->reflection->getFillable(),
298
299
            //Mutators can be altered based on ODM\SchemasConfig
300
            DocumentEntity::SH_MUTATORS      => $this->getMutators(),
301
302
            //Document behaviours (we can mix them with accessors due potential inheritance)
303
            DocumentEntity::SH_COMPOSITIONS  => $this->packCompositions($builder),
304
            DocumentEntity::SH_AGGREGATIONS  => $this->packAggregations($builder),
305
        ];
306
    }
307
308
    /**
309
     * Define instantiator specific options (usually needed to resolve class inheritance). Might
310
     * return null if associated instantiator is unknown to DocumentSchema.
311
     *
312
     * @param SchemaBuilder $builder
313
     *
314
     * @return mixed
315
     */
316
    protected function instantiationOptions(SchemaBuilder $builder)
317
    {
318
        if ($this->getInstantiator() != DocumentInstantiator::class) {
319
            //Unable to define options for non default inheritance based instantiator
320
            return null;
321
        }
322
323
        //Let's define a way how to separate one model from another based on given fields
324
        $helper = new InheritanceHelper($this, $builder->getSchemas());
325
326
        return $helper->makeDefinition();
327
    }
328
329
    /**
330
     * Entity default values.
331
     *
332
     * @param SchemaBuilder $builder
333
     * @param array         $overwriteDefaults Set of default values to replace user defined values.
334
     *
335
     * @return array
336
     *
337
     * @throws SchemaException
338
     */
339
    protected function packDefaults(SchemaBuilder $builder, array $overwriteDefaults = []): array
340
    {
341
        //Defined compositions
342
        $compositions = $this->getCompositions($builder);
343
344
        //User defined default values
345
        $userDefined = $overwriteDefaults + $this->getDefaults();
346
347
        //We need mutators to normalize default values
348
        $mutators = $this->getMutators();
349
350
        $defaults = [];
351
        foreach ($this->getFields() as $field => $type) {
352
            $default = is_array($type) ? [] : null;
353
354
            if (array_key_exists($field, $userDefined)) {
355
                //No merge to keep fields order intact
356
                $default = $userDefined[$field];
357
            }
358
359
            //Registering default values
360
            $defaults[$field] = $this->mutateValue(
361
                $builder,
362
                $compositions,
363
                $userDefined,
364
                $mutators,
365
                $field,
366
                $default
367
            );
368
        }
369
370
        return $defaults;
371
    }
372
373
    /**
374
     * Pack compositions into simple array definition.
375
     *
376
     * @param SchemaBuilder $builder
377
     *
378
     * @return array
379
     *
380
     * @throws SchemaException
381
     */
382
    public function packCompositions(SchemaBuilder $builder): array
383
    {
384
        $result = [];
385
        foreach ($this->getCompositions($builder) as $name => $composition) {
386
            $result[$name] = $composition->packSchema();
387
        }
388
389
        return $result;
390
    }
391
392
    /**
393
     * Pack aggregations into simple array definition.
394
     *
395
     * @param SchemaBuilder $builder
396
     *
397
     * @return array
398
     *
399
     * @throws SchemaException
400
     */
401
    protected function packAggregations(SchemaBuilder $builder): array
402
    {
403
        $result = [];
404
        foreach ($this->getAggregations() as $name => $aggregation) {
405
            if (!$builder->hasSchema($aggregation->getClass())) {
406
                throw new SchemaException(
407
                    "Aggregation {$this->getClass()}.'{$name}' refers to undefined document '{$aggregation->getClass()}'"
408
                );
409
            }
410
411
            if ($builder->getSchema($aggregation->getClass())->isEmbedded()) {
412
                throw new SchemaException(
413
                    "Aggregation {$this->getClass()}.'{$name}' refers to non storable document '{$aggregation->getClass()}'"
414
                );
415
            }
416
417
            $result[$name] = $aggregation->packSchema();
418
        }
419
420
        return $result;
421
    }
422
423
    /**
424
     * Check if field schema/type defines aggregation.
425
     *
426
     * @param mixed $type
427
     *
428
     * @return bool
429
     */
430
    protected function isAggregation($type): bool
431
    {
432
        if (is_array($type)) {
433
            if (isset($type[Document::ONE]) || isset($type[Document::MANY])) {
434
                return true;
435
            }
436
        }
437
438
        return false;
439
    }
440
441
    /**
442
     * Ensure default value using associated mutators or pass thought composition.
443
     *
444
     * @param SchemaBuilder $builder
445
     * @param array         $compositions
446
     * @param array         $userDefined User defined set of default values.
447
     * @param array         $mutators
448
     * @param string        $field
449
     * @param mixed         $default
450
     *
451
     * @return mixed
452
     */
453
    protected function mutateValue(
454
        SchemaBuilder $builder,
455
        array $compositions,
456
        array $userDefined,
457
        array $mutators,
458
        string $field,
459
        $default
460
    ) {
461
        //Let's process default value using associated setter
462
        if (isset($mutators[DocumentEntity::MUTATOR_SETTER][$field])) {
463
            try {
464
                $setter = $mutators[DocumentEntity::MUTATOR_SETTER][$field];
465
                $default = call_user_func($setter, $default);
466
467
                return $default;
468
            } catch (\Exception $exception) {
469
                //Unable to generate default value, use null or empty array as fallback
470
            }
471
        }
472
473
        if (isset($mutators[DocumentEntity::MUTATOR_ACCESSOR][$field])) {
474
            $default = $this->accessorDefault(
475
                $default,
476
                $mutators[DocumentEntity::MUTATOR_ACCESSOR][$field]
477
            );
478
        }
479
480
        if (isset($compositions[$field])) {
481
            if (is_null($default) && !array_key_exists($field, $userDefined)) {
482
                //Let's force default value for composite fields
483
                $default = [];
484
            }
485
486
            $default = $this->compositionDefault($default, $compositions[$field], $builder);
487
488
            return $default;
489
        }
490
491
        return $default;
492
    }
493
494
    /**
495
     * Pass value thought accessor to ensure it's default.
496
     *
497
     * @param mixed  $default
498
     * @param string $accessor
499
     *
500
     * @return mixed
501
     *
502
     * @throws AccessorExceptionInterface
503
     */
504
    protected function accessorDefault($default, string $accessor)
505
    {
506
        /**
507
         * @var AccessorInterface $instance
508
         */
509
        $instance = new $accessor($default, [/*no context given*/]);
510
        $default = $instance->packValue();
511
512
        if (is_object($default)) {
513
            //Some accessors might want to return objects (DateTime, StorageObject), default to null
514
            $default = null;
515
        }
516
517
        return $default;
518
    }
519
520
    /**
521
     * Ensure default value for composite field,
522
     *
523
     * @param mixed                 $default
524
     * @param CompositionDefinition $composition
525
     * @param SchemaBuilder         $builder
526
     *
527
     * @return array
528
     *
529
     * @throws SchemaException
530
     */
531
    protected function compositionDefault(
532
        $default,
533
        CompositionDefinition $composition,
534
        SchemaBuilder $builder
535
    ) {
536
        if (!is_array($default)) {
537
            if ($composition->getType() == DocumentEntity::MANY) {
538
                //Composition many must always defaults to array
539
                return [];
540
            }
541
542
            //Composite ONE must always defaults to null if no default value are specified
543
            return null;
544
        }
545
546
        //Nothing to do with value for composite many
547
        if ($composition->getType() == DocumentEntity::MANY) {
548
            return $default;
549
        }
550
551
        $embedded = $builder->getSchema($composition->getClass());
552
        if (!$embedded instanceof self) {
553
            //We can not normalize values handled by external schemas yet
554
            return $default;
555
        }
556
557
        if ($embedded->getClass() == $this->getClass()) {
558
            if (!empty($default)) {
559
                throw new SchemaException(
560
                    "Possible recursion issue in '{$this->getClass()}', model refers to itself (has default value)"
561
                );
562
            }
563
564
            //No recursions!
565
            return null;
566
        }
567
568
        return $embedded->packDefaults($builder, $default);
569
    }
570
}