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

ManyToManyRelation::unlink()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 10
nc 4
nop 1
dl 0
loc 21
rs 8.7624
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\Builders\SelectQuery;
10
use Spiral\Database\Entities\Table;
11
use Spiral\Database\Exceptions\QueryException;
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\Entities\Relations\Traits\MatchTrait;
24
use Spiral\ORM\Entities\Relations\Traits\PartialTrait;
25
use Spiral\ORM\Exceptions\RelationException;
26
use Spiral\ORM\Exceptions\SelectorException;
27
use Spiral\ORM\Helpers\WhereDecorator;
28
use Spiral\ORM\Record;
29
use Spiral\ORM\RecordInterface;
30
use Spiral\ORM\RelationInterface;
31
32
/**
33
 * Provides ability to create pivot map between parent record and multiple objects with ability to
34
 * link them, link and create, update pivot, unlink or sync send. Relation support partial mode.
35
 */
36
class ManyToManyRelation extends AbstractRelation implements \IteratorAggregate, \Countable
37
{
38
    use MatchTrait, PartialTrait, LookupTrait;
39
40
    /** Schema shortcuts */
41
    const PIVOT_TABLE    = Record::PIVOT_TABLE;
42
    const PIVOT_DATABASE = 917;
43
44
    /**
45
     * @var Table|null
46
     */
47
    private $pivotTable = null;
48
49
    /**
50
     * @var \SplObjectStorage
51
     */
52
    private $pivotData;
53
54
    /**
55
     * Linked records.
56
     *
57
     * @var RecordInterface[]
58
     */
59
    private $linked = [];
60
61
    /**
62
     * Linked but not saved yet records.
63
     *
64
     * @var array
65
     */
66
    private $scheduled = [];
67
68
    /**
69
     * Record which pivot data was updated, record must still present in linked array.
70
     *
71
     * @var array
72
     */
73
    private $updated = [];
74
75
    /**
76
     * Records scheduled to be de-associated.
77
     *
78
     * @var RecordInterface[]
79
     */
80
    private $unlinked = [];
81
82
    /**
83
     * {@inheritdoc}
84
     */
85
    public function hasRelated(): bool
86
    {
87
        return !empty($this->linked);
88
    }
89
90
    /**
91
     * {@inheritdoc}
92
     */
93
    public function withContext(
94
        RecordInterface $parent,
95
        bool $loaded = false,
96
        array $data = null
97
    ): RelationInterface {
98
        /**
99
         * @var self $relation
100
         */
101
        $relation = parent::withContext($parent, $loaded, $data);
102
        $relation->pivotData = $this->pivotData ?? new \SplObjectStorage();
103
104
        return $relation->initInstances();
105
    }
106
107
    /**
108
     * {@inheritdoc}
109
     */
110
    public function setRelated($value)
111
    {
112
        $this->loadData(true);
113
114
        if (is_null($value)) {
115
            $value = [];
116
        }
117
118
        if (!is_array($value)) {
119
            throw new RelationException("HasMany relation can only be set with array of entities");
120
        }
121
122
        //todo: write this section!!
123
    }
124
125
    /**
126
     * @return $this
127
     */
128
    public function getRelated()
129
    {
130
        return $this;
131
    }
132
133
    /**
134
     * Iterate over linked instances, will force pre-loading unless partial.
135
     *
136
     * @return \ArrayIterator
137
     */
138
    public function getIterator()
139
    {
140
        return new \ArrayIterator($this->loadData(true)->linked);
141
    }
142
143
    /**
144
     * @return int
145
     */
146
    public function count()
147
    {
148
        return count($this->loadData(true)->linked);
149
    }
150
151
    /**
152
     * Get all unlinked records.
153
     *
154
     * @return \ArrayIterator
155
     */
156
    public function getUnlinked()
157
    {
158
        return new \ArrayIterator($this->unlinked);
159
    }
160
161
    /**
162
     * Get pivot data associated with specific instance.
163
     *
164
     * @param RecordInterface $record
165
     *
166
     * @return array
167
     *
168
     * @throws RelationException
169
     */
170
    public function getPivot(RecordInterface $record): array
171
    {
172
        if (empty($matched = $this->matchOne($record))) {
173
            throw new RelationException(
174
                "Unable to get pivotData for non linked record" . ($this->autoload ? '' : " (partial on)")
175
            );
176
        }
177
178
        return $this->pivotData->offsetGet($matched);
179
    }
180
181
    /**
182
     * Link record with parent entity. Only record instances is accepted.
183
     *
184
     * @param RecordInterface $record
185
     * @param array           $pivotData
186
     *
187
     * @return self
188
     *
189
     * @throws RelationException
190
     */
191
    public function link(RecordInterface $record, array $pivotData = []): self
192
    {
193
        $this->loadData(true);
194
        $this->assertValid($record);
195
196
        if (in_array($record, $this->linked)) {
197
            $this->assertPivot($pivotData);
198
199
            //Merging pivot data
200
            $this->pivotData->offsetSet($record, $pivotData + $this->getPivot($record));
201
202
            if (!in_array($record, $this->updated)) {
203
                //Indicating that record pivot data has been changed
204
                $this->updated[] = $record;
205
            }
206
207
            return $this;
208
        }
209
210
        //New association
211
        $this->linked[] = $record;
212
        $this->scheduled[] = $record;
213
        $this->pivotData->offsetSet($record, $pivotData);
214
215
        return $this;
216
    }
217
218
    /**
219
     * Unlink specific entity from relation. Will load relation data! Record to delete will be
220
     * automatically matched to a give record.
221
     *
222
     * @param RecordInterface $record
223
     *
224
     * @return self
225
     *
226
     * @throws RelationException When entity not linked.
227
     */
228
    public function unlink(RecordInterface $record): self
229
    {
230
        $this->loadData(true);
231
232
        foreach ($this->linked as $index => $linked) {
233
            if ($this->match($linked, $record)) {
234
                //Removing locally
235
                unset($this->linked[$index]);
236
237
                if (!in_array($linked, $this->scheduled) || !$this->autoload) {
238
                    //Scheduling unlink in db when we know relation OR partial mode is on
239
                    $this->unlinked[] = $linked;
240
                }
241
                break;
242
            }
243
        }
244
245
        $this->linked = array_values($this->linked);
246
247
        return $this;
248
    }
249
250
    /**
251
     * Check if given query points to linked entity.
252
     *
253
     * Example:
254
     * echo $post->tags->has(1);
255
     * echo $post->tags->has(['name'=>'tag a']);
256
     *
257
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
258
     *
259
     * @return bool
260
     */
261
    public function has($query)
262
    {
263
        return !empty($this->matchOne($query));
264
    }
265
266
    /**
267
     * Fine one entity for a given query or return null. Method will autoload data.
268
     *
269
     * Example: ->matchOne(['value' => 'something', ...]);
270
     *
271
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
272
     *
273
     * @return RecordInterface|null
274
     */
275
    public function matchOne($query)
276
    {
277
        foreach ($this->loadData(true)->linked as $instance) {
278
            if ($this->match($instance, $query)) {
279
                return $instance;
280
            }
281
        }
282
283
        return null;
284
    }
285
286
    /**
287
     * Return only instances matched given query, performed in memory! Only simple conditions are
288
     * allowed. Not "find" due trademark violation. Method will autoload data.
289
     *
290
     * Example: ->matchMultiple(['value' => 'something', ...]);
291
     *
292
     * @param array|RecordInterface|mixed $query Fields, entity or PK.
293
     *
294
     * @return \ArrayIterator
295
     */
296
    public function matchMultiple($query)
297
    {
298
        $result = [];
299
        foreach ($this->loadData()->linked as $instance) {
300
            if ($this->match($instance, $query)) {
301
                $result[] = $instance;
302
            }
303
        }
304
305
        return new \ArrayIterator($result);
306
    }
307
308
    /**
309
     * {@inheritdoc}
310
     */
311
    public function queueCommands(ContextualCommandInterface $parentCommand): CommandInterface
312
    {
313
        $transaction = new TransactionalCommand();
314
315
        foreach ($this->unlinked as $record) {
316
            //Leading command
317
            $transaction->addCommand($recordCommand = $record->queueStore(), true);
318
319
            //Delete link
320
            $command = new ContextualDeleteCommand($this->pivotTable(), [
321
                $this->key(Record::THOUGHT_INNER_KEY) => null,
322
                $this->key(Record::THOUGHT_OUTER_KEY) => null,
323
            ]);
324
325
            //Make sure command is properly configured with conditions OR create promises
326
            $command = $this->ensureContext(
327
                $command,
328
                $this->parent,
329
                $parentCommand,
330
                $record,
331
                $recordCommand
332
            );
333
334
            $transaction->addCommand($command);
335
        }
336
337
        foreach ($this->linked as $record) {
338
            //Leading command
339
            $transaction->addCommand($recordCommand = $record->queueStore(), true);
340
341
            //Create or refresh link between records
342
            if (in_array($record, $this->scheduled)) {
343
                //Create link
344
                $command = new InsertCommand(
345
                    $this->pivotTable(),
346
                    $this->pivotData->offsetGet($record)
347
                );
348
            } elseif (in_array($record, $this->updated)) {
349
                //Update link
350
                $command = new UpdateCommand(
351
                    $this->pivotTable(),
352
                    [
353
                        $this->key(Record::THOUGHT_INNER_KEY) => null,
354
                        $this->key(Record::THOUGHT_OUTER_KEY) => null,
355
                    ],
356
                    $this->pivotData->offsetGet($record)
357
                );
358
            } else {
359
                //Nothing to do
360
                continue;
361
            }
362
363
            //Syncing pivot data values
364
            $command->onComplete(function (ContextualCommandInterface $command) use ($record) {
365
                //Now when we are done we can sync our values with current data
366
                $this->pivotData->offsetSet($record, $command->getContext());
367
            });
368
369
            //Make sure command is properly configured with conditions OR create promises
370
            $command = $this->ensureContext(
371
                $command,
372
                $this->parent,
373
                $parentCommand,
374
                $record,
375
                $recordCommand
376
            );
377
378
            $transaction->addCommand($command);
379
        }
380
381
        $this->scheduled = [];
382
        $this->unlinked = [];
383
        $this->updated = [];
384
385
        return $transaction;
386
    }
387
388
    /**
389
     * Load related records from database.
390
     *
391
     * @param bool $autoload
392
     *
393
     * @return self
394
     *
395
     * @throws SelectorException
396
     * @throws QueryException (needs wrapping)
397
     */
398
    protected function loadData(bool $autoload = true): self
399
    {
400
        if ($this->loaded) {
401
            return $this;
402
        }
403
404
        $this->loaded = true;
405
406
        if (empty($this->data) || !is_array($this->data)) {
407
            if ($this->autoload && $autoload) {
408
                //Only for non partial selections
409
                $this->data = $this->loadRelated();
410
            } else {
411
                $this->data = [];
412
            }
413
        }
414
415
        return $this->initInstances();
416
    }
417
418
    /**
419
     * Fetch data from database. Lazy load. Method require a bit of love.
420
     *
421
     * @return array
422
     */
423
    protected function loadRelated(): array
424
    {
425
        $innerKey = $this->parent->getField($this->key(Record::INNER_KEY));
426
        if (empty($innerKey)) {
427
            return [];
428
        }
429
430
        //Work with pre-build query
431
        $query = $this->createQuery($innerKey);
432
433
        //Use custom node to parse data
434
        $node = new PivotedRootNode(
435
            $this->schema[Record::RELATION_COLUMNS],
436
            $this->schema[Record::PIVOT_COLUMNS],
437
            $this->schema[Record::OUTER_KEY],
438
            $this->schema[Record::THOUGHT_INNER_KEY],
439
            $this->schema[Record::THOUGHT_OUTER_KEY]
440
        );
441
442
        $iterator = $query->getIterator();
443
        foreach ($query->getIterator() as $row) {
444
            //Time to parse some data
445
            $node->parseRow(0, $row);
446
        }
447
448
        //Memory free
449
        $iterator->close();
450
451
        return $node->getResult();
452
    }
453
454
    /**
455
     * Indicates that records are not linked yet.
456
     *
457
     * @param RecordInterface $outer
458
     * @param bool            $strict When true will also check that keys are not empty.
459
     *
460
     * @return bool
461
     */
462
    protected function isLinked(RecordInterface $outer, bool $strict = false)
0 ignored issues
show
Unused Code introduced by
The parameter $strict is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
463
    {
464
        $pivotData = $this->pivotData->offsetGet($outer);
465
        if (empty($pivotData)) {
466
            //No pivot data at all
467
            return false;
468
        }
469
470
        if (empty($pivotData[$this->key(Record::THOUGHT_OUTER_KEY)])) {
471
            //No outer key value
472
            return false;
473
        }
474
475
        if (empty($pivotData[$this->key(Record::THOUGHT_INNER_KEY)])) {
476
            //No inner key value
477
            return false;
478
        }
479
480
        //Both keys are set
481
        return true;
482
    }
483
484
    /**
485
     * Insane method used to properly set pivot command context (where or insert statement) based on
486
     * parent and outer records AND/OR based on command promises.
487
     *
488
     * @param ContextualCommandInterface $pivotCommand
489
     * @param RecordInterface            $parent
490
     * @param ContextualCommandInterface $parentCommand
491
     * @param RecordInterface            $outer
492
     * @param ContextualCommandInterface $outerCommand
493
     *
494
     * @return ContextualCommandInterface
495
     */
496
    protected function ensureContext(
497
        ContextualCommandInterface $pivotCommand,
498
        RecordInterface $parent,
499
        ContextualCommandInterface $parentCommand,
500
        RecordInterface $outer,
501
        ContextualCommandInterface $outerCommand
502
    ) {
503
        //Parent record dependency
504
        $parentCommand->onExecute(function ($parentCommand) use ($pivotCommand, $parent) {
505
            $pivotCommand->addContext(
506
                $this->key(Record::THOUGHT_INNER_KEY),
507
                $this->lookupKey(Record::INNER_KEY, $parent, $parentCommand)
508
            );
509
        });
510
511
        //Outer record dependency
512
        $outerCommand->onExecute(function ($outerCommand) use ($pivotCommand, $outer) {
513
            $pivotCommand->addContext(
514
                $this->key(Record::THOUGHT_OUTER_KEY),
515
                $this->lookupKey(Record::OUTER_KEY, $outer, $outerCommand)
516
            );
517
        });
518
519
        return $pivotCommand;
520
    }
521
522
    /**
523
     * @return Table
524
     */
525
    protected function pivotTable()
526
    {
527
        if (empty($this->pivotTable)) {
528
            $this->pivotTable = $this->orm->database(
529
                $this->schema[self::PIVOT_DATABASE]
530
            )->table(
531
                $this->schema[self::PIVOT_TABLE]
532
            );
533
        }
534
535
        return $this->pivotTable;
536
    }
537
538
539
    /**
540
     * Create query for lazy loading.
541
     *
542
     * @param mixed $innerKey
543
     *
544
     * @return SelectQuery
545
     */
546
    protected function createQuery($innerKey): SelectQuery
547
    {
548
        $table = $this->orm->table($this->class);
549
        $query = $table->select();
550
551
        //Loader will take care of query configuration
552
        $loader = new ManyToManyLoader($this->class, $table->getName(), $this->schema, $this->orm);
553
554
        //This is root loader, we can do self-alias (THIS IS SAFE due loader in POSTLOAD mode)
555
        $loader = $loader->withContext(
556
            $loader,
557
            ['alias' => $table->getName(), 'method' => RelationLoader::POSTLOAD]
558
        );
559
560
        //Configuring query using parent inner key value as reference
561
        /** @var ManyToManyLoader $loader */
562
        $query = $loader->configureQuery($query, [$innerKey]);
563
564
        //Additional pivot conditions
565
        $pivotDecorator = new WhereDecorator($query, 'onWhere', 'root_pivot');
566
        $pivotDecorator->where($this->schema[Record::WHERE_PIVOT]);
567
568
        //Additional where conditions!
569
        $decorator = new WhereDecorator($query, 'where', 'root');
570
        $decorator->where($this->schema[Record::WHERE]);
571
572
        return $query;
573
    }
574
575
    /**
576
     * Init relations and populate pivot map.
577
     *
578
     * @return ManyToManyRelation
579
     */
580
    private function initInstances(): self
581
    {
582
        if (is_array($this->data) && !empty($this->data)) {
583
            //Iterates and instantiate records
584
            $iterator = new RecordIterator($this->data, $this->class, $this->orm);
585
586
            foreach ($iterator as $pivotData => $item) {
587
                if (in_array($item, $this->linked)) {
588
                    //Skip duplicates (if any?)
589
                    continue;
590
                }
591
592
                $this->pivotData->attach($item, $pivotData);
593
                $this->linked[] = $item;
594
            }
595
        }
596
597
        //Memory free
598
        $this->data = [];
599
600
        return $this;
601
    }
602
603
    /**
604
     * Make sure that pivot data in a valid format.
605
     *
606
     * @param array $pivotData
607
     *
608
     * @throws RelationException
609
     */
610
    private function assertPivot(array $pivotData)
611
    {
612
        if ($diff = array_diff(array_keys($pivotData), $this->schema[Record::PIVOT_COLUMNS])) {
613
            throw new RelationException(
614
                "Invalid pivot data, undefined columns found: " . join(', ', $diff)
615
            );
616
        }
617
    }
618
}