ManyToManyRelation::assertPivot()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
471
                    continue;
472
                }
473
474
                $this->pivotData->attach($item, $pivotData);
475
                $this->instances[] = $item;
476
            }
477
        }
478
479
        //Memory free
480
        $this->data = [];
481
482
        return $this;
483
    }
484
485
    /**
486
     * @return Table
487
     */
488
    private function pivotTable()
489
    {
490
        if (empty($this->pivotTable)) {
491
            $this->pivotTable = $this->orm->database(
492
                $this->schema[Record::PIVOT_DATABASE]
493
            )->table(
494
                $this->schema[Record::PIVOT_TABLE]
495
            );
496
        }
497
498
        return $this->pivotTable;
499
    }
500
501
    /**
502
     * Make sure that pivot data in a valid format.
503
     *
504
     * @param array $pivotData
505
     *
506
     * @throws RelationException
507
     */
508
    private function assertPivot(array $pivotData)
509
    {
510
        if ($diff = array_diff(array_keys($pivotData), $this->schema[Record::PIVOT_COLUMNS])) {
511
            throw new RelationException(
512
                "Invalid pivot data, undefined columns found: " . join(', ', $diff)
513
            );
514
        }
515
    }
516
517
    /**
518
     * Defined role to be used in morphed relations.
519
     *
520
     * @return string
521
     */
522
    private function targetRole(): string
523
    {
524
        return $this->targetRole ?? $this->orm->define(
525
                get_class($this->parent),
526
                ORMInterface::R_ROLE_NAME
527
            );
528
    }
529
}