Completed
Branch feature/pre-split (656bce)
by Anton
04:24
created

DocumentCompositor::getClass()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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