Completed
Branch feature/pre-split (c69968)
by Anton
21:25
created

HasManyRelation::queueRelated()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 11
nc 2
nop 2
dl 0
loc 19
rs 9.4285
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\RecordIterator;
15
use Spiral\ORM\Entities\RecordSelector;
16
use Spiral\ORM\Entities\Relations\Traits\LookupTrait;
17
use Spiral\ORM\Entities\Relations\Traits\MatchTrait;
18
use Spiral\ORM\Entities\Relations\Traits\PartialTrait;
19
use Spiral\ORM\Exceptions\RelationException;
20
use Spiral\ORM\Exceptions\SelectorException;
21
use Spiral\ORM\Helpers\WhereDecorator;
22
use Spiral\ORM\Record;
23
use Spiral\ORM\RecordInterface;
24
use Spiral\ORM\RelationInterface;
25
26
/**
27
 * Attention, this relation delete operation works inside loaded scope!
28
 *
29
 * When empty array assigned to relation it will schedule all related instances to be deleted.
30
 *
31
 * If you wish to load with relation WITHOUT loading previous records use [] initialization.
32
 */
33
class HasManyRelation extends AbstractRelation implements \IteratorAggregate, \Countable
34
{
35
    use MatchTrait, PartialTrait, LookupTrait;
36
37
    /**
38
     * Loaded list of records. SplObjectStorage?
39
     *
40
     * @var RecordInterface[]
41
     */
42
    private $instances = [];
43
44
    /**
45
     * Records deleted from list. Potentially pre-schedule command?
46
     *
47
     * @var RecordInterface[]
48
     */
49
    private $deletedInstances = [];
50
51
    /**
52
     * {@inheritdoc}
53
     */
54
    public function hasRelated(): bool
55
    {
56
        if (!$this->isLoaded()) {
57
            //Lazy loading our relation data
58
            $this->loadData();
59
        }
60
61
        return !empty($this->instances);
62
    }
63
64
    /**
65
     * {@inheritdoc}
66
     */
67
    public function withContext(
68
        RecordInterface $parent,
69
        bool $loaded = false,
70
        array $data = null
71
    ): RelationInterface {
72
        $hasMany = parent::withContext($parent, $loaded, $data);
73
74
        /** @var self $hasMany */
75
        return $hasMany->initInstances();
76
    }
77
78
    /**
79
     * {@inheritdoc}
80
     *
81
     * @throws RelationException
82
     */
83
    public function setRelated($value)
84
    {
85
        $this->loadData(true);
86
87
        if (is_null($value)) {
88
            $value = [];
89
        }
90
91
        if (!is_array($value)) {
92
            throw new RelationException("HasMany relation can only be set with array of entities");
93
        }
94
95
        //todo: optimize this section!?
96
97
        //Cleaning existed instances
98
        $this->deletedInstances = array_unique(array_merge(
99
            $this->deletedInstances,
100
            $this->instances
101
        ));
102
103
        $this->instances = [];
104
        foreach ($value as $item) {
105
            if (!is_null($item)) {
106
                $this->assertValid($item);
107
                $this->instances[] = $item;
108
            }
109
        }
110
    }
111
112
    /**
113
     * Has many relation represent itself (see getIterator method).
114
     *
115
     * @return $this
116
     */
117
    public function getRelated()
118
    {
119
        return $this;
120
    }
121
122
    /**
123
     * Iterate over instance set.
124
     *
125
     * @return \ArrayIterator
126
     */
127
    public function getIterator()
128
    {
129
        return new \ArrayIterator($this->loadData(true)->instances);
130
    }
131
132
    /**
133
     * @return int
134
     */
135
    public function count()
136
    {
137
        return count($this->loadData(true)->instances);
138
    }
139
140
    /**
141
     * Iterate over deleted instances.
142
     *
143
     * @return \ArrayIterator
144
     */
145
    public function getDeleted()
146
    {
147
        return new \ArrayIterator($this->deletedInstances);
148
    }
149
150
    /**
151
     * Method will autoload data.
152
     *
153
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
154
     *
155
     * @return bool
156
     */
157
    public function has($query): bool
158
    {
159
        return !empty($this->matchOne($query));
160
    }
161
162
    /**
163
     * Add new record into entity set. Attention, usage of this method WILL load relation data
164
     * unless partial.
165
     *
166
     * @param RecordInterface $record
167
     *
168
     * @return self
169
     *
170
     * @throws RelationException
171
     */
172
    public function add(RecordInterface $record): self
173
    {
174
        $this->assertValid($record);
175
        $this->loadData(true)->instances[] = $record;
176
177
        return $this;
178
    }
179
180
    /**
181
     * Delete one record, strict compaction, make sure exactly same instance is given.
182
     *
183
     * @param RecordInterface $record
184
     *
185
     * @return self
186
     *
187
     * @throws RelationException
188
     */
189
    public function delete(RecordInterface $record): self
190
    {
191
        $this->loadData(true);
192
        $this->assertValid($record);
193
194
        foreach ($this->instances as $index => $instance) {
195
            if ($this->match($instance, $record)) {
196
                //Remove from save
197
                unset($this->instances[$index]);
198
                $this->deletedInstances[] = $instance;
199
                break;
200
            }
201
        }
202
203
        $this->instances = array_values($this->instances);
204
205
        return $this;
206
    }
207
208
    /**
209
     * Fine one entity for a given query or return null. Method will autoload data.
210
     *
211
     * Example: ->matchOne(['value' => 'something', ...]);
212
     *
213
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
214
     *
215
     * @return RecordInterface|null
216
     */
217
    public function matchOne($query)
218
    {
219
        foreach ($this->loadData(true)->instances as $instance) {
220
            if ($this->match($instance, $query)) {
221
                return $instance;
222
            }
223
        }
224
225
        return null;
226
    }
227
228
    /**
229
     * Return only instances matched given query, performed in memory! Only simple conditions are
230
     * allowed. Not "find" due trademark violation. Method will autoload data.
231
     *
232
     * Example: ->matchMultiple(['value' => 'something', ...]);
233
     *
234
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
235
     *
236
     * @return \ArrayIterator
237
     */
238
    public function matchMultiple($query)
239
    {
240
        $result = [];
241
        foreach ($this->loadData()->instances as $instance) {
242
            if ($this->match($instance, $query)) {
243
                $result[] = $instance;
244
            }
245
        }
246
247
        return new \ArrayIterator($result);
248
    }
249
250
    /**
251
     * {@inheritdoc}
252
     */
253
    public function queueCommands(ContextualCommandInterface $parentCommand): CommandInterface
254
    {
255
        //No autoloading here
256
257
        if (empty($this->instances) && empty($this->deletedInstances)) {
258
            return new NullCommand();
259
        }
260
261
        $transaction = new TransactionalCommand();
262
263
        //Delete old instances first
264
        foreach ($this->deletedInstances as $deleted) {
265
            //To de-associate use BELONGS_TO relation
266
            $transaction->addCommand($deleted->queueDelete());
267
        }
268
269
        //Store all instances
270
        foreach ($this->instances as $instance) {
271
            $transaction->addCommand($this->queueRelated($parentCommand, $instance));
272
        }
273
274
        //Flushing instances
275
        $this->deletedInstances = [];
276
277
        return $transaction;
278
    }
279
280
    /**
281
     * {@inheritdoc}
282
     *
283
     * @return self
284
     *
285
     * @throws SelectorException
286
     * @throws QueryException (needs wrapping)
287
     */
288
    protected function loadData(bool $autoload = true): self
289
    {
290
        if ($this->loaded) {
291
            return $this;
292
        }
293
294
        $this->loaded = true;
295
296
        if (empty($this->data) || !is_array($this->data)) {
297
            if ($this->autoload && $autoload) {
298
                //Only for non partial selections (excluded already selected)
299
                $this->data = $this->loadRelated();
300
            } else {
301
                $this->data = [];
302
            }
303
        }
304
305
        return $this->initInstances();
306
    }
307
308
    /**
309
     * Fetch data from database. Lazy load.
310
     *
311
     * @return array
312
     */
313
    protected function loadRelated(): array
314
    {
315
        $innerKey = $this->parent->getField($this->key(Record::INNER_KEY));
316
        if (!empty($innerKey)) {
317
            return $this->createSelector($innerKey)->fetchData();
318
        }
319
320
        return [];
321
    }
322
323
    /**
324
     * Create outer selector for a given inner key value.
325
     *
326
     * @param mixed $innerKey
327
     *
328
     * @return RecordSelector
329
     */
330
    protected function createSelector($innerKey): RecordSelector
331
    {
332
        $selector = $this->orm->selector($this->class)->where(
333
            $this->key(Record::OUTER_KEY),
334
            $innerKey
0 ignored issues
show
Unused Code introduced by
The call to RecordSelector::where() has too many arguments starting with $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...
335
        );
336
337
        if (!empty($this->schema[Record::WHERE])) {
338
            //Configuring where conditions with alias resolution
339
            $decorator = new WhereDecorator($selector, 'where', $selector->getAlias());
340
            $decorator->where($this->schema[Record::WHERE]);
341
342
            return $selector;
343
        }
344
345
        return $selector;
346
    }
347
348
    /**
349
     * Init pre-loaded data.
350
     *
351
     * @return HasManyRelation
352
     */
353
    private function initInstances(): self
354
    {
355
        if (is_array($this->data) && !empty($this->data)) {
356
            //Iterates and instantiate records
357
            $iterator = new RecordIterator($this->data, $this->class, $this->orm);
358
359
            foreach ($iterator as $item) {
360
                if ($this->has($item)) {
361
                    //Skip duplicates
362
                    continue;
363
                }
364
365
                $this->instances[] = $item;
366
            }
367
        }
368
369
        //Memory free
370
        $this->data = null;
371
372
        return $this;
373
    }
374
375
    /**
376
     * @param ContextualCommandInterface $parentCommand
377
     * @param RecordInterface            $instance
378
     *
379
     * @return CommandInterface
380
     */
381
    private function queueRelated(
382
        ContextualCommandInterface $parentCommand,
383
        RecordInterface $instance
384
    ): CommandInterface {
385
        //Related entity store command
386
        $innerCommand = $instance->queueStore(true);
387
388
        if (!$this->isSynced($this->parent, $instance)) {
389
            //Delayed linking
390
            $parentCommand->onExecute(function ($outerCommand) use ($innerCommand) {
391
                $innerCommand->addContext(
392
                    $this->key(Record::OUTER_KEY),
393
                    $this->lookupKey(Record::INNER_KEY, $this->parent, $outerCommand)
394
                );
395
            });
396
        }
397
398
        return $innerCommand;
399
    }
400
}