Completed
Branch feature/pre-split (0a985a)
by Anton
05:37
created

HasManyRelation::initInstances()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 12
nc 2
nop 0
dl 0
loc 23
rs 8.5906
c 0
b 0
f 0
1
<?php
2
/**
3
 * components
4
 *
5
 * @author    Wolfy-J
6
 */
7
namespace Spiral\ORM\Entities\Relations;
8
9
use Spiral\Database\Exceptions\QueryException;
10
use Spiral\ORM\CommandInterface;
11
use Spiral\ORM\Commands\NullCommand;
12
use Spiral\ORM\Commands\TransactionalCommand;
13
use Spiral\ORM\ContextualCommandInterface;
14
use Spiral\ORM\Entities\Relations\Traits\MatchTrait;
15
use Spiral\ORM\Exceptions\RelationException;
16
use Spiral\ORM\Exceptions\SelectorException;
17
use Spiral\ORM\ORMInterface;
18
use Spiral\ORM\Record;
19
use Spiral\ORM\RecordInterface;
20
use Spiral\ORM\RelationInterface;
21
22
/**
23
 * Attention, this relation delete operation works inside loaded scope!
24
 *
25
 * When empty array assigned to relation it will schedule all related instances to be deleted.
26
 *
27
 * If you wish to load with relation WITHOUT loading previous records use [] initialization.
28
 */
29
class HasManyRelation extends AbstractRelation implements \IteratorAggregate
30
{
31
    use MatchTrait;
32
33
    /**
34
     * @var bool
35
     */
36
    private $autoload = true;
37
38
    /**
39
     * Loaded list of records.
40
     *
41
     * @var RecordInterface[]
42
     */
43
    private $instances = [];
44
45
    /**
46
     * Records deleted from list. Potentially pre-schedule command?
47
     *
48
     * @var RecordInterface[]
49
     */
50
    private $deletedInstances = [];
51
52
    /**
53
     * {@inheritdoc}
54
     */
55
    public function withContext(
56
        RecordInterface $parent,
57
        bool $loaded = false,
58
        array $data = null
59
    ): RelationInterface {
60
        $hasMany = parent::withContext($parent, $loaded, $data);
61
62
        /**
63
         * @var self $hasMany
64
         */
65
        if ($hasMany->loaded) {
66
            //Init all nested models immidiatelly
67
            $hasMany->initInstances();
68
        }
69
70
        return $hasMany->initInstances();
71
    }
72
73
    /**
74
     * Partial selections will not be autoloaded.
75
     *
76
     * Example:
77
     *
78
     * $post = $this->findPost(); //no comments
79
     * $post->comments->partial(true);
80
     * assert($post->comments->count() == 0); //never loaded
81
     *
82
     * $post->comments->add($comment);
83
     *
84
     * @param bool $partial
85
     *
86
     * @return HasManyRelation
87
     */
88
    public function partial(bool $partial = true): self
89
    {
90
        $this->autoload = !$partial;
91
92
        return $this;
93
    }
94
95
    /**
96
     * @return bool
97
     */
98
    public function isPartial(): bool
99
    {
100
        return !$this->autoload;
101
    }
102
103
    /**
104
     * {@inheritdoc}
105
     *
106
     * @param bool $force When true all existed records will be loaded and removed.
107
     *
108
     * @throws RelationException
109
     */
110
    public function setRelated($value, bool $force = false)
111
    {
112
        $this->autoload = $force;
113
        $this->loadData();
114
115
        if (is_null($value)) {
116
            $value = [];
117
        }
118
119
        if (!is_array($value)) {
120
            throw new RelationException("HasMany relation can only be set with array of entities");
121
        }
122
123
        //Cleaning existed instances
124
        $this->deletedInstances = array_unique(array_merge(
125
            $this->deletedInstances,
126
            $this->instances
127
        ));
128
129
        $this->instances = [];
130
        foreach ($value as $item) {
131
            if (!is_null($item)) {
132
                $this->assertValid($item);
133
                $this->instances[] = $item;
134
            }
135
        }
136
    }
137
138
    /**
139
     * Has many relation represent itself (see getIterator method).
140
     *
141
     * @return $this
142
     */
143
    public function getRelated()
144
    {
145
        return $this;
146
    }
147
148
    /**
149
     * Iterate over instance set.
150
     *
151
     * @return \ArrayIterator
152
     */
153
    public function getIterator()
154
    {
155
        return new \ArrayIterator($this->loadData(true)->instances);
156
    }
157
158
    /**
159
     * Iterate over deleted instances.
160
     *
161
     * @return \ArrayIterator
162
     */
163
    public function getDeleted()
164
    {
165
        return new \ArrayIterator($this->deletedInstances);
166
    }
167
168
    /**
169
     * Add new record into entity set. Attention, usage of this method WILL make relation partial.
170
     *
171
     * @param RecordInterface $record
172
     *
173
     * @throws RelationException
174
     */
175
    public function add(RecordInterface $record)
176
    {
177
        $this->assertValid($record);
178
179
        $this->autoload = false;
180
        $this->instances[] = $record;
181
    }
182
183
    /**
184
     * Method will autoload data.
185
     *
186
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
187
     *
188
     * @return bool
189
     */
190
    public function has($query): bool
191
    {
192
        return !empty($this->matchOne($query));
193
    }
194
195
    /**
196
     * Delete one record, strict compaction, make sure exactly same instance is given.
197
     *
198
     * @param RecordInterface $record
199
     */
200
    public function delete(RecordInterface $record)
201
    {
202
        $this->deleteMultiple([$record]);
203
    }
204
205
    /**
206
     * Delete multiple records, strict compaction, make sure exactly same instance is given. Method
207
     * would not autoload instance and will mark it as partial.
208
     *
209
     * @param array|\Traversable $records
210
     */
211
    public function deleteMultiple($records)
212
    {
213
        //Partial
214
        $this->autoload = false;
215
216
        foreach ($records as $record) {
217
            $this->assertValid($record);
218
219
            foreach ($this->instances as $index => $instance) {
220
                if ($instance === $record) {
221
                    //Remove from save
222
                    unset($this->instances[$index]);
223
                    $this->deletedInstances[] = $instance;
224
                    break;
225
                }
226
            }
227
        }
228
229
        $this->instances = array_values($this->instances);
230
    }
231
232
    /**
233
     * Fine one entity for a given query or return null. Method will autoload data.
234
     *
235
     * Example: ->matchOne(['value' => 'something', ...]);
236
     *
237
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
238
     *
239
     * @return RecordInterface|null
240
     */
241
    public function matchOne($query)
242
    {
243
        foreach ($this->loadData(true)->instances as $instance) {
244
            if ($this->match($instance, $query)) {
245
                return $instance;
246
            }
247
        }
248
249
        return null;
250
    }
251
252
    /**
253
     * Return only instances matched given query, performed in memory! Only simple conditions are
254
     * allowed. Not "find" due trademark violation. Method will autoload data.
255
     *
256
     * Example: ->matchMultiple(['value' => 'something', ...]);
257
     *
258
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
259
     *
260
     * @return \ArrayIterator
261
     */
262
    public function matchMultiple($query)
263
    {
264
        $result = [];
265
        foreach ($this->loadData()->instances as $instance) {
266
            if ($this->match($instance, $query)) {
267
                $result[] = $instance;
268
            }
269
        }
270
271
        return new \ArrayIterator($result);
272
    }
273
274
    /**
275
     * {@inheritdoc}
276
     */
277
    public function queueCommands(ContextualCommandInterface $command): CommandInterface
278
    {
279
        //No autoloading here
280
281
        if (empty($this->instances) && empty($this->deletedInstances)) {
282
            return new NullCommand();
283
        }
284
285
        $transaction = new TransactionalCommand();
286
287
        //Delete old instances first
288
        foreach ($this->deletedInstances as $deleted) {
289
            //To de-associate use BELONGS_TO relation
290
            $transaction->addCommand($deleted->queueDelete());
291
        }
292
293
        //Store all instances
294
        foreach ($this->instances as $instance) {
295
            $transaction->addCommand($this->queueRelated($command, $instance));
296
        }
297
298
        //Flushing instances
299
        $this->deletedInstances = [];
300
301
        return $transaction;
302
    }
303
304
    /**
305
     * {@inheritdoc}
306
     *
307
     * @return self
308
     *
309
     * @throws SelectorException
310
     * @throws QueryException (needs wrapping)
311
     */
312
    protected function loadData(bool $autoload = true): self
313
    {
314
        if ($this->loaded) {
315
            return $this;
316
        }
317
318
        $this->loaded = true;
319
320
        if (empty($this->data) || !is_array($this->data)) {
321
            if ($this->autoload && $autoload) {
322
                //Only for non partial selections
323
                $this->data = $this->loadRelated();
324
            } else {
325
                $this->data = [];
326
            }
327
        }
328
329
        return $this->initInstances();
330
    }
331
332
    /**
333
     * Init pre-loaded data.
334
     *
335
     * @return HasManyRelation
336
     */
337
    private function initInstances(): self
338
    {
339
        if (is_array($this->data) && !empty($this->data)) {
340
            foreach ($this->data as $item) {
341
                if ($this->has($item)) {
342
                    //Skip duplicates
343
                    continue;
344
                }
345
346
                $this->instances[] = $this->orm->make(
347
                    $this->class,
348
                    $item,
349
                    ORMInterface::STATE_LOADED,
350
                    true
351
                );
352
            }
353
        }
354
355
        //Memory free
356
        $this->data = null;
0 ignored issues
show
Documentation Bug introduced by
It seems like null of type null is incompatible with the declared type array of property $data.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
357
358
        return $this;
359
    }
360
361
    /**
362
     * @param ContextualCommandInterface $command
363
     * @param RecordInterface            $instance
364
     *
365
     * @return CommandInterface
366
     */
367
    private function queueRelated(
368
        ContextualCommandInterface $command,
369
        RecordInterface $instance
370
    ): CommandInterface {
371
        //Inner storing inner instance
372
        $inner = $instance->queueStore(true);
373
374
        /*
375
         * Instance FK not synced.
376
         */
377
        if (
378
            $this->value($instance, Record::OUTER_KEY)
379
            != $this->value($this->parent, Record::INNER_KEY)
380
            || empty($this->value($this->parent, Record::INNER_KEY))
381
        ) {
382
            if ($this->primaryColumnOf($this->parent) == $this->key(Record::INNER_KEY)) {
383
                /**
384
                 * Particular case when parent entity exists but now saved yet AND outer key is PK.
385
                 * Basically inversed case of BELONGS_TO.
386
                 */
387
                $command->onExecute(function (ContextualCommandInterface $command) use ($inner) {
388
                    $inner->addContext($this->schema[Record::OUTER_KEY], $command->primaryKey());
389
                });
390
            } else {
391
                //Syncing FKs
392
                $inner->addContext(
393
                    $this->key(Record::OUTER_KEY),
394
                    $this->parent->getField($this->schema[Record::INNER_KEY])
395
                );
396
            }
397
        }
398
399
        return $inner;
400
    }
401
402
    /**
403
     * Fetch data from database. Lazy load.
404
     *
405
     * @return array
406
     */
407
    protected function loadRelated(): array
408
    {
409
        $innerKey = $this->key(Record::INNER_KEY);
410
        if (!empty($this->parent->getField($innerKey))) {
411
            return $this->orm
412
                ->selector($this->class)
413
                ->where($this->key(Record::OUTER_KEY), $this->parent->getField($innerKey))
0 ignored issues
show
Unused Code introduced by
The call to RecordSelector::where() has too many arguments starting with $this->parent->getField($innerKey).

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
414
                ->fetchData();
415
        }
416
417
        return [];
418
    }
419
}