Completed
Branch feature/pre-split (669609)
by Anton
03:30
created

DocumentEntity::commitUpdates()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 3
nop 0
dl 0
loc 10
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
        //Original field value
206
        $original = $this->getField($name, null, false);
207
208
        parent::setField($name, $value, $filter);
209
210
        if (!array_key_exists($name, $this->changes)) {
211
            //Let's keep track of how field looked before first change
212
            $this->changes[$name] = $original instanceof AccessorInterface
213
                ? $original->packValue()
214
                : $original;
215
        }
216
    }
217
218
    /**
219
     * {@inheritdoc}
220
     *
221
     * Will restore default value if presented.
222
     */
223
    public function __unset($offset)
224
    {
225
        if (!$this->isNullable($offset)) {
226
            throw new FieldException("Unable to unset not nullable field '{$offset}'");
227
        }
228
229
        $this->setField($offset, null, false);
230
    }
231
232
    /**
233
     * Provides ability to invoke document aggregation.
234
     *
235
     * @param string $method
236
     * @param array  $arguments
237
     *
238
     * @return mixed|null|AccessorInterface|CompositableInterface|Document|Entities\DocumentSelector
239
     */
240
    public function __call($method, array $arguments)
241
    {
242
        if (isset($this->documentSchema[self::SH_AGGREGATIONS][$method])) {
243
            if (!empty($arguments)) {
244
                throw new AggregationException("Aggregation method call except 0 parameters");
245
            }
246
247
            $helper = new AggregationHelper($this, $this->odm);
248
249
            return $helper->createAggregation($method);
250
        }
251
252
        throw new DocumentException("Undefined method call '{$method}' in '" . get_called_class() . "'");
253
    }
254
255
    /**
256
     * {@inheritdoc}
257
     *
258
     * @param string $field Check once specific field changes.
259
     */
260
    public function hasUpdates(string $field = null): bool
261
    {
262
        //Check updates for specific field
263
        if (!empty($field)) {
264
            if (array_key_exists($field, $this->changes)) {
265
                return true;
266
            }
267
268
            //Do not force accessor creation
269
            $value = $this->getField($field, null, false);
270
            if ($value instanceof CompositableInterface && $value->hasUpdates()) {
271
                return true;
272
            }
273
274
            return false;
275
        }
276
277
        if (!empty($this->changes)) {
278
            return true;
279
        }
280
281
        //Do not force accessor creation
282
        foreach ($this->getFields(false) as $value) {
283
            //Checking all fields for changes (handled internally)
284
            if ($value instanceof CompositableInterface && $value->hasUpdates()) {
285
                return true;
286
            }
287
        }
288
289
        return false;
290
    }
291
292
    /**
293
     * {@inheritdoc}
294
     */
295
    public function buildAtomics(string $container = null): array
296
    {
297
        if (!$this->hasUpdates() && !$this->isSolid()) {
298
            return [];
299
        }
300
301
        if ($this->isSolid()) {
302
            if (!empty($container)) {
303
                //Simple nested document in solid state
304
                return ['$set' => [$container => $this->packValue(false)]];
305
            }
306
307
            //No parent container
308
            return ['$set' => $this->packValue(false)];
309
        }
310
311
        //Aggregate atomics from every nested composition
312
        $atomics = [];
313
314
        foreach ($this->getFields(false) as $field => $value) {
315
            if ($value instanceof CompositableInterface) {
316
                $atomics = array_merge_recursive(
317
                    $atomics,
318
                    $value->buildAtomics((!empty($container) ? $container . '.' : '') . $field)
319
                );
320
321
                continue;
322
            }
323
324
            foreach ($atomics as $atomic => $operations) {
325
                if (array_key_exists($field, $operations) && $atomic != '$set') {
326
                    //Property already changed by atomic operation
327
                    continue;
328
                }
329
            }
330
331
            if (array_key_exists($field, $this->changes)) {
332
                //Generating set operation for changed field
333
                $atomics['$set'][(!empty($container) ? $container . '.' : '') . $field] = $value;
334
            }
335
        }
336
337
        return $atomics;
338
    }
339
340
    /**
341
     * {@inheritdoc}
342
     */
343
    public function commitUpdates()
344
    {
345
        $this->changes = [];
346
347
        foreach ($this->getFields(false) as $field => $value) {
348
            if ($value instanceof CompositableInterface) {
349
                $value->commitUpdates();
350
            }
351
        }
352
    }
353
354
    /**
355
     * {@inheritdoc}
356
     *
357
     * @param bool $includeID Set to false to exclude _id from packed fields.
358
     */
359
    public function packValue(bool $includeID = true)
360
    {
361
        $values = parent::packValue();
362
363
        if (!$includeID) {
364
            unset($values['_id']);
365
        }
366
367
        return $values;
368
    }
369
370
    /**
371
     * Since most of ODM documents might contain ObjectIDs and other fields we will try to normalize
372
     * them into string values.
373
     *
374
     * @return array
375
     */
376
    public function publicFields(): array
377
    {
378
        $public = parent::publicFields();
379
380
        array_walk_recursive($public, function (&$value) {
381
            if ($value instanceof ObjectID) {
382
                $value = (string)$value;
383
            }
384
        });
385
386
        return $public;
387
    }
388
389
    /**
390
     * Cloning will be called when object will be embedded into another document.
391
     */
392
    public function __clone()
393
    {
394
        //De-serialize document in order to ensure that all compositions are recreated
395
        $this->stateValue($this->packValue());
396
397
        //Since document embedded as one piece let's ensure that it is solid
398
        $this->solidState = true;
399
        $this->changes = [];
400
    }
401
402
    /**
403
     * @return array
404
     */
405
    public function __debugInfo()
406
    {
407
        return [
408
            'fields'  => $this->getFields(),
409
            'atomics' => $this->hasUpdates() ? $this->buildAtomics() : [],
410
        ];
411
    }
412
413
    /**
414
     * {@inheritdoc}
415
     *
416
     * @see CompositionDefinition
417
     */
418
    protected function getMutator(string $field, string $mutator)
419
    {
420
        /**
421
         * Every document composition is valid accessor but defined a bit differently.
422
         */
423
        if (isset($this->documentSchema[self::SH_COMPOSITIONS][$field])) {
424
            return $this->documentSchema[self::SH_COMPOSITIONS][$field];
425
        }
426
427
        return parent::getMutator($field, $mutator);
428
    }
429
430
    /**
431
     * {@inheritdoc}
432
     */
433
    protected function isNullable(string $field): bool
434
    {
435
        if (array_key_exists($field, $this->documentSchema[self::SH_DEFAULTS])) {
436
            //Only fields with default null value can be nullable
437
            return is_null($this->documentSchema[self::SH_DEFAULTS][$field]);
438
        }
439
440
        //Values unknown to schema always nullable
441
        return true;
442
    }
443
444
    /**
445
     * {@inheritdoc}
446
     *
447
     * DocumentEntity will pass ODM instance as part of accessor context.
448
     *
449
     * @see CompositionDefinition
450
     */
451
    protected function createAccessor(
452
        $accessor,
453
        string $field,
454
        $value,
455
        array $context = []
456
    ): AccessorInterface {
457
        if (is_array($accessor)) {
458
            //We are working with definition of composition.
459
            switch ($accessor[0]) {
460
                case self::ONE:
461
                    //Singular embedded document
462
                    return $this->odm->make($accessor[1], $value, false);
463
                case self::MANY:
464
                    return new DocumentCompositor($accessor[1], $value, $this->odm);
465
            }
466
467
            throw new AccessorException("Invalid accessor definition for field '{$field}'");
468
        }
469
470
        //Field as a context
471
        return parent::createAccessor($accessor, $field, $value, $context + ['odm' => $this->odm]);
472
    }
473
474
    /**
475
     * {@inheritdoc}
476
     */
477
    protected function iocContainer()
478
    {
479
        if ($this->odm instanceof Component) {
480
            //Forwarding IoC scope to parent ODM instance
481
            return $this->odm->iocContainer();
482
        }
483
484
        return parent::iocContainer();
485
    }
486
}