Completed
Branch feature/pre-split (e38ab3)
by Anton
03:29
created

DocumentEntity::setField()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 8
nc 2
nop 3
dl 0
loc 15
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * Spiral Framework, Core Components
4
 *
5
 * @author    Wolfy-J
6
 */
7
namespace Spiral\ODM;
8
9
use MongoDB\BSON\ObjectID;
10
use Spiral\Core\Component;
11
use Spiral\Core\Exceptions\ScopeException;
12
use Spiral\Core\Traits\SaturateTrait;
13
use Spiral\Models\AccessorInterface;
14
use Spiral\Models\SchematicEntity;
15
use Spiral\Models\Traits\SolidableTrait;
16
use Spiral\ODM\Entities\DocumentCompositor;
17
use Spiral\ODM\Entities\DocumentInstantiator;
18
use Spiral\ODM\Exceptions\AccessorException;
19
use Spiral\ODM\Exceptions\AggregationException;
20
use Spiral\ODM\Exceptions\DocumentException;
21
use Spiral\ODM\Exceptions\FieldException;
22
use Spiral\ODM\Helpers\AggregationHelper;
23
use Spiral\ODM\Schemas\Definitions\CompositionDefinition;
24
25
/**
26
 * Primary class for spiral ODM, provides ability to pack it's own updates in a form of atomic
27
 * updates.
28
 *
29
 * You can use same properties to configure entity as in DataEntity + schema property.
30
 *
31
 * Example:
32
 *
33
 * class Test extends DocumentEntity
34
 * {
35
 *    const SCHEMA = [
36
 *       'name' => 'string'
37
 *    ];
38
 * }
39
 *
40
 * Configuration properties:
41
 * - schema
42
 * - defaults
43
 * - secured (* by default)
44
 * - fillable
45
 */
46
abstract class DocumentEntity extends SchematicEntity implements CompositableInterface
47
{
48
    use SaturateTrait, SolidableTrait;
49
50
    /**
51
     * Set of schema sections needed to describe entity behaviour.
52
     */
53
    const SH_INSTANTIATION = 0;
54
    const SH_DEFAULTS      = 1;
55
    const SH_COMPOSITIONS  = 6;
56
    const SH_AGGREGATIONS  = 7;
57
58
    /**
59
     * Constants used to describe aggregation relations (also used internally to identify
60
     * composition).
61
     *
62
     * Example:
63
     * 'items' => [self::MANY => Item::class, ['parentID' => 'key::_id']]
64
     *
65
     * @see DocumentEntity::SCHEMA
66
     */
67
    const MANY = 778;
68
    const ONE  = 899;
69
70
    /**
71
     * Class responsible for instance construction.
72
     */
73
    const INSTANTIATOR = DocumentInstantiator::class;
74
75
    /**
76
     * Document fields, accessors and relations. ODM will generate setters and getters for some
77
     * fields based on their types.
78
     *
79
     * Example, fields:
80
     * const SCHEMA = [
81
     *      '_id'    => 'MongoId', //Primary key field
82
     *      'value'  => 'string',  //Default string field
83
     *      'values' => ['string'] //ScalarArray accessor will be applied for fields like that
84
     * ];
85
     *
86
     * Compositions:
87
     * const SCHEMA = [
88
     *     ...,
89
     *     'child'       => Child::class,   //One document are composited, for example user Profile
90
     *     'many'        => [Child::class]  //Compositor accessor will be applied, allows to
91
     *                                      //composite many document instances
92
     * ];
93
     *
94
     * Documents can extend each other, in this case schema will also be inherited.
95
     *
96
     * Attention, make sure you properly set FILLABLE option in parent class to use constructions
97
     * like:
98
     * $parent->child = [...];
99
     *
100
     * or
101
     * $parent->setFields(['child'=>[...]]);
102
     *
103
     * @var array
104
     */
105
    const SCHEMA = [];
106
107
    /**
108
     * Default field values.
109
     *
110
     * @var array
111
     */
112
    const DEFAULTS = [];
113
114
    /**
115
     * Model behaviour configurations.
116
     */
117
    const SECURED   = '*';
118
    const HIDDEN    = [];
119
    const FILLABLE  = [];
120
    const SETTERS   = [];
121
    const GETTERS   = [];
122
    const ACCESSORS = [];
123
124
    /**
125
     * Document behaviour schema.
126
     *
127
     * @var array
128
     */
129
    private $documentSchema = [];
130
131
    /**
132
     * Document field updates (changed values).
133
     *
134
     * @var array
135
     */
136
    private $changes = [];
137
138
    /**
139
     * Parent ODM instance, responsible for aggregations and lazy loading operations.
140
     *
141
     * @invisible
142
     * @var ODMInterface
143
     */
144
    protected $odm;
145
146
    /**
147
     * {@inheritdoc}
148
     *
149
     * @param ODMInterface $odm To lazy create nested document ang aggregations.
150
     *
151
     * @throws ScopeException When no ODM instance can be resolved.
152
     */
153
    public function __construct($fields = [], ODMInterface $odm = null, array $schema = null)
154
    {
155
        //We can use global container as fallback if no default values were provided
156
        $this->odm = $this->saturate($odm, ODMInterface::class);
157
158
        //Use supplied schema or fetch one from ODM
159
        $this->documentSchema = !empty($schema) ? $schema : $this->odm->define(
160
            static::class,
161
            ODMInterface::D_SCHEMA
162
        );
163
164
        $fields = is_array($fields) ? $fields : [];
165
        if (!empty($this->documentSchema[self::SH_DEFAULTS])) {
166
            //Merging with default values
167
            $fields = array_replace_recursive($this->documentSchema[self::SH_DEFAULTS], $fields);
168
        }
169
170
        parent::__construct($fields, $this->documentSchema);
171
    }
172
173
    /**
174
     * {@inheritdoc}
175
     */
176
    public function getField(string $name, $default = null, bool $filter = true)
177
    {
178
        if (!$this->hasField($name) && !isset($this->documentSchema[self::SH_COMPOSITIONS][$name])) {
179
            throw new FieldException(sprintf(
180
                "No such property '%s' in '%s', check schema being relevant",
181
                $name,
182
                get_called_class()
183
            ));
184
        }
185
186
        return parent::getField($name, $default, $filter);
187
    }
188
189
    /**
190
     * {@inheritdoc}
191
     *
192
     * Tracks field changes.
193
     */
194
    public function setField(string $name, $value, bool $filter = true)
195
    {
196
        if (!$this->hasField($name)) {
197
            //We are only allowing to modify existed fields, this is strict schema
198
            throw new FieldException(sprintf(
199
                "No such property '%s' in '%s', check schema being relevant",
200
                $name,
201
                get_called_class()
202
            ));
203
        }
204
205
        $this->registerChange($name);
206
207
        parent::setField($name, $value, $filter);
208
    }
209
210
    /**
211
     * {@inheritdoc}
212
     *
213
     * Will restore default value if presented.
214
     */
215
    public function __unset($offset)
216
    {
217
        if (!$this->isNullable($offset)) {
218
            throw new FieldException("Unable to unset not nullable field '{$offset}'");
219
        }
220
221
        $this->setField($offset, null, false);
222
    }
223
224
    /**
225
     * Provides ability to invoke document aggregation.
226
     *
227
     * @param string $method
228
     * @param array  $arguments
229
     *
230
     * @return mixed|null|AccessorInterface|CompositableInterface|Document|Entities\DocumentSelector
231
     */
232
    public function __call($method, array $arguments)
233
    {
234
        if (isset($this->documentSchema[self::SH_AGGREGATIONS][$method])) {
235
            if (!empty($arguments)) {
236
                throw new AggregationException("Aggregation method call except 0 parameters");
237
            }
238
239
            $helper = new AggregationHelper($this, $this->odm);
240
241
            return $helper->createAggregation($method);
242
        }
243
244
        throw new DocumentException("Undefined method call '{$method}' in '" . get_called_class() . "'");
245
    }
246
247
    /**
248
     * {@inheritdoc}
249
     *
250
     * @param string $field Check once specific field changes.
251
     */
252
    public function hasUpdates(string $field = null): bool
253
    {
254
        //Check updates for specific field
255
        if (!empty($field)) {
256
            if (array_key_exists($field, $this->changes)) {
257
                return true;
258
            }
259
260
            //Do not force accessor creation
261
            $value = $this->getField($field, null, false);
262
            if ($value instanceof CompositableInterface && $value->hasUpdates()) {
263
                return true;
264
            }
265
266
            return false;
267
        }
268
269
        if (!empty($this->changes)) {
270
            return true;
271
        }
272
273
        //Do not force accessor creation
274
        foreach ($this->getFields(false) as $value) {
275
            //Checking all fields for changes (handled internally)
276
            if ($value instanceof CompositableInterface && $value->hasUpdates()) {
277
                return true;
278
            }
279
        }
280
281
        return false;
282
    }
283
284
    /**
285
     * {@inheritdoc}
286
     */
287
    public function buildAtomics(string $container = null): array
288
    {
289
        if (!$this->hasUpdates() && !$this->isSolid()) {
290
            return [];
291
        }
292
293
        if ($this->isSolid()) {
294
            if (!empty($container)) {
295
                //Simple nested document in solid state
296
                return ['$set' => [$container => $this->packValue(false)]];
297
            }
298
299
            //No parent container
300
            return ['$set' => $this->packValue(false)];
301
        }
302
303
        //Aggregate atomics from every nested composition
304
        $atomics = [];
305
306
        foreach ($this->getFields(false) as $field => $value) {
307
            if ($value instanceof CompositableInterface) {
308
                $atomics = array_merge_recursive(
309
                    $atomics,
310
                    $value->buildAtomics((!empty($container) ? $container . '.' : '') . $field)
311
                );
312
313
                continue;
314
            }
315
316
            foreach ($atomics as $atomic => $operations) {
317
                if (array_key_exists($field, $operations) && $atomic != '$set') {
318
                    //Property already changed by atomic operation
319
                    continue;
320
                }
321
            }
322
323
            if (array_key_exists($field, $this->changes)) {
324
                //Generating set operation for changed field
325
                $atomics['$set'][(!empty($container) ? $container . '.' : '') . $field] = $value;
326
            }
327
        }
328
329
        return $atomics;
330
    }
331
332
    /**
333
     * {@inheritdoc}
334
     */
335
    public function commitUpdates()
336
    {
337
        $this->changes = [];
338
339
        foreach ($this->getFields(false) as $field => $value) {
340
            if ($value instanceof CompositableInterface) {
341
                $value->commitUpdates();
342
            }
343
        }
344
    }
345
346
    /**
347
     * {@inheritdoc}
348
     *
349
     * @param bool $includeID Set to false to exclude _id from packed fields.
350
     */
351
    public function packValue(bool $includeID = true)
352
    {
353
        $values = parent::packValue();
354
355
        if (!$includeID) {
356
            unset($values['_id']);
357
        }
358
359
        return $values;
360
    }
361
362
    /**
363
     * Since most of ODM documents might contain ObjectIDs and other fields we will try to normalize
364
     * them into string values.
365
     *
366
     * @return array
367
     */
368
    public function publicFields(): array
369
    {
370
        $public = parent::publicFields();
371
372
        array_walk_recursive($public, function (&$value) {
373
            if ($value instanceof ObjectID) {
374
                $value = (string)$value;
375
            }
376
        });
377
378
        return $public;
379
    }
380
381
    /**
382
     * Cloning will be called when object will be embedded into another document.
383
     */
384
    public function __clone()
385
    {
386
        //De-serialize document in order to ensure that all compositions are recreated
387
        $this->stateValue($this->packValue());
388
389
        //Since document embedded as one piece let's ensure that it is solid
390
        $this->solidState = true;
391
        $this->changes = [];
392
    }
393
394
    /**
395
     * @return array
396
     */
397
    public function __debugInfo()
398
    {
399
        return [
400
            'fields'  => $this->getFields(),
401
            'atomics' => $this->hasUpdates() ? $this->buildAtomics() : [],
402
        ];
403
    }
404
405
    /**
406
     * {@inheritdoc}
407
     *
408
     * @see CompositionDefinition
409
     */
410
    protected function getMutator(string $field, string $mutator)
411
    {
412
        /**
413
         * Every document composition is valid accessor but defined a bit differently.
414
         */
415
        if (isset($this->documentSchema[self::SH_COMPOSITIONS][$field])) {
416
            return $this->documentSchema[self::SH_COMPOSITIONS][$field];
417
        }
418
419
        return parent::getMutator($field, $mutator);
420
    }
421
422
    /**
423
     * {@inheritdoc}
424
     */
425
    protected function isNullable(string $field): bool
426
    {
427
        if (array_key_exists($field, $this->documentSchema[self::SH_DEFAULTS])) {
428
            //Only fields with default null value can be nullable
429
            return is_null($this->documentSchema[self::SH_DEFAULTS][$field]);
430
        }
431
432
        //Values unknown to schema always nullable
433
        return true;
434
    }
435
436
    /**
437
     * {@inheritdoc}
438
     *
439
     * DocumentEntity will pass ODM instance as part of accessor context.
440
     *
441
     * @see CompositionDefinition
442
     */
443
    protected function createAccessor(
444
        $accessor,
445
        string $field,
446
        $value,
447
        array $context = []
448
    ): AccessorInterface {
449
        if (is_array($accessor)) {
450
            //We are working with definition of composition.
451
            switch ($accessor[0]) {
452
                case self::ONE:
453
                    //Singular embedded document
454
                    return $this->odm->make($accessor[1], $value, false);
455
                case self::MANY:
456
                    return new DocumentCompositor($accessor[1], $value, $this->odm);
457
            }
458
459
            throw new AccessorException("Invalid accessor definition for field '{$field}'");
460
        }
461
462
        //Field as a context
463
        return parent::createAccessor($accessor, $field, $value, $context + ['odm' => $this->odm]);
464
    }
465
466
    /**
467
     * {@inheritdoc}
468
     */
469
    protected function iocContainer()
470
    {
471
        if ($this->odm instanceof Component) {
472
            //Forwarding IoC scope to parent ODM instance
473
            return $this->odm->iocContainer();
474
        }
475
476
        return parent::iocContainer();
477
    }
478
479
    /**
480
     * @param string $name
481
     */
482
    private function registerChange(string $name)
483
    {
484
        $original = $this->getField($name, null, false);
485
486
        if (!array_key_exists($name, $this->changes)) {
487
            //Let's keep track of how field looked before first change
488
            $this->changes[$name] = $original instanceof AccessorInterface
489
                ? $original->packValue()
490
                : $original;
491
        }
492
    }
493
}