Skip to content

Commit 82ddb83

Browse files
authoredNov 22, 2023
[Feature] Add MorphToMany support (#2670)
1 parent bcadf52 commit 82ddb83

File tree

7 files changed

+1092
-3
lines changed

7 files changed

+1092
-3
lines changed
 

‎src/Eloquent/HybridRelations.php

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515
use MongoDB\Laravel\Relations\HasOne;
1616
use MongoDB\Laravel\Relations\MorphMany;
1717
use MongoDB\Laravel\Relations\MorphTo;
18+
use MongoDB\Laravel\Relations\MorphToMany;
1819

20+
use function array_pop;
1921
use function debug_backtrace;
22+
use function implode;
2023
use function is_subclass_of;
24+
use function preg_split;
2125

2226
use const DEBUG_BACKTRACE_IGNORE_ARGS;
27+
use const PREG_SPLIT_DELIM_CAPTURE;
2328

2429
/**
2530
* Cross-database relationships between SQL and MongoDB.
@@ -328,6 +333,125 @@ public function belongsToMany(
328333
);
329334
}
330335

336+
/**
337+
* Define a morph-to-many relationship.
338+
*
339+
* @param string $related
340+
* @param string $name
341+
* @param null $table
342+
* @param null $foreignPivotKey
343+
* @param null $relatedPivotKey
344+
* @param null $parentKey
345+
* @param null $relatedKey
346+
* @param null $relation
347+
* @param bool $inverse
348+
*
349+
* @return \Illuminate\Database\Eloquent\Relations\MorphToMany
350+
*/
351+
public function morphToMany(
352+
$related,
353+
$name,
354+
$table = null,
355+
$foreignPivotKey = null,
356+
$relatedPivotKey = null,
357+
$parentKey = null,
358+
$relatedKey = null,
359+
$relation = null,
360+
$inverse = false,
361+
) {
362+
// If no relationship name was passed, we will pull backtraces to get the
363+
// name of the calling function. We will use that function name as the
364+
// title of this relation since that is a great convention to apply.
365+
if ($relation === null) {
366+
$relation = $this->guessBelongsToManyRelation();
367+
}
368+
369+
// Check if it is a relation with an original model.
370+
if (! is_subclass_of($related, Model::class)) {
371+
return parent::morphToMany(
372+
$related,
373+
$name,
374+
$table,
375+
$foreignPivotKey,
376+
$relatedPivotKey,
377+
$parentKey,
378+
$relatedKey,
379+
$relation,
380+
$inverse,
381+
);
382+
}
383+
384+
$instance = new $related();
385+
386+
$foreignPivotKey = $foreignPivotKey ?: $name . '_id';
387+
$relatedPivotKey = $relatedPivotKey ?: Str::plural($instance->getForeignKey());
388+
389+
// Now we're ready to create a new query builder for the related model and
390+
// the relationship instances for this relation. This relation will set
391+
// appropriate query constraints then entirely manage the hydration.
392+
if (! $table) {
393+
$words = preg_split('/(_)/u', $name, -1, PREG_SPLIT_DELIM_CAPTURE);
394+
$lastWord = array_pop($words);
395+
$table = implode('', $words) . Str::plural($lastWord);
396+
}
397+
398+
return new MorphToMany(
399+
$instance->newQuery(),
400+
$this,
401+
$name,
402+
$table,
403+
$foreignPivotKey,
404+
$relatedPivotKey,
405+
$parentKey ?: $this->getKeyName(),
406+
$relatedKey ?: $instance->getKeyName(),
407+
$relation,
408+
$inverse,
409+
);
410+
}
411+
412+
/**
413+
* Define a polymorphic, inverse many-to-many relationship.
414+
*
415+
* @param string $related
416+
* @param string $name
417+
* @param null $table
418+
* @param null $foreignPivotKey
419+
* @param null $relatedPivotKey
420+
* @param null $parentKey
421+
* @param null $relatedKey
422+
*
423+
* @return \Illuminate\Database\Eloquent\Relations\MorphToMany
424+
*/
425+
public function morphedByMany(
426+
$related,
427+
$name,
428+
$table = null,
429+
$foreignPivotKey = null,
430+
$relatedPivotKey = null,
431+
$parentKey = null,
432+
$relatedKey = null,
433+
$relation = null,
434+
) {
435+
$foreignPivotKey = $foreignPivotKey ?: Str::plural($this->getForeignKey());
436+
437+
// For the inverse of the polymorphic many-to-many relations, we will change
438+
// the way we determine the foreign and other keys, as it is the opposite
439+
// of the morph-to-many method since we're figuring out these inverses.
440+
$relatedPivotKey = $relatedPivotKey ?: $name . '_id';
441+
442+
return $this->morphToMany(
443+
$related,
444+
$name,
445+
$table,
446+
$foreignPivotKey,
447+
$relatedPivotKey,
448+
$parentKey,
449+
$relatedKey,
450+
$relatedKey,
451+
true,
452+
);
453+
}
454+
331455
/** @inheritdoc */
332456
public function newEloquentBuilder($query)
333457
{

‎src/Helpers/QueriesRelationships.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
use Illuminate\Database\Eloquent\Relations\Relation;
1414
use Illuminate\Support\Collection;
1515
use MongoDB\Laravel\Eloquent\Model;
16+
use MongoDB\Laravel\Relations\MorphToMany;
1617

1718
use function array_count_values;
1819
use function array_filter;
1920
use function array_keys;
2021
use function array_map;
2122
use function class_basename;
23+
use function collect;
24+
use function get_class;
2225
use function in_array;
2326
use function is_array;
2427
use function is_string;
@@ -114,13 +117,48 @@ public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $
114117
$not = ! $not;
115118
}
116119

117-
$relations = $hasQuery->pluck($this->getHasCompareKey($relation));
120+
$relations = match (true) {
121+
$relation instanceof MorphToMany => $relation->getInverse() ?
122+
$this->handleMorphedByMany($hasQuery, $relation) :
123+
$this->handleMorphToMany($hasQuery, $relation),
124+
default => $hasQuery->pluck($this->getHasCompareKey($relation))
125+
};
118126

119127
$relatedIds = $this->getConstrainedRelatedIds($relations, $operator, $count);
120128

121129
return $this->whereIn($this->getRelatedConstraintKey($relation), $relatedIds, $boolean, $not);
122130
}
123131

132+
/**
133+
* @param Builder $hasQuery
134+
* @param Relation $relation
135+
*
136+
* @return Collection
137+
*/
138+
private function handleMorphToMany($hasQuery, $relation)
139+
{
140+
// First we select the parent models that have a relation to our related model,
141+
// Then extracts related model's ids from the pivot column
142+
$hasQuery->where($relation->getTable() . '.' . $relation->getMorphType(), get_class($relation->getParent()));
143+
$relations = $hasQuery->pluck($relation->getTable());
144+
$relations = $relation->extractIds($relations->flatten(1)->toArray(), $relation->getForeignPivotKeyName());
145+
146+
return collect($relations);
147+
}
148+
149+
/**
150+
* @param Builder $hasQuery
151+
* @param Relation $relation
152+
*
153+
* @return Collection
154+
*/
155+
private function handleMorphedByMany($hasQuery, $relation)
156+
{
157+
$hasQuery->whereNotNull($relation->getForeignPivotKeyName());
158+
159+
return $hasQuery->pluck($relation->getForeignPivotKeyName())->flatten(1);
160+
}
161+
124162
/** @return string */
125163
protected function getHasCompareKey(Relation $relation)
126164
{

‎src/Relations/MorphToMany.php

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Relations;
6+
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Eloquent\Collection;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Database\Eloquent\Relations\MorphToMany as EloquentMorphToMany;
11+
use Illuminate\Support\Arr;
12+
13+
use function array_diff;
14+
use function array_key_exists;
15+
use function array_keys;
16+
use function array_map;
17+
use function array_merge;
18+
use function array_reduce;
19+
use function array_values;
20+
use function count;
21+
use function is_array;
22+
use function is_numeric;
23+
24+
class MorphToMany extends EloquentMorphToMany
25+
{
26+
/** @inheritdoc */
27+
public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*'])
28+
{
29+
return $query;
30+
}
31+
32+
/** @inheritdoc */
33+
protected function hydratePivotRelation(array $models)
34+
{
35+
// Do nothing.
36+
}
37+
38+
/** @inheritdoc */
39+
protected function shouldSelect(array $columns = ['*'])
40+
{
41+
return $columns;
42+
}
43+
44+
/** @inheritdoc */
45+
public function addConstraints()
46+
{
47+
if (static::$constraints) {
48+
$this->setWhere();
49+
}
50+
}
51+
52+
/** @inheritdoc */
53+
public function addEagerConstraints(array $models)
54+
{
55+
// To load relation's data, we act normally on MorphToMany relation,
56+
// But on MorphedByMany relation, we collect related ids from pivot column
57+
// and add to a whereIn condition
58+
if ($this->getInverse()) {
59+
$ids = $this->getKeys($models, $this->table);
60+
$ids = $this->extractIds($ids[0] ?? []);
61+
$this->query->whereIn($this->relatedKey, $ids);
62+
} else {
63+
parent::addEagerConstraints($models);
64+
65+
$this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass);
66+
}
67+
}
68+
69+
/**
70+
* Set the where clause for the relation query.
71+
*
72+
* @return $this
73+
*/
74+
protected function setWhere()
75+
{
76+
if ($this->getInverse()) {
77+
$ids = $this->extractIds((array) $this->parent->{$this->table});
78+
79+
$this->query->whereIn($this->relatedKey, $ids);
80+
} else {
81+
$this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey});
82+
}
83+
84+
return $this;
85+
}
86+
87+
/** @inheritdoc */
88+
public function save(Model $model, array $joining = [], $touch = true)
89+
{
90+
$model->save(['touch' => false]);
91+
92+
$this->attach($model, $joining, $touch);
93+
94+
return $model;
95+
}
96+
97+
/** @inheritdoc */
98+
public function create(array $attributes = [], array $joining = [], $touch = true)
99+
{
100+
$instance = $this->related->newInstance($attributes);
101+
102+
// Once we save the related model, we need to attach it to the base model via
103+
// through intermediate table so we'll use the existing "attach" method to
104+
// accomplish this which will insert the record and any more attributes.
105+
$instance->save(['touch' => false]);
106+
107+
$this->attach($instance, $joining, $touch);
108+
109+
return $instance;
110+
}
111+
112+
/** @inheritdoc */
113+
public function sync($ids, $detaching = true)
114+
{
115+
$changes = [
116+
'attached' => [],
117+
'detached' => [],
118+
'updated' => [],
119+
];
120+
121+
if ($ids instanceof Collection) {
122+
$ids = $this->parseIds($ids);
123+
} elseif ($ids instanceof Model) {
124+
$ids = $this->parseIds($ids);
125+
}
126+
127+
// First we need to attach any of the associated models that are not currently
128+
// in this joining table. We'll spin through the given IDs, checking to see
129+
// if they exist in the array of current ones, and if not we will insert.
130+
if ($this->getInverse()) {
131+
$current = $this->extractIds($this->parent->{$this->table} ?: []);
132+
} else {
133+
$current = $this->parent->{$this->relatedPivotKey} ?: [];
134+
}
135+
136+
// See issue #256.
137+
if ($current instanceof Collection) {
138+
$current = $this->parseIds($current);
139+
}
140+
141+
$records = $this->formatRecordsList($ids);
142+
143+
$current = Arr::wrap($current);
144+
145+
$detach = array_diff($current, array_keys($records));
146+
147+
// We need to make sure we pass a clean array, so that it is not interpreted
148+
// as an associative array.
149+
$detach = array_values($detach);
150+
151+
// Next, we will take the differences of the currents and given IDs and detach
152+
// all of the entities that exist in the "current" array but are not in the
153+
// the array of the IDs given to the method which will complete the sync.
154+
if ($detaching && count($detach) > 0) {
155+
$this->detach($detach);
156+
157+
$changes['detached'] = array_map(function ($v) {
158+
return is_numeric($v) ? (int) $v : (string) $v;
159+
}, $detach);
160+
}
161+
162+
// Now we are finally ready to attach the new records. Note that we'll disable
163+
// touching until after the entire operation is complete so we don't fire a
164+
// ton of touch operations until we are totally done syncing the records.
165+
$changes = array_merge(
166+
$changes,
167+
$this->attachNew($records, $current, false),
168+
);
169+
170+
if (count($changes['attached']) || count($changes['updated'])) {
171+
$this->touchIfTouching();
172+
}
173+
174+
return $changes;
175+
}
176+
177+
/** @inheritdoc */
178+
public function updateExistingPivot($id, array $attributes, $touch = true)
179+
{
180+
// Do nothing, we have no pivot table.
181+
}
182+
183+
/** @inheritdoc */
184+
public function attach($id, array $attributes = [], $touch = true)
185+
{
186+
if ($id instanceof Model) {
187+
$model = $id;
188+
189+
$id = $this->parseId($model);
190+
191+
if ($this->getInverse()) {
192+
// Attach the new ids to the parent model.
193+
$this->parent->push($this->table, [
194+
[
195+
$this->relatedPivotKey => $model->{$this->relatedKey},
196+
$this->morphType => $model->getMorphClass(),
197+
],
198+
], true);
199+
200+
// Attach the new parent id to the related model.
201+
$model->push($this->foreignPivotKey, $this->parseIds($this->parent), true);
202+
} else {
203+
// Attach the new parent id to the related model.
204+
$model->push($this->table, [
205+
[
206+
$this->foreignPivotKey => $this->parent->{$this->parentKey},
207+
$this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null,
208+
],
209+
], true);
210+
211+
// Attach the new ids to the parent model.
212+
$this->parent->push($this->relatedPivotKey, (array) $id, true);
213+
}
214+
} else {
215+
if ($id instanceof Collection) {
216+
$id = $this->parseIds($id);
217+
}
218+
219+
$id = (array) $id;
220+
221+
$query = $this->newRelatedQuery();
222+
$query->whereIn($this->relatedKey, $id);
223+
224+
if ($this->getInverse()) {
225+
// Attach the new parent id to the related model.
226+
$query->push($this->foreignPivotKey, $this->parent->{$this->parentKey});
227+
228+
// Attach the new ids to the parent model.
229+
foreach ($id as $item) {
230+
$this->parent->push($this->table, [
231+
[
232+
$this->relatedPivotKey => $item,
233+
$this->morphType => $this->related instanceof Model ? $this->related->getMorphClass() : null,
234+
],
235+
], true);
236+
}
237+
} else {
238+
// Attach the new parent id to the related model.
239+
$query->push($this->table, [
240+
[
241+
$this->foreignPivotKey => $this->parent->{$this->parentKey},
242+
$this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null,
243+
],
244+
], true);
245+
246+
// Attach the new ids to the parent model.
247+
$this->parent->push($this->relatedPivotKey, $id, true);
248+
}
249+
}
250+
251+
if ($touch) {
252+
$this->touchIfTouching();
253+
}
254+
}
255+
256+
/** @inheritdoc */
257+
public function detach($ids = [], $touch = true)
258+
{
259+
if ($ids instanceof Model) {
260+
$ids = $this->parseIds($ids);
261+
}
262+
263+
$query = $this->newRelatedQuery();
264+
265+
// If associated IDs were passed to the method we will only delete those
266+
// associations, otherwise all the association ties will be broken.
267+
// We'll return the numbers of affected rows when we do the deletes.
268+
$ids = (array) $ids;
269+
270+
// Detach all ids from the parent model.
271+
if ($this->getInverse()) {
272+
// Remove the relation from the parent.
273+
$data = [];
274+
foreach ($ids as $item) {
275+
$data = array_merge($data, [
276+
[
277+
$this->relatedPivotKey => $item,
278+
$this->morphType => $this->related->getMorphClass(),
279+
],
280+
]);
281+
}
282+
283+
$this->parent->pull($this->table, $data);
284+
285+
// Prepare the query to select all related objects.
286+
if (count($ids) > 0) {
287+
$query->whereIn($this->relatedKey, $ids);
288+
}
289+
290+
// Remove the relation from the related.
291+
$query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey});
292+
} else {
293+
// Remove the relation from the parent.
294+
$this->parent->pull($this->relatedPivotKey, $ids);
295+
296+
// Prepare the query to select all related objects.
297+
if (count($ids) > 0) {
298+
$query->whereIn($this->relatedKey, $ids);
299+
}
300+
301+
// Remove the relation to the related.
302+
$query->pull($this->table, [
303+
[
304+
$this->foreignPivotKey => $this->parent->{$this->parentKey},
305+
$this->morphType => $this->parent->getMorphClass(),
306+
],
307+
]);
308+
}
309+
310+
if ($touch) {
311+
$this->touchIfTouching();
312+
}
313+
314+
return count($ids);
315+
}
316+
317+
/** @inheritdoc */
318+
protected function buildDictionary(Collection $results)
319+
{
320+
$foreign = $this->foreignPivotKey;
321+
322+
// First we will build a dictionary of child models keyed by the foreign key
323+
// of the relation so that we will easily and quickly match them to their
324+
// parents without having a possibly slow inner loops for every models.
325+
$dictionary = [];
326+
327+
foreach ($results as $result) {
328+
if ($this->getInverse()) {
329+
foreach ($result->$foreign as $item) {
330+
$dictionary[$item][] = $result;
331+
}
332+
} else {
333+
// Collect $foreign value from pivot column of result model
334+
$items = $this->extractIds($result->{$this->table} ?? [], $foreign);
335+
foreach ($items as $item) {
336+
$dictionary[$item][] = $result;
337+
}
338+
}
339+
}
340+
341+
return $dictionary;
342+
}
343+
344+
/** @inheritdoc */
345+
public function newPivotQuery()
346+
{
347+
return $this->newRelatedQuery();
348+
}
349+
350+
/**
351+
* Create a new query builder for the related model.
352+
*
353+
* @return \Illuminate\Database\Query\Builder
354+
*/
355+
public function newRelatedQuery()
356+
{
357+
return $this->related->newQuery();
358+
}
359+
360+
/** @inheritdoc */
361+
public function getQualifiedRelatedPivotKeyName()
362+
{
363+
return $this->relatedPivotKey;
364+
}
365+
366+
/**
367+
* Get the name of the "where in" method for eager loading.
368+
*
369+
* @param string $key
370+
*
371+
* @return string
372+
*/
373+
protected function whereInMethod(Model $model, $key)
374+
{
375+
return 'whereIn';
376+
}
377+
378+
/**
379+
* Extract ids from given pivot table data
380+
*
381+
* @param array $data
382+
* @param string|null $relatedPivotKey
383+
*
384+
* @return mixed
385+
*/
386+
public function extractIds(array $data, ?string $relatedPivotKey = null)
387+
{
388+
$relatedPivotKey = $relatedPivotKey ?: $this->relatedPivotKey;
389+
return array_reduce($data, function ($carry, $item) use ($relatedPivotKey) {
390+
if (is_array($item) && array_key_exists($relatedPivotKey, $item)) {
391+
$carry[] = $item[$relatedPivotKey];
392+
}
393+
394+
return $carry;
395+
}, []);
396+
}
397+
}

‎tests/Models/Client.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,22 @@ public function addresses(): HasMany
2929
{
3030
return $this->hasMany(Address::class, 'data.client_id', 'data.client_id');
3131
}
32+
33+
public function labels()
34+
{
35+
return $this->morphToMany(Label::class, 'labelled');
36+
}
37+
38+
public function labelsWithCustomKeys()
39+
{
40+
return $this->morphToMany(
41+
Label::class,
42+
'clabelled',
43+
'clabelleds',
44+
'cclabelled_id',
45+
'clabel_ids',
46+
'cclient_id',
47+
'clabel_id',
48+
);
49+
}
3250
}

‎tests/Models/Label.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tests\Models;
6+
7+
use MongoDB\Laravel\Eloquent\Model as Eloquent;
8+
9+
/**
10+
* @property string $title
11+
* @property string $author
12+
* @property array $chapters
13+
*/
14+
class Label extends Eloquent
15+
{
16+
protected $connection = 'mongodb';
17+
protected $collection = 'labels';
18+
protected static $unguarded = true;
19+
20+
protected $fillable = [
21+
'name',
22+
'author',
23+
'chapters',
24+
];
25+
26+
/**
27+
* Get all the posts that are assigned this tag.
28+
*/
29+
public function users()
30+
{
31+
return $this->morphedByMany(User::class, 'labelled');
32+
}
33+
34+
public function clients()
35+
{
36+
return $this->morphedByMany(Client::class, 'labelled');
37+
}
38+
39+
public function clientsWithCustomKeys()
40+
{
41+
return $this->morphedByMany(
42+
Client::class,
43+
'clabelled',
44+
'clabelleds',
45+
'clabel_ids',
46+
'cclabelled_id',
47+
'clabel_id',
48+
'cclient_id',
49+
);
50+
}
51+
}

‎tests/Models/User.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,22 @@ class User extends Eloquent implements AuthenticatableContract, CanResetPassword
3838
use Notifiable;
3939
use MassPrunable;
4040

41-
protected $connection = 'mongodb';
42-
protected $casts = [
41+
protected $connection = 'mongodb';
42+
protected $casts = [
4343
'birthday' => 'datetime',
4444
'entry.date' => 'datetime',
4545
'member_status' => MemberStatus::class,
4646
];
47+
48+
protected $fillable = [
49+
'name',
50+
'email',
51+
'title',
52+
'age',
53+
'birthday',
54+
'username',
55+
'member_status',
56+
];
4757
protected static $unguarded = true;
4858

4959
public function books()
@@ -96,6 +106,11 @@ public function photos()
96106
return $this->morphMany(Photo::class, 'has_image');
97107
}
98108

109+
public function labels()
110+
{
111+
return $this->morphToMany(Label::class, 'labelled');
112+
}
113+
99114
public function addresses()
100115
{
101116
return $this->embedsMany(Address::class);

‎tests/RelationsTest.php

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.