Completed
Branch feature/pre-split (cb15b4)
by Anton
03:23
created

DocumentCompositor::commitUpdates()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 0
dl 0
loc 8
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\Entities;
8
9
use Spiral\Models\PublishableInterface;
10
use Spiral\Models\Traits\SolidableTrait;
11
use Spiral\ODM\CompositableInterface;
12
use Spiral\ODM\Exceptions\CompositorException;
13
use Spiral\ODM\ODMInterface;
14
15
/**
16
 * Provides ability to composite multiple documents in a form of array.
17
 *
18
 * Attention, composition will be saved as one big $set operation in case when multiple atomic
19
 * operations applied to it (not supported by Mongo).
20
 */
21
class DocumentCompositor implements
22
    CompositableInterface,
23
    PublishableInterface,
24
    \Countable,
25
    \IteratorAggregate
26
{
27
    use SolidableTrait;
28
29
    /**
30
     * Lazy conversion from array to CompositableInterface (ie DocumentEntity).
31
     *
32
     * @var CompositableInterface[]
33
     */
34
    private $entities = [];
35
36
    /**
37
     * Set of atomic operation applied to whole composition set.
38
     *
39
     * @var array
40
     */
41
    protected $atomics = [];
42
43
    /**
44
     * @var string
45
     */
46
    protected $class;
47
48
    /**
49
     * @invisible
50
     * @var ODMInterface
51
     */
52
    protected $odm;
53
54
    /**
55
     * @param string                        $class
56
     * @param array|CompositableInterface[] $data
57
     * @param ODMInterface                  $odm
58
     */
59
    public function __construct(string $class, array $data, ODMInterface $odm)
60
    {
61
        $this->class = $class;
62
        $this->odm = $odm;
63
64
        //Instantiating composed entities (no data filtering)
65
        $this->entities = $this->createEntities($data, false);
66
    }
67
68
    /**
69
     * Get primary composition class.
70
     *
71
     * @return string
72
     */
73
    public function getClass(): string
74
    {
75
        return $this->class;
76
    }
77
78
    /**
79
     * Push new entity to end of set.
80
     *
81
     * Be aware that any added entity will be cloned in order to detach it from passed object:
82
     * $user->addresses->push($address);
83
     * $address->city = 'Minsk'; //this will have no effect of $user->addresses
84
     *
85
     * @param CompositableInterface $entity
86
     *
87
     * @return DocumentCompositor
88
     *
89
     * @throws CompositorException When entity is invalid type.
90
     */
91
    public function push(CompositableInterface $entity): DocumentCompositor
92
    {
93
        //Detaching entity
94
        $entity = clone $entity;
95
96
        $this->assertSupported($entity);
97
98
        $this->entities[] = $entity;
99
        $this->atomics['$push'][] = $entity;
100
101
        return $this;
102
    }
103
104
    /**
105
     * Add entity to set, only one instance of document must be presented.
106
     *
107
     * Be aware that any added entity will be cloned in order to detach it from passed object:
108
     * $user->addresses->add($address);
109
     * $address->city = 'Minsk'; //this will have no effect of $user->addresses
110
     *
111
     * @param CompositableInterface $entity
112
     *
113
     * @return DocumentCompositor
114
     *
115
     * @throws CompositorException When entity is invalid type.
116
     */
117
    public function add(CompositableInterface $entity): DocumentCompositor
118
    {
119
        //Detaching entity
120
        $entity = clone $entity;
121
122
        $this->assertSupported($entity);
123
124
        if (!$this->has($entity)) {
125
            $this->entities[] = $entity;
126
        }
127
128
        $this->atomics['$addToSet'][] = $entity;
129
130
        return $this;
131
    }
132
133
    /**
134
     * Pull mathced entities from composition.
135
     *
136
     * $user->addresses->pull($address);
137
     *
138
     * @param CompositableInterface $entity
139
     *
140
     * @return DocumentCompositor
141
     */
142
    public function pull(CompositableInterface $entity): DocumentCompositor
143
    {
144
        //Passing true to get all entity offsets
145
        $targets = $this->find($entity, true);
146
147
        foreach ($targets as $offset => $target) {
148
            unset($this->entities[$offset]);
149
        }
150
151
        $this->atomics['$pull'][] = clone $entity;
152
153
        return $this;
154
    }
155
156
    /**
157
     * Check if composition contains desired document or document matching query.
158
     *
159
     * Example:
160
     * $user->cards->has(['active' => true]);
161
     * $user->cards->has(new Card(...));
162
     *
163
     * @param CompositableInterface|array $query
164
     *
165
     * @return bool
166
     */
167
    public function has($query): bool
168
    {
169
        return !empty($this->findOne($query));
170
    }
171
172
    /**
173
     * Find document in composition based on given entity or matching query.
174
     *
175
     * $user->cards->findOne(['active' => true]);
176
     * $user->cards->findOne(new Card(...));
177
     *
178
     * @param CompositableInterface|array $query
179
     *
180
     * @return CompositableInterface|null
181
     */
182
    public function findOne($query)
183
    {
184
        $entities = $this->find($query);
185
        if (empty($entities)) {
186
            return null;
187
        }
188
189
        return current($entities);
190
    }
191
192
    /**
193
     * Find all entities matching given query (query can be provided in a form of
194
     * CompositableInterface).
195
     *
196
     * $user->cards->find(['active' => true]);
197
     * $user->cards->find(new Card(...));     //Attention, this will likely to return only on match
198
     *
199
     * @param CompositableInterface|array $query
200
     * @param bool                        $preserveKeys Set to true to keep original offsets.
201
     *
202
     * @return CompositableInterface[]
203
     */
204
    public function find($query, bool $preserveKeys = false): array
205
    {
206
        if ($query instanceof CompositableInterface) {
207
            //Intersecting using values
208
            $query = $query->packValue();
209
        }
210
211
        $result = [];
212
        foreach ($this->entities as $offset => $entity) {
213
            //Looking for entities using key intersection
214
            if (empty($query) || (array_intersect_assoc($entity->packValue(), $query) == $query)) {
215
                $result[$offset] = $entity;
216
            }
217
        }
218
219
        if (!$preserveKeys) {
220
            return array_values($result);
221
        }
222
223
        return $result;
224
    }
225
226
    /**
227
     * {@inheritdoc}
228
     *
229
     * Be aware that any added entity will be cloned in order to detach it from passed object:
230
     * $user->addresses->mountValue([$address]);
231
     * $address->city = 'Minsk'; //this will have no effect of $user->addresses
232
     */
233
    public function stateValue($data)
234
    {
235
        //Manually altered compositions must always end in solid state
236
        $this->solidState = true;
237
        $this->entities = [];
238
239
        if (!is_array($data)) {
240
            //Unable to initiate
241
            return;
242
        }
243
244
        //Instantiating entities (with filtering enabled)
245
        $this->entities = $this->createEntities($data, true);
246
    }
247
248
    /**
249
     * {@inheritdoc}
250
     */
251
    public function packValue(): array
252
    {
253
        return $this->packValues($this->entities);
254
    }
255
256
    /**
257
     * {@inheritdoc}
258
     *
259
     * @param bool $changedEntities Reference, will be set to true if any of entities changed
260
     *                              internally.
261
     */
262
    public function hasUpdates(bool &$changedEntities = null): bool
263
    {
264
        foreach ($this->entities as $entity) {
265
            if ($entity->hasUpdates()) {
266
                $changedEntities = true;
267
268
                return true;
269
            }
270
        }
271
272
        return !empty($this->atomics);
273
    }
274
275
    /**
276
     * {@inheritdoc}
277
     */
278
    public function flushUpdates()
279
    {
280
        $this->atomics = [];
281
282
        foreach ($this->entities as $entity) {
283
            $entity->flushUpdates();
284
        }
285
    }
286
287
    /**
288
     * {@inheritdoc}
289
     */
290
    public function buildAtomics(string $container = ''): array
291
    {
292
        //$changedEntities will be set to true if any of internal entities were changed directly
293
        if (!$this->hasUpdates($changedEntities)) {
294
            return [];
295
        }
296
297
        //Mongo does not support multiple operations for one field, switching to $set (make sure it's
298
        //reasonable)
299
        if ($this->solidState || count($this->atomics) > 1 || $changedEntities) {
300
            //We don't care about atomics in solid state
301
            return ['$set' => [$container => $this->packValue()]];
302
        }
303
304
        //Aggregate composition specific atomics (pull, push, addToSet) and entity specific atomics
305
        $atomics = [];
306
307
        //If entity already presented in any of composition atomics we are not insluding it's own
308
        //offset specific operations into atomics
309
        $excluded = [];
310
311
        foreach ($this->atomics as $operation => $items) {
312
            //Collect all atomics handled on
313
            $excluded = array_merge($excluded, $items);
314
315
            //Into array form
316
            $atomics[$operation][$container][$operation == '$pull' ? '$in' : '$each'] = $this->packValues($items);
317
        }
318
319
        //Document specific atomic operations (excluding document which are colliding with composition
320
        //specific operations)
321
        foreach ($this->entities as $offset => $entity) {
322
            if (!$entity->hasUpdates() || in_array($entity, $excluded)) {
323
                //Handler on higher level
324
                continue;
325
            }
326
327
            $atomics = array_merge(
328
                $atomics,
329
                $entity->buildAtomics((!empty($container) ? $container . '.' : '') . $offset)
330
            );
331
        }
332
333
        return $atomics;
334
    }
335
336
    /**
337
     * Packs only public values of all nested documents.
338
     *
339
     * @return array
340
     */
341
    public function publicValue(): array
342
    {
343
        $result = [];
344
        foreach ($this->entities as $entity) {
345
            if ($entity instanceof PublishableInterface) {
346
                $result[] = $entity->publicValue();
347
            }
348
        }
349
350
        return $result;
351
    }
352
353
    /**
354
     * {@inheritdoc}
355
     */
356
    public function jsonSerialize()
357
    {
358
        return $this->publicValue();
359
    }
360
361
    /**
362
     * @return int
363
     */
364
    public function count(): int
365
    {
366
        return count($this->entities);
367
    }
368
369
    /**
370
     * @return \ArrayIterator
371
     */
372
    public function getIterator(): \ArrayIterator
373
    {
374
        return new \ArrayIterator($this->entities);
375
    }
376
377
    /**
378
     * Cloning will be called when object will be embedded into another document.
379
     */
380
    public function __clone()
381
    {
382
        $this->solidState = true;
383
        $this->atomics = [];
384
385
        //De-serialize composition in order to ensure that all compositions are recreated
386
        $this->stateValue($this->packValue());
387
    }
388
389
    /**
390
     * @return array
391
     */
392
    public function __debugInfo()
393
    {
394
        return [
395
            'entities' => array_values($this->entities),
396
            'atomics'  => $this->buildAtomics('@compositor')
397
        ];
398
    }
399
400
    /**
401
     * Assert that given entity supported by composition.
402
     *
403
     * @param CompositableInterface $entity
404
     *
405
     * @throws CompositorException
406
     */
407
    protected function assertSupported(CompositableInterface $entity)
408
    {
409
        if (!is_object($entity) || !is_a($entity, $this->class)) {
410
            throw new CompositorException(sprintf(
411
                "Only instances of '%s' supported, '%s' given",
412
                $this->class,
413
                is_object($entity) ? get_class($entity) : gettype($entity)
414
            ));
415
        }
416
    }
417
418
    /**
419
     * Instantiate every entity in composition.
420
     *
421
     * @param array $data
422
     * @param bool  $filter
423
     *
424
     * @return CompositableInterface[]
425
     *
426
     * @throws CompositorException
427
     */
428
    private function createEntities(array $data, bool $filter = true): array
429
    {
430
        $result = [];
431
        foreach ($data as $item) {
432
            if ($item instanceof CompositableInterface) {
433
                $this->assertSupported($item);
434
435
                //Always clone to detach from original value
436
                $result[] = clone $item;
437
            } else {
438
                $result[] = $this->odm->make($this->class, $item, $filter);
439
            }
440
        }
441
442
        return $result;
443
    }
444
445
    /**
446
     * Pack multiple entities into array form.
447
     *
448
     * @param CompositableInterface[] $entities
449
     *
450
     * @return array
451
     */
452
    private function packValues(array $entities): array
453
    {
454
        $result = [];
455
        foreach ($entities as $entity) {
456
            $result[] = $entity->packValue();
457
        }
458
459
        return $result;
460
    }
461
}