diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4869c6ca0..99579fa0a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -7,7 +7,7 @@ parameters: - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" - count: 6 + count: 2 path: src/Relations/MorphToMany.php - diff --git a/src/Eloquent/HybridRelations.php b/src/Eloquent/HybridRelations.php index 9551a6c43..5c058f50f 100644 --- a/src/Eloquent/HybridRelations.php +++ b/src/Eloquent/HybridRelations.php @@ -432,12 +432,16 @@ public function morphedByMany( $relatedKey = null, $relation = null, ) { - $foreignPivotKey = $foreignPivotKey ?: Str::plural($this->getForeignKey()); - - // For the inverse of the polymorphic many-to-many relations, we will change - // the way we determine the foreign and other keys, as it is the opposite - // of the morph-to-many method since we're figuring out these inverses. - $relatedPivotKey = $relatedPivotKey ?: $name . '_id'; + // If the related model is an instance of eloquent model class, leave pivot keys + // as default. It's necessary for supporting hybrid relationship + if (is_subclass_of($related, Model::class)) { + // For the inverse of the polymorphic many-to-many relations, we will change + // the way we determine the foreign and other keys, as it is the opposite + // of the morph-to-many method since we're figuring out these inverses. + $foreignPivotKey = $foreignPivotKey ?: Str::plural($this->getForeignKey()); + + $relatedPivotKey = $relatedPivotKey ?: $name . '_id'; + } return $this->morphToMany( $related, diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php index a2c55969f..163e7e67f 100644 --- a/src/Relations/MorphToMany.php +++ b/src/Relations/MorphToMany.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany as EloquentMorphToMany; use Illuminate\Support\Arr; +use MongoDB\BSON\ObjectId; use function array_diff; use function array_key_exists; @@ -17,7 +18,9 @@ use function array_merge; use function array_reduce; use function array_values; +use function collect; use function count; +use function in_array; use function is_array; use function is_numeric; @@ -74,11 +77,20 @@ public function addEagerConstraints(array $models) protected function setWhere() { if ($this->getInverse()) { - $ids = $this->extractIds((array) $this->parent->{$this->table}); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $ids = $this->extractIds((array) $this->parent->{$this->table}); - $this->query->whereIn($this->relatedKey, $ids); + $this->query->whereIn($this->relatedKey, $ids); + } else { + $this->query + ->whereIn($this->foreignPivotKey, (array) $this->parent->{$this->parentKey}); + } } else { - $this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey}); + match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + true => $this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey}), + false => $this->query + ->whereIn($this->getQualifiedForeignPivotKeyName(), (array) $this->parent->{$this->parentKey}), + }; } return $this; @@ -128,9 +140,25 @@ public function sync($ids, $detaching = true) // in this joining table. We'll spin through the given IDs, checking to see // if they exist in the array of current ones, and if not we will insert. if ($this->getInverse()) { - $current = $this->extractIds($this->parent->{$this->table} ?: []); + $current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + true => $this->parent->{$this->table} ?: [], + false => $this->parent->{$this->relationName} ?: [], + }; + + if ($current instanceof Collection) { + $current = collect($this->parseIds($current))->flatten()->toArray(); + } else { + $current = $this->extractIds($current); + } } else { - $current = $this->parent->{$this->relatedPivotKey} ?: []; + $current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + true => $this->parent->{$this->relatedPivotKey} ?: [], + false => $this->parent->{$this->relationName} ?: [], + }; + + if ($current instanceof Collection) { + $current = $this->parseIds($current); + } } $records = $this->formatRecordsList($ids); @@ -185,15 +213,19 @@ public function attach($id, array $attributes = [], $touch = true) if ($this->getInverse()) { // Attach the new ids to the parent model. - $this->parent->push($this->table, [ - [ - $this->relatedPivotKey => $model->{$this->relatedKey}, - $this->morphType => $model->getMorphClass(), - ], - ], true); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $this->parent->push($this->table, [ + [ + $this->relatedPivotKey => $model->{$this->relatedKey}, + $this->morphType => $model->getMorphClass(), + ], + ], true); + } else { + $this->addIdToParentRelationData($id); + } // Attach the new parent id to the related model. - $model->push($this->foreignPivotKey, $this->parseIds($this->parent), true); + $model->push($this->foreignPivotKey, (array) $this->parent->{$this->parentKey}, true); } else { // Attach the new parent id to the related model. $model->push($this->table, [ @@ -204,7 +236,11 @@ public function attach($id, array $attributes = [], $touch = true) ], true); // Attach the new ids to the parent model. - $this->parent->push($this->relatedPivotKey, (array) $id, true); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $this->parent->push($this->relatedPivotKey, (array) $id, true); + } else { + $this->addIdToParentRelationData($id); + } } } else { if ($id instanceof Collection) { @@ -221,13 +257,19 @@ public function attach($id, array $attributes = [], $touch = true) $query->push($this->foreignPivotKey, $this->parent->{$this->parentKey}); // Attach the new ids to the parent model. - foreach ($id as $item) { - $this->parent->push($this->table, [ - [ - $this->relatedPivotKey => $item, - $this->morphType => $this->related instanceof Model ? $this->related->getMorphClass() : null, - ], - ], true); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + foreach ($id as $item) { + $this->parent->push($this->table, [ + [ + $this->relatedPivotKey => $item, + $this->morphType => $this->related instanceof Model ? $this->related->getMorphClass() : null, + ], + ], true); + } + } else { + foreach ($id as $item) { + $this->addIdToParentRelationData($item); + } } } else { // Attach the new parent id to the related model. @@ -239,7 +281,13 @@ public function attach($id, array $attributes = [], $touch = true) ], true); // Attach the new ids to the parent model. - $this->parent->push($this->relatedPivotKey, $id, true); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $this->parent->push($this->relatedPivotKey, $id, true); + } else { + foreach ($id as $item) { + $this->addIdToParentRelationData($item); + } + } } } @@ -276,7 +324,13 @@ public function detach($ids = [], $touch = true) ]; } - $this->parent->pull($this->table, $data); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $this->parent->pull($this->table, $data); + } else { + $value = $this->parent->{$this->relationName} + ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $this->extractIds($data))); + $this->parent->setRelation($this->relationName, $value); + } // Prepare the query to select all related objects. if (count($ids) > 0) { @@ -287,7 +341,13 @@ public function detach($ids = [], $touch = true) $query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey}); } else { // Remove the relation from the parent. - $this->parent->pull($this->relatedPivotKey, $ids); + if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $this->parent->pull($this->relatedPivotKey, $ids); + } else { + $value = $this->parent->{$this->relationName} + ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $ids)); + $this->parent->setRelation($this->relationName, $value); + } // Prepare the query to select all related objects. if (count($ids) > 0) { @@ -390,4 +450,20 @@ public function extractIds(array $data, ?string $relatedPivotKey = null) return $carry; }, []); } + + /** + * Add the given id to the relation's data of the current parent instance. + * It helps to keep up-to-date the sql model instances in hybrid relationships. + * + * @param ObjectId|string|int $id + * + * @return void + */ + private function addIdToParentRelationData($id) + { + $instance = new $this->related(); + $instance->forceFill([$this->relatedKey => $id]); + $relationData = $this->parent->{$this->relationName}->push($instance)->unique($this->relatedKey); + $this->parent->setRelation($this->relationName, $relationData); + } } diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 0080a3a47..5253784c9 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -7,6 +7,8 @@ use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; use MongoDB\Laravel\Tests\Models\Book; +use MongoDB\Laravel\Tests\Models\Experience; +use MongoDB\Laravel\Tests\Models\Label; use MongoDB\Laravel\Tests\Models\Role; use MongoDB\Laravel\Tests\Models\Skill; use MongoDB\Laravel\Tests\Models\SqlBook; @@ -38,6 +40,8 @@ public function tearDown(): void SqlBook::truncate(); SqlRole::truncate(); Skill::truncate(); + Experience::truncate(); + Label::truncate(); } public function testSqlRelations() @@ -261,4 +265,106 @@ public function testHybridBelongsToMany() $check = SqlUser::find($user->id); $this->assertEquals(1, $check->skills->count()); } + + public function testHybridMorphToManySqlModelToMongoModel() + { + // SqlModel -> MorphToMany -> MongoModel + $user = new SqlUser(); + $user2 = new SqlUser(); + $this->assertInstanceOf(SqlUser::class, $user); + $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); + $this->assertInstanceOf(SqlUser::class, $user2); + $this->assertInstanceOf(SQLiteConnection::class, $user2->getConnection()); + + // Create Mysql Users + $user->fill(['name' => 'John Doe'])->save(); + $user = SqlUser::query()->find($user->id); + + $user2->fill(['name' => 'Maria Doe'])->save(); + $user2 = SqlUser::query()->find($user2->id); + + // Create Mongodb skills + $label = Label::query()->create(['name' => 'Laravel']); + $label2 = Label::query()->create(['name' => 'MongoDB']); + + // MorphToMany (pivot is empty) + $user->labels()->sync([$label->_id, $label2->_id]); + $check = SqlUser::query()->find($user->id); + $this->assertEquals(2, $check->labels->count()); + + // MorphToMany (pivot is not empty) + $user->labels()->sync($label); + $check = SqlUser::query()->find($user->id); + $this->assertEquals(1, $check->labels->count()); + + // Attach MorphToMany + $user->labels()->sync([]); + $check = SqlUser::query()->find($user->id); + $this->assertEquals(0, $check->labels->count()); + $user->labels()->attach($label); + $user->labels()->attach($label); // ignore duplicates + $check = SqlUser::query()->find($user->id); + $this->assertEquals(1, $check->labels->count()); + + // Inverse MorphToMany (pivot is empty) + $label->sqlUsers()->sync([$user->id, $user2->id]); + $check = Label::query()->find($label->_id); + $this->assertEquals(2, $check->sqlUsers->count()); + + // Inverse MorphToMany (pivot is empty) + $label->sqlUsers()->sync([$user->id, $user2->id]); + $check = Label::query()->find($label->_id); + $this->assertEquals(2, $check->sqlUsers->count()); + } + + public function testHybridMorphToManyMongoModelToSqlModel() + { + // MongoModel -> MorphToMany -> SqlModel + $user = new SqlUser(); + $user2 = new SqlUser(); + $this->assertInstanceOf(SqlUser::class, $user); + $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); + $this->assertInstanceOf(SqlUser::class, $user2); + $this->assertInstanceOf(SQLiteConnection::class, $user2->getConnection()); + + // Create Mysql Users + $user->fill(['name' => 'John Doe'])->save(); + $user = SqlUser::query()->find($user->id); + + $user2->fill(['name' => 'Maria Doe'])->save(); + $user2 = SqlUser::query()->find($user2->id); + + // Create Mongodb experiences + $experience = Experience::query()->create(['title' => 'DB expert']); + $experience2 = Experience::query()->create(['title' => 'MongoDB']); + + // MorphToMany (pivot is empty) + $experience->sqlUsers()->sync([$user->id, $user2->id]); + $check = Experience::query()->find($experience->_id); + $this->assertEquals(2, $check->sqlUsers->count()); + + // MorphToMany (pivot is not empty) + $experience->sqlUsers()->sync([$user->id]); + $check = Experience::query()->find($experience->_id); + $this->assertEquals(1, $check->sqlUsers->count()); + + // Inverse MorphToMany (pivot is empty) + $user->experiences()->sync([$experience->_id, $experience2->_id]); + $check = SqlUser::query()->find($user->id); + $this->assertEquals(2, $check->experiences->count()); + + // Inverse MorphToMany (pivot is not empty) + $user->experiences()->sync([$experience->_id]); + $check = SqlUser::query()->find($user->id); + $this->assertEquals(1, $check->experiences->count()); + + // Inverse MorphToMany (pivot is not empty) + $user->experiences()->sync([]); + $check = SqlUser::query()->find($user->id); + $this->assertEquals(0, $check->experiences->count()); + $user->experiences()->attach($experience); + $user->experiences()->attach($experience); // ignore duplicates + $check = SqlUser::query()->find($user->id); + $this->assertEquals(1, $check->experiences->count()); + } } diff --git a/tests/Models/Experience.php b/tests/Models/Experience.php index 617073c79..4c2869d9e 100644 --- a/tests/Models/Experience.php +++ b/tests/Models/Experience.php @@ -4,6 +4,7 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use MongoDB\Laravel\Eloquent\Model as Eloquent; class Experience extends Eloquent @@ -23,4 +24,9 @@ public function skillsWithCustomParentKey() { return $this->belongsToMany(Skill::class, parentKey: 'cexperience_id'); } + + public function sqlUsers(): MorphToMany + { + return $this->morphToMany(SqlUser::class, 'experienced'); + } } diff --git a/tests/Models/Label.php b/tests/Models/Label.php index 179503ce1..5bd1cf4da 100644 --- a/tests/Models/Label.php +++ b/tests/Models/Label.php @@ -4,6 +4,7 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use MongoDB\Laravel\Eloquent\Model as Eloquent; /** @@ -23,14 +24,16 @@ class Label extends Eloquent 'chapters', ]; - /** - * Get all the posts that are assigned this tag. - */ public function users() { return $this->morphedByMany(User::class, 'labelled'); } + public function sqlUsers(): MorphToMany + { + return $this->morphedByMany(SqlUser::class, 'labeled'); + } + public function clients() { return $this->morphedByMany(Client::class, 'labelled'); diff --git a/tests/Models/SqlUser.php b/tests/Models/SqlUser.php index 34c65f42e..4cb77faa5 100644 --- a/tests/Models/SqlUser.php +++ b/tests/Models/SqlUser.php @@ -12,6 +12,7 @@ use Illuminate\Database\Schema\SQLiteBuilder; use Illuminate\Support\Facades\Schema; use MongoDB\Laravel\Eloquent\HybridRelations; +use MongoDB\Laravel\Relations\MorphToMany; use function assert; @@ -43,6 +44,16 @@ public function sqlBooks(): HasMany return $this->hasMany(SqlBook::class); } + public function labels(): MorphToMany + { + return $this->morphToMany(Label::class, 'labeled'); + } + + public function experiences(): MorphToMany + { + return $this->morphedByMany(Experience::class, 'experienced'); + } + /** * Check if we need to run the schema. */ @@ -57,6 +68,8 @@ public static function executeSchema(): void $table->string('name'); $table->timestamps(); }); + + // Pivot table for BelongsToMany relationship with Skill if (! $schema->hasTable('skill_sql_user')) { $schema->create('skill_sql_user', function (Blueprint $table) { $table->foreignIdFor(self::class)->constrained()->cascadeOnDelete(); @@ -64,5 +77,21 @@ public static function executeSchema(): void $table->primary([(new self())->getForeignKey(), (new Skill())->getForeignKey()]); }); } + + // Pivot table for MorphToMany relationship with Label + if (! $schema->hasTable('labeleds')) { + $schema->create('labeleds', function (Blueprint $table) { + $table->foreignIdFor(self::class)->constrained()->cascadeOnDelete(); + $table->morphs('labeled'); + }); + } + + // Pivot table for MorphedByMany relationship with Experience + if (! $schema->hasTable('experienceds')) { + $schema->create('experienceds', function (Blueprint $table) { + $table->foreignIdFor(self::class)->constrained()->cascadeOnDelete(); + $table->morphs('experienced'); + }); + } } }