Skip to content

Commit 1a4a972

Browse files
authored
Hybrid support for MorphToMany relationship (#2690)
1 parent b3779a1 commit 1a4a972

File tree

7 files changed

+257
-33
lines changed

7 files changed

+257
-33
lines changed

phpstan-baseline.neon

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ parameters:
77

88
-
99
message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#"
10-
count: 6
10+
count: 2
1111
path: src/Relations/MorphToMany.php
1212

1313
-

src/Eloquent/HybridRelations.php

+10-6
Original file line numberDiff line numberDiff line change
@@ -432,12 +432,16 @@ public function morphedByMany(
432432
$relatedKey = null,
433433
$relation = null,
434434
) {
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';
435+
// If the related model is an instance of eloquent model class, leave pivot keys
436+
// as default. It's necessary for supporting hybrid relationship
437+
if (is_subclass_of($related, Model::class)) {
438+
// For the inverse of the polymorphic many-to-many relations, we will change
439+
// the way we determine the foreign and other keys, as it is the opposite
440+
// of the morph-to-many method since we're figuring out these inverses.
441+
$foreignPivotKey = $foreignPivotKey ?: Str::plural($this->getForeignKey());
442+
443+
$relatedPivotKey = $relatedPivotKey ?: $name . '_id';
444+
}
441445

442446
return $this->morphToMany(
443447
$related,

src/Relations/MorphToMany.php

+99-23
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Database\Eloquent\Model;
1010
use Illuminate\Database\Eloquent\Relations\MorphToMany as EloquentMorphToMany;
1111
use Illuminate\Support\Arr;
12+
use MongoDB\BSON\ObjectId;
1213

1314
use function array_diff;
1415
use function array_key_exists;
@@ -17,7 +18,9 @@
1718
use function array_merge;
1819
use function array_reduce;
1920
use function array_values;
21+
use function collect;
2022
use function count;
23+
use function in_array;
2124
use function is_array;
2225
use function is_numeric;
2326

@@ -74,11 +77,20 @@ public function addEagerConstraints(array $models)
7477
protected function setWhere()
7578
{
7679
if ($this->getInverse()) {
77-
$ids = $this->extractIds((array) $this->parent->{$this->table});
80+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
81+
$ids = $this->extractIds((array) $this->parent->{$this->table});
7882

79-
$this->query->whereIn($this->relatedKey, $ids);
83+
$this->query->whereIn($this->relatedKey, $ids);
84+
} else {
85+
$this->query
86+
->whereIn($this->foreignPivotKey, (array) $this->parent->{$this->parentKey});
87+
}
8088
} else {
81-
$this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey});
89+
match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
90+
true => $this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey}),
91+
false => $this->query
92+
->whereIn($this->getQualifiedForeignPivotKeyName(), (array) $this->parent->{$this->parentKey}),
93+
};
8294
}
8395

8496
return $this;
@@ -128,9 +140,25 @@ public function sync($ids, $detaching = true)
128140
// in this joining table. We'll spin through the given IDs, checking to see
129141
// if they exist in the array of current ones, and if not we will insert.
130142
if ($this->getInverse()) {
131-
$current = $this->extractIds($this->parent->{$this->table} ?: []);
143+
$current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
144+
true => $this->parent->{$this->table} ?: [],
145+
false => $this->parent->{$this->relationName} ?: [],
146+
};
147+
148+
if ($current instanceof Collection) {
149+
$current = collect($this->parseIds($current))->flatten()->toArray();
150+
} else {
151+
$current = $this->extractIds($current);
152+
}
132153
} else {
133-
$current = $this->parent->{$this->relatedPivotKey} ?: [];
154+
$current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
155+
true => $this->parent->{$this->relatedPivotKey} ?: [],
156+
false => $this->parent->{$this->relationName} ?: [],
157+
};
158+
159+
if ($current instanceof Collection) {
160+
$current = $this->parseIds($current);
161+
}
134162
}
135163

136164
$records = $this->formatRecordsList($ids);
@@ -185,15 +213,19 @@ public function attach($id, array $attributes = [], $touch = true)
185213

186214
if ($this->getInverse()) {
187215
// Attach the new ids to the parent model.
188-
$this->parent->push($this->table, [
189-
[
190-
$this->relatedPivotKey => $model->{$this->relatedKey},
191-
$this->morphType => $model->getMorphClass(),
192-
],
193-
], true);
216+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
217+
$this->parent->push($this->table, [
218+
[
219+
$this->relatedPivotKey => $model->{$this->relatedKey},
220+
$this->morphType => $model->getMorphClass(),
221+
],
222+
], true);
223+
} else {
224+
$this->addIdToParentRelationData($id);
225+
}
194226

195227
// Attach the new parent id to the related model.
196-
$model->push($this->foreignPivotKey, $this->parseIds($this->parent), true);
228+
$model->push($this->foreignPivotKey, (array) $this->parent->{$this->parentKey}, true);
197229
} else {
198230
// Attach the new parent id to the related model.
199231
$model->push($this->table, [
@@ -204,7 +236,11 @@ public function attach($id, array $attributes = [], $touch = true)
204236
], true);
205237

206238
// Attach the new ids to the parent model.
207-
$this->parent->push($this->relatedPivotKey, (array) $id, true);
239+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
240+
$this->parent->push($this->relatedPivotKey, (array) $id, true);
241+
} else {
242+
$this->addIdToParentRelationData($id);
243+
}
208244
}
209245
} else {
210246
if ($id instanceof Collection) {
@@ -221,13 +257,19 @@ public function attach($id, array $attributes = [], $touch = true)
221257
$query->push($this->foreignPivotKey, $this->parent->{$this->parentKey});
222258

223259
// Attach the new ids to the parent model.
224-
foreach ($id as $item) {
225-
$this->parent->push($this->table, [
226-
[
227-
$this->relatedPivotKey => $item,
228-
$this->morphType => $this->related instanceof Model ? $this->related->getMorphClass() : null,
229-
],
230-
], true);
260+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
261+
foreach ($id as $item) {
262+
$this->parent->push($this->table, [
263+
[
264+
$this->relatedPivotKey => $item,
265+
$this->morphType => $this->related instanceof Model ? $this->related->getMorphClass() : null,
266+
],
267+
], true);
268+
}
269+
} else {
270+
foreach ($id as $item) {
271+
$this->addIdToParentRelationData($item);
272+
}
231273
}
232274
} else {
233275
// Attach the new parent id to the related model.
@@ -239,7 +281,13 @@ public function attach($id, array $attributes = [], $touch = true)
239281
], true);
240282

241283
// Attach the new ids to the parent model.
242-
$this->parent->push($this->relatedPivotKey, $id, true);
284+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
285+
$this->parent->push($this->relatedPivotKey, $id, true);
286+
} else {
287+
foreach ($id as $item) {
288+
$this->addIdToParentRelationData($item);
289+
}
290+
}
243291
}
244292
}
245293

@@ -276,7 +324,13 @@ public function detach($ids = [], $touch = true)
276324
];
277325
}
278326

279-
$this->parent->pull($this->table, $data);
327+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
328+
$this->parent->pull($this->table, $data);
329+
} else {
330+
$value = $this->parent->{$this->relationName}
331+
->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $this->extractIds($data)));
332+
$this->parent->setRelation($this->relationName, $value);
333+
}
280334

281335
// Prepare the query to select all related objects.
282336
if (count($ids) > 0) {
@@ -287,7 +341,13 @@ public function detach($ids = [], $touch = true)
287341
$query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey});
288342
} else {
289343
// Remove the relation from the parent.
290-
$this->parent->pull($this->relatedPivotKey, $ids);
344+
if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) {
345+
$this->parent->pull($this->relatedPivotKey, $ids);
346+
} else {
347+
$value = $this->parent->{$this->relationName}
348+
->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $ids));
349+
$this->parent->setRelation($this->relationName, $value);
350+
}
291351

292352
// Prepare the query to select all related objects.
293353
if (count($ids) > 0) {
@@ -390,4 +450,20 @@ public function extractIds(array $data, ?string $relatedPivotKey = null)
390450
return $carry;
391451
}, []);
392452
}
453+
454+
/**
455+
* Add the given id to the relation's data of the current parent instance.
456+
* It helps to keep up-to-date the sql model instances in hybrid relationships.
457+
*
458+
* @param ObjectId|string|int $id
459+
*
460+
* @return void
461+
*/
462+
private function addIdToParentRelationData($id)
463+
{
464+
$instance = new $this->related();
465+
$instance->forceFill([$this->relatedKey => $id]);
466+
$relationData = $this->parent->{$this->relationName}->push($instance)->unique($this->relatedKey);
467+
$this->parent->setRelation($this->relationName, $relationData);
468+
}
393469
}

tests/HybridRelationsTest.php

+106
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Illuminate\Database\SQLiteConnection;
88
use Illuminate\Support\Facades\DB;
99
use MongoDB\Laravel\Tests\Models\Book;
10+
use MongoDB\Laravel\Tests\Models\Experience;
11+
use MongoDB\Laravel\Tests\Models\Label;
1012
use MongoDB\Laravel\Tests\Models\Role;
1113
use MongoDB\Laravel\Tests\Models\Skill;
1214
use MongoDB\Laravel\Tests\Models\SqlBook;
@@ -38,6 +40,8 @@ public function tearDown(): void
3840
SqlBook::truncate();
3941
SqlRole::truncate();
4042
Skill::truncate();
43+
Experience::truncate();
44+
Label::truncate();
4145
}
4246

4347
public function testSqlRelations()
@@ -261,4 +265,106 @@ public function testHybridBelongsToMany()
261265
$check = SqlUser::find($user->id);
262266
$this->assertEquals(1, $check->skills->count());
263267
}
268+
269+
public function testHybridMorphToManySqlModelToMongoModel()
270+
{
271+
// SqlModel -> MorphToMany -> MongoModel
272+
$user = new SqlUser();
273+
$user2 = new SqlUser();
274+
$this->assertInstanceOf(SqlUser::class, $user);
275+
$this->assertInstanceOf(SQLiteConnection::class, $user->getConnection());
276+
$this->assertInstanceOf(SqlUser::class, $user2);
277+
$this->assertInstanceOf(SQLiteConnection::class, $user2->getConnection());
278+
279+
// Create Mysql Users
280+
$user->fill(['name' => 'John Doe'])->save();
281+
$user = SqlUser::query()->find($user->id);
282+
283+
$user2->fill(['name' => 'Maria Doe'])->save();
284+
$user2 = SqlUser::query()->find($user2->id);
285+
286+
// Create Mongodb skills
287+
$label = Label::query()->create(['name' => 'Laravel']);
288+
$label2 = Label::query()->create(['name' => 'MongoDB']);
289+
290+
// MorphToMany (pivot is empty)
291+
$user->labels()->sync([$label->_id, $label2->_id]);
292+
$check = SqlUser::query()->find($user->id);
293+
$this->assertEquals(2, $check->labels->count());
294+
295+
// MorphToMany (pivot is not empty)
296+
$user->labels()->sync($label);
297+
$check = SqlUser::query()->find($user->id);
298+
$this->assertEquals(1, $check->labels->count());
299+
300+
// Attach MorphToMany
301+
$user->labels()->sync([]);
302+
$check = SqlUser::query()->find($user->id);
303+
$this->assertEquals(0, $check->labels->count());
304+
$user->labels()->attach($label);
305+
$user->labels()->attach($label); // ignore duplicates
306+
$check = SqlUser::query()->find($user->id);
307+
$this->assertEquals(1, $check->labels->count());
308+
309+
// Inverse MorphToMany (pivot is empty)
310+
$label->sqlUsers()->sync([$user->id, $user2->id]);
311+
$check = Label::query()->find($label->_id);
312+
$this->assertEquals(2, $check->sqlUsers->count());
313+
314+
// Inverse MorphToMany (pivot is empty)
315+
$label->sqlUsers()->sync([$user->id, $user2->id]);
316+
$check = Label::query()->find($label->_id);
317+
$this->assertEquals(2, $check->sqlUsers->count());
318+
}
319+
320+
public function testHybridMorphToManyMongoModelToSqlModel()
321+
{
322+
// MongoModel -> MorphToMany -> SqlModel
323+
$user = new SqlUser();
324+
$user2 = new SqlUser();
325+
$this->assertInstanceOf(SqlUser::class, $user);
326+
$this->assertInstanceOf(SQLiteConnection::class, $user->getConnection());
327+
$this->assertInstanceOf(SqlUser::class, $user2);
328+
$this->assertInstanceOf(SQLiteConnection::class, $user2->getConnection());
329+
330+
// Create Mysql Users
331+
$user->fill(['name' => 'John Doe'])->save();
332+
$user = SqlUser::query()->find($user->id);
333+
334+
$user2->fill(['name' => 'Maria Doe'])->save();
335+
$user2 = SqlUser::query()->find($user2->id);
336+
337+
// Create Mongodb experiences
338+
$experience = Experience::query()->create(['title' => 'DB expert']);
339+
$experience2 = Experience::query()->create(['title' => 'MongoDB']);
340+
341+
// MorphToMany (pivot is empty)
342+
$experience->sqlUsers()->sync([$user->id, $user2->id]);
343+
$check = Experience::query()->find($experience->_id);
344+
$this->assertEquals(2, $check->sqlUsers->count());
345+
346+
// MorphToMany (pivot is not empty)
347+
$experience->sqlUsers()->sync([$user->id]);
348+
$check = Experience::query()->find($experience->_id);
349+
$this->assertEquals(1, $check->sqlUsers->count());
350+
351+
// Inverse MorphToMany (pivot is empty)
352+
$user->experiences()->sync([$experience->_id, $experience2->_id]);
353+
$check = SqlUser::query()->find($user->id);
354+
$this->assertEquals(2, $check->experiences->count());
355+
356+
// Inverse MorphToMany (pivot is not empty)
357+
$user->experiences()->sync([$experience->_id]);
358+
$check = SqlUser::query()->find($user->id);
359+
$this->assertEquals(1, $check->experiences->count());
360+
361+
// Inverse MorphToMany (pivot is not empty)
362+
$user->experiences()->sync([]);
363+
$check = SqlUser::query()->find($user->id);
364+
$this->assertEquals(0, $check->experiences->count());
365+
$user->experiences()->attach($experience);
366+
$user->experiences()->attach($experience); // ignore duplicates
367+
$check = SqlUser::query()->find($user->id);
368+
$this->assertEquals(1, $check->experiences->count());
369+
}
264370
}

tests/Models/Experience.php

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace MongoDB\Laravel\Tests\Models;
66

7+
use Illuminate\Database\Eloquent\Relations\MorphToMany;
78
use MongoDB\Laravel\Eloquent\Model as Eloquent;
89

910
class Experience extends Eloquent
@@ -23,4 +24,9 @@ public function skillsWithCustomParentKey()
2324
{
2425
return $this->belongsToMany(Skill::class, parentKey: 'cexperience_id');
2526
}
27+
28+
public function sqlUsers(): MorphToMany
29+
{
30+
return $this->morphToMany(SqlUser::class, 'experienced');
31+
}
2632
}

0 commit comments

Comments
 (0)