Completed
Branch feature/pre-split (ab51d7)
by Anton
05:02
created

ManyToManyRelation::queueCommands()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 76
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 41
nc 8
nop 1
dl 0
loc 76
rs 8.4596
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * components
4
 *
5
 * @author    Wolfy-J
6
 */
7
namespace Spiral\ORM\Entities\Relations;
8
9
use Spiral\Database\Builders\SelectQuery;
10
use Spiral\Database\Entities\Table;
11
use Spiral\ORM\CommandInterface;
12
use Spiral\ORM\Commands\ContextualDeleteCommand;
13
use Spiral\ORM\Commands\InsertCommand;
14
use Spiral\ORM\Commands\TransactionalCommand;
15
use Spiral\ORM\Commands\UpdateCommand;
16
use Spiral\ORM\ContextualCommandInterface;
17
use Spiral\ORM\Entities\Loaders\ManyToManyLoader;
18
use Spiral\ORM\Entities\Loaders\RelationLoader;
19
use Spiral\ORM\Entities\Nodes\PivotedRootNode;
20
use Spiral\ORM\Entities\RecordIterator;
21
use Spiral\ORM\Entities\Relations\Traits\LookupTrait;
22
use Spiral\ORM\Exceptions\RelationException;
23
use Spiral\ORM\Helpers\WhereDecorator;
24
use Spiral\ORM\ORMInterface;
25
use Spiral\ORM\Record;
26
use Spiral\ORM\RecordInterface;
27
28
/**
29
 * Provides ability to create pivot map between parent record and multiple objects with ability to
30
 * link them, link and create, update pivot, unlink or sync send. Relation support partial mode.
31
 */
32
class ManyToManyRelation extends MultipleRelation implements \IteratorAggregate, \Countable
33
{
34
    use LookupTrait;
35
36
    /** Schema shortcuts */
37
    const PIVOT_TABLE    = Record::PIVOT_TABLE;
38
    const PIVOT_DATABASE = 917;
39
40
    /**
41
     * @var Table|null
42
     */
43
    private $pivotTable = null;
44
45
    /**
46
     * @var \SplObjectStorage
47
     */
48
    private $pivotData;
49
50
    /**
51
     * Linked but not saved yet records.
52
     *
53
     * @var array
54
     */
55
    private $scheduled = [];
56
57
    /**
58
     * Record which pivot data was updated, record must still present in linked array.
59
     *
60
     * @var array
61
     */
62
    private $updated = [];
63
64
    /**
65
     * Records scheduled to be de-associated.
66
     *
67
     * @var RecordInterface[]
68
     */
69
    private $unlinked = [];
70
71
    /**
72
     * @param string       $class
73
     * @param array        $schema
74
     * @param ORMInterface $orm
75
     */
76
    public function __construct($class, array $schema, ORMInterface $orm)
77
    {
78
        parent::__construct($class, $schema, $orm);
79
        $this->pivotData = new \SplObjectStorage();
80
    }
81
82
    /**
83
     * {@inheritdoc}
84
     *
85
     * Pivot data must be set separatelly.
86
     */
87
    public function setRelated($value)
88
    {
89
        $this->loadData(true);
90
91
        if (is_null($value)) {
92
            $value = [];
93
        }
94
95
        if (!is_array($value)) {
96
            throw new RelationException("HasMany relation can only be set with array of entities");
97
        }
98
99
        //Do not add items twice
100
        $matched = [];
101
        foreach ($value as $index => $record) {
102
            if (is_null($record)) {
103
                unset($value[$index]);
104
                continue;
105
            }
106
107
            $this->assertValid($record);
108
            if (!empty($instance = $this->matchOne($record))) {
109
                $matched[] = $instance;
110
                unset($value[$index]);
111
            }
112
        }
113
114
        //Unlink records
115
        foreach (array_diff($this->instances, $matched) as $record) {
116
            $this->unlink($record);
117
        }
118
119
        //Add new record
120
        foreach ($value as $record) {
121
            $this->link($record);
122
        }
123
    }
124
125
    /**
126
     * Get all unlinked records.
127
     *
128
     * @return \ArrayIterator
129
     */
130
    public function getUnlinked()
131
    {
132
        return new \ArrayIterator($this->unlinked);
133
    }
134
135
    /**
136
     * Get pivot data associated with specific instance.
137
     *
138
     * @param RecordInterface $record
139
     *
140
     * @return array
141
     *
142
     * @throws RelationException
143
     */
144
    public function getPivot(RecordInterface $record): array
145
    {
146
        if (empty($matched = $this->matchOne($record))) {
147
            throw new RelationException(
148
                "Unable to get pivotData for non linked record" . ($this->autoload ? '' : " (partial on)")
149
            );
150
        }
151
152
        return $this->pivotData->offsetGet($matched);
153
    }
154
155
    /**
156
     * Link record with parent entity. Only record instances is accepted.
157
     *
158
     * @param RecordInterface $record
159
     * @param array           $pivotData
160
     *
161
     * @return self
162
     *
163
     * @throws RelationException
164
     */
165
    public function link(RecordInterface $record, array $pivotData = []): self
166
    {
167
        $this->loadData(true);
168
        $this->assertValid($record);
169
170
        if (in_array($record, $this->instances)) {
171
            $this->assertPivot($pivotData);
172
173
            //Merging pivot data
174
            $this->pivotData->offsetSet($record, $pivotData + $this->getPivot($record));
175
176
            if (!in_array($record, $this->updated)) {
177
                //Indicating that record pivot data has been changed
178
                $this->updated[] = $record;
179
            }
180
181
            return $this;
182
        }
183
184
        //New association
185
        $this->instances[] = $record;
186
        $this->scheduled[] = $record;
187
        $this->pivotData->offsetSet($record, $pivotData);
188
189
        return $this;
190
    }
191
192
    /**
193
     * Unlink specific entity from relation. Will load relation data! Record to delete will be
194
     * automatically matched to a give record.
195
     *
196
     * @param RecordInterface $record
197
     *
198
     * @return self
199
     *
200
     * @throws RelationException When entity not linked.
201
     */
202
    public function unlink(RecordInterface $record): self
203
    {
204
        $this->loadData(true);
205
206
        foreach ($this->instances as $index => $linked) {
207
            if ($this->match($linked, $record)) {
208
                //Removing locally
209
                unset($this->instances[$index]);
210
211
                if (!in_array($linked, $this->scheduled) || !$this->autoload) {
212
                    //Scheduling unlink in db when we know relation OR partial mode is on
213
                    $this->unlinked[] = $linked;
214
                }
215
                break;
216
            }
217
        }
218
219
        $this->instances = array_values($this->instances);
220
221
        return $this;
222
    }
223
224
    /**
225
     * {@inheritdoc}
226
     */
227
    public function queueCommands(ContextualCommandInterface $parentCommand): CommandInterface
228
    {
229
        $transaction = new TransactionalCommand();
230
231
        foreach ($this->unlinked as $record) {
232
            //Leading command
233
            $transaction->addCommand($recordCommand = $record->queueStore(), true);
234
235
            //Delete link
236
            $command = new ContextualDeleteCommand($this->pivotTable(), [
237
                $this->key(Record::THOUGHT_INNER_KEY) => null,
238
                $this->key(Record::THOUGHT_OUTER_KEY) => null,
239
            ]);
240
241
            //Make sure command is properly configured with conditions OR create promises
242
            $command = $this->ensureContext(
243
                $command,
244
                $this->parent,
245
                $parentCommand,
246
                $record,
247
                $recordCommand
248
            );
249
250
            $transaction->addCommand($command);
251
        }
252
253
        foreach ($this->instances as $record) {
254
            //Leading command
255
            $transaction->addCommand($recordCommand = $record->queueStore(), true);
256
257
            //Create or refresh link between records
258
            if (in_array($record, $this->scheduled)) {
259
                //Create link
260
                $command = new InsertCommand(
261
                    $this->pivotTable(),
262
                    $this->pivotData->offsetGet($record)
263
                );
264
            } elseif (in_array($record, $this->updated)) {
265
                //Update link
266
                $command = new UpdateCommand(
267
                    $this->pivotTable(),
268
                    [
269
                        $this->key(Record::THOUGHT_INNER_KEY) => null,
270
                        $this->key(Record::THOUGHT_OUTER_KEY) => null,
271
                    ],
272
                    $this->pivotData->offsetGet($record)
273
                );
274
            } else {
275
                //Nothing to do
276
                continue;
277
            }
278
279
            //Syncing pivot data values
280
            $command->onComplete(function (ContextualCommandInterface $command) use ($record) {
281
                //Now when we are done we can sync our values with current data
282
                $this->pivotData->offsetSet($record, $command->getContext());
283
            });
284
285
            //Make sure command is properly configured with conditions OR create promises
286
            $command = $this->ensureContext(
287
                $command,
288
                $this->parent,
289
                $parentCommand,
290
                $record,
291
                $recordCommand
292
            );
293
294
            $transaction->addCommand($command);
295
        }
296
297
        $this->scheduled = [];
298
        $this->unlinked = [];
299
        $this->updated = [];
300
301
        return $transaction;
302
    }
303
304
    /**
305
     * Insane method used to properly set pivot command context (where or insert statement) based on
306
     * parent and outer records AND/OR based on command promises.
307
     *
308
     * @param ContextualCommandInterface $pivotCommand
309
     * @param RecordInterface            $parent
310
     * @param ContextualCommandInterface $parentCommand
311
     * @param RecordInterface            $outer
312
     * @param ContextualCommandInterface $outerCommand
313
     *
314
     * @return ContextualCommandInterface
315
     */
316
    protected function ensureContext(
317
        ContextualCommandInterface $pivotCommand,
318
        RecordInterface $parent,
319
        ContextualCommandInterface $parentCommand,
320
        RecordInterface $outer,
321
        ContextualCommandInterface $outerCommand
322
    ) {
323
        //Parent record dependency
324
        $parentCommand->onExecute(function ($parentCommand) use ($pivotCommand, $parent) {
325
            $pivotCommand->addContext(
326
                $this->key(Record::THOUGHT_INNER_KEY),
327
                $this->lookupKey(Record::INNER_KEY, $parent, $parentCommand)
328
            );
329
        });
330
331
        //Outer record dependency
332
        $outerCommand->onExecute(function ($outerCommand) use ($pivotCommand, $outer) {
333
            $pivotCommand->addContext(
334
                $this->key(Record::THOUGHT_OUTER_KEY),
335
                $this->lookupKey(Record::OUTER_KEY, $outer, $outerCommand)
336
            );
337
        });
338
339
        return $pivotCommand;
340
    }
341
342
    /**
343
     * Fetch data from database. Lazy load. Method require a bit of love.
344
     *
345
     * @return array
346
     */
347
    protected function loadRelated(): array
348
    {
349
        $innerKey = $this->parent->getField($this->key(Record::INNER_KEY));
350
        if (empty($innerKey)) {
351
            return [];
352
        }
353
354
        //Work with pre-build query
355
        $query = $this->createQuery($innerKey);
356
357
        //Use custom node to parse data
358
        $node = new PivotedRootNode(
359
            $this->schema[Record::RELATION_COLUMNS],
360
            $this->schema[Record::PIVOT_COLUMNS],
361
            $this->schema[Record::OUTER_KEY],
362
            $this->schema[Record::THOUGHT_INNER_KEY],
363
            $this->schema[Record::THOUGHT_OUTER_KEY]
364
        );
365
366
        $iterator = $query->getIterator();
367
        foreach ($query->getIterator() as $row) {
368
            //Time to parse some data
369
            $node->parseRow(0, $row);
370
        }
371
372
        //Memory free
373
        $iterator->close();
374
375
        return $node->getResult();
376
    }
377
378
    /**
379
     * Create query for lazy loading.
380
     *
381
     * @param mixed $innerKey
382
     *
383
     * @return SelectQuery
384
     */
385
    protected function createQuery($innerKey): SelectQuery
386
    {
387
        $table = $this->orm->table($this->class);
388
        $query = $this->orm->table($this->class)->select();
389
390
        //Loader will take care of query configuration
391
        $loader = new ManyToManyLoader($this->class, $table->getName(), $this->schema, $this->orm);
392
393
        //This is root loader, we can do self-alias (THIS IS SAFE due loader in POSTLOAD mode)
394
        $loader = $loader->withContext(
395
            $loader,
396
            [
397
                'alias'      => $table->getName(),
398
                'pivotAlias' => $table->getName() . '_pivot',
399
                'method'     => RelationLoader::POSTLOAD
400
            ]
401
        );
402
403
        //Configuring query using parent inner key value as reference
404
        /** @var ManyToManyLoader $loader */
405
        $query = $loader->configureQuery($query, [$innerKey]);
406
407
        //Additional pivot conditions
408
        $pivotDecorator = new WhereDecorator($query, 'onWhere', $table->getName() . '_pivot');
409
        $pivotDecorator->where($this->schema[Record::WHERE_PIVOT]);
410
411
        //Additional where conditions!
412
        $decorator = new WhereDecorator($query, 'where', 'root');
413
        $decorator->where($this->schema[Record::WHERE]);
414
415
        return $query;
416
    }
417
418
    /**
419
     * Init relations and populate pivot map.
420
     *
421
     * @return ManyToManyRelation
422
     */
423
    protected function initInstances(): MultipleRelation
424
    {
425
        if (is_array($this->data) && !empty($this->data)) {
426
            //Iterates and instantiate records
427
            $iterator = new RecordIterator($this->data, $this->class, $this->orm);
428
429
            foreach ($iterator as $pivotData => $item) {
430
                if (in_array($item, $this->instances)) {
431
                    //Skip duplicates (if any?)
432
                    continue;
433
                }
434
435
                $this->pivotData->attach($item, $pivotData);
436
                $this->instances[] = $item;
437
            }
438
        }
439
440
        //Memory free
441
        $this->data = [];
442
443
        return $this;
444
    }
445
446
    /**
447
     * @return Table
448
     */
449
    private function pivotTable()
450
    {
451
        if (empty($this->pivotTable)) {
452
            $this->pivotTable = $this->orm->database(
453
                $this->schema[self::PIVOT_DATABASE]
454
            )->table(
455
                $this->schema[self::PIVOT_TABLE]
456
            );
457
        }
458
459
        return $this->pivotTable;
460
    }
461
462
    /**
463
     * Make sure that pivot data in a valid format.
464
     *
465
     * @param array $pivotData
466
     *
467
     * @throws RelationException
468
     */
469
    private function assertPivot(array $pivotData)
470
    {
471
        if ($diff = array_diff(array_keys($pivotData), $this->schema[Record::PIVOT_COLUMNS])) {
472
            throw new RelationException(
473
                "Invalid pivot data, undefined columns found: " . join(', ', $diff)
474
            );
475
        }
476
    }
477
}