Completed
Branch feature/pre-split (825af5)
by Anton
03:36
created

ManyToManyRelation::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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