Completed
Branch feature/pre-split (a35c5b)
by Anton
03:21
created

DocumentSchema::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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