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?) |
|
|
|
|
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
|
|
|
} |
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.