diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index f7b4f1f36..f3031fb1e 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -771,4 +771,52 @@ public function refresh() return $this; } + + /** + * Get a serialized attribute from the model. + * + * @param string $key + * + * @return mixed + */ + public function getSerializedAttribute($key) + { + $value = $this->getAttribute($key); + + // Convert ObjectID to string. + if ($value instanceof ObjectID) { + return (string) $value; + } + + if ($value instanceof Binary) { + return (string) $value->getData(); + } + + return $value; + } + + /** + * Get the serialized value of the model's primary key. + * + * @return mixed + */ + public function getSerializedKey() + { + return $this->getSerializedAttribute($this->getKeyName()); + } + + /** + * Determine if two models have the same ID and belong to the same table. + * + * @param \Illuminate\Database\Eloquent\Model|null $model + * + * @return bool + */ + public function is($model) + { + return $model !== null && + $this->getSerializedKey() === $model->getSerializedKey() && + $this->getTable() === $model->getTable() && + $this->getConnectionName() === $model->getConnectionName(); + } } diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index 8ff311f3f..0d0fdc6e9 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -4,18 +4,23 @@ namespace MongoDB\Laravel\Relations; +use BackedEnum; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany as EloquentBelongsToMany; use Illuminate\Support\Arr; +use Illuminate\Support\Collection as BaseCollection; +use MongoDB\BSON\Binary; +use MongoDB\BSON\ObjectId; use function array_diff; -use function array_keys; +use function array_intersect; use function array_map; use function array_merge; use function array_values; use function assert; +use function collect; use function count; use function in_array; use function is_numeric; @@ -107,8 +112,37 @@ public function create(array $attributes = [], array $joining = [], $touch = tru return $instance; } - /** @inheritdoc */ - public function sync($ids, $detaching = true) + /** + * Format the sync / toggle record list so that it is keyed by ID. + * + * @param array $records + * + * @return array + */ + protected function formatRecordsList($records): array + { + //Support for an object type id. + //Removal of attribute management because there is no pivot table + return collect($records)->map(function ($id) { + if ($id instanceof BackedEnum) { + $id = $id->value; + } + + return $id; + })->all(); + } + + /** + * Toggles a model (or models) from the parent. + * + * Each existing model is detached, and non existing ones are attached. + * + * @param mixed $ids + * @param bool $touch + * + * @return array + */ + public function toggle($ids, $touch = true) { $changes = [ 'attached' => [], @@ -130,25 +164,96 @@ public function sync($ids, $detaching = true) false => $this->parent->{$this->relationName} ?: [], }; - if ($current instanceof Collection) { + // Support Base Collection + if ($current instanceof BaseCollection) { $current = $this->parseIds($current); } + $current = Arr::wrap($current); $records = $this->formatRecordsList($ids); - $current = Arr::wrap($current); + $detach = array_values(array_intersect($current, $records)); - $detach = array_diff($current, array_keys($records)); + if (count($detach) > 0) { + $this->detach($detach, false); - // We need to make sure we pass a clean array, so that it is not interpreted - // as an associative array. - $detach = array_values($detach); + $changes['detached'] = (array) array_map(function ($v) { + return is_numeric($v) ? (int) $v : (string) $v; + }, $detach); + } + + // Finally, for all of the records which were not "detached", we'll attach the + // records into the intermediate table. Then, we will add those attaches to + // this change list and get ready to return these results to the callers. + $attach = array_values(array_diff($records, $current)); + + if (count($attach) > 0) { + $this->attach($attach, [], false); + + $changes['attached'] = (array) array_map(function ($v) { + return $this->castKey($v); + }, $attach); + } + + // Once we have finished attaching or detaching the records, we will see if we + // have done any attaching or detaching, and if we have we will touch these + // relationships if they are configured to touch on any database updates. + if ( + $touch && (count($changes['attached']) || + count($changes['detached'])) + ) { + $this->parent->touch(); + $this->newRelatedQuery()->whereIn($this->relatedKey, $ids)->touch(); + } + + return $changes; + } + + /** + * Sync the intermediate tables with a list of IDs or collection of models. + * + * @param \Illuminate\Support\Collection|Model|array $ids + * @param bool $detaching + * + * @return array + */ + public function sync($ids, $detaching = true) + { + $changes = [ + 'attached' => [], + 'detached' => [], + 'updated' => [], + ]; + + if ($ids instanceof Collection) { + $ids = $this->parseIds($ids); + } elseif ($ids instanceof Model) { + $ids = $this->parseIds($ids); + } + + // First we need to attach any of the associated models that are not currently + // 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. + $current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + true => $this->parent->{$this->relatedPivotKey} ?: [], + false => $this->parent->{$this->relationName} ?: [], + }; + + // Support Base Collection + if ($current instanceof BaseCollection) { + $current = $this->parseIds($current); + } + + $current = Arr::wrap($current); + $records = $this->formatRecordsList($ids); + + $detach = array_values(array_diff($current, $records)); // Next, we will take the differences of the currents and given IDs and detach // all of the entities that exist in the "current" array but are not in the // the array of the IDs given to the method which will complete the sync. if ($detaching && count($detach) > 0) { - $this->detach($detach); + $this->detach($detach, false); $changes['detached'] = (array) array_map(function ($v) { return is_numeric($v) ? (int) $v : (string) $v; @@ -158,13 +263,18 @@ public function sync($ids, $detaching = true) // Now we are finally ready to attach the new records. Note that we'll disable // touching until after the entire operation is complete so we don't fire a // ton of touch operations until we are totally done syncing the records. - $changes = array_merge( - $changes, - $this->attachNew($records, $current, false), - ); + foreach ($records as $id) { + // Only non strict check if exist no update s possible beacause no attributtes + if (! in_array($id, $current)) { + $this->attach($id, [], false); + $changes['attached'][] = $this->castKey($id); + } + } - if (count($changes['attached']) || count($changes['updated'])) { - $this->touchIfTouching(); + if ((count($changes['attached']) || count($changes['detached']))) { + $touches = array_merge($detach, $records); + $this->parent->touch(); + $this->newRelatedQuery()->whereIn($this->relatedKey, $touches)->touch(); } return $changes; @@ -202,7 +312,13 @@ public function attach($id, array $attributes = [], $touch = true) // Attach the new ids to the parent model. if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { - $this->parent->push($this->relatedPivotKey, (array) $id, true); + if ($id instanceof ObjectId) { + $id = [$id]; + } else { + $id = (array) $id; + } + + $this->parent->push($this->relatedPivotKey, $id, true); } else { $instance = new $this->related(); $instance->forceFill([$this->relatedKey => $id]); @@ -214,13 +330,16 @@ public function attach($id, array $attributes = [], $touch = true) return; } - $this->touchIfTouching(); + $this->parent->touch(); + $this->newRelatedQuery()->whereIn($this->relatedKey, (array) $id); } /** @inheritdoc */ public function detach($ids = [], $touch = true) { - if ($ids instanceof Model) { + if ($ids instanceof Collection) { + $ids = $this->parseIds($ids); + } elseif ($ids instanceof Model) { $ids = $this->parseIds($ids); } @@ -229,7 +348,11 @@ public function detach($ids = [], $touch = true) // If associated IDs were passed to the method we will only delete those // associations, otherwise all of the association ties will be broken. // We'll return the numbers of affected rows when we do the deletes. - $ids = (array) $ids; + if ($ids instanceof ObjectId) { + $ids = [$ids]; + } else { + $ids = (array) $ids; + } // Detach all ids from the parent model. if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { @@ -237,6 +360,7 @@ public function detach($ids = [], $touch = true) } else { $value = $this->parent->{$this->relationName} ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $ids)); + $this->parent->setRelation($this->relationName, $value); } @@ -251,7 +375,8 @@ public function detach($ids = [], $touch = true) $query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey}); if ($touch) { - $this->touchIfTouching(); + $this->parent->touch(); + $query->touch(); } return count($ids); @@ -269,6 +394,15 @@ protected function buildDictionary(Collection $results) foreach ($results as $result) { foreach ($result->$foreign as $item) { + //Prevent if id is non keyable + if ($item instanceof ObjectId) { + $item = (string) $item; + } + + if ($item instanceof Binary) { + $item = (string) $item->getData(); + } + $dictionary[$item][] = $result; } } diff --git a/tests/Models/PlanetOid.php b/tests/Models/PlanetOid.php new file mode 100644 index 000000000..67a7da683 --- /dev/null +++ b/tests/Models/PlanetOid.php @@ -0,0 +1,23 @@ +belongsToMany(SpaceExplorerOid::class); + } +} diff --git a/tests/Models/SpaceExplorerOid.php b/tests/Models/SpaceExplorerOid.php new file mode 100644 index 000000000..c3512e597 --- /dev/null +++ b/tests/Models/SpaceExplorerOid.php @@ -0,0 +1,23 @@ +belongsToMany(PlanetOid::class); + } +} diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index 368406feb..cf8f28218 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -14,9 +14,11 @@ use MongoDB\Laravel\Tests\Models\Item; use MongoDB\Laravel\Tests\Models\Label; use MongoDB\Laravel\Tests\Models\Photo; +use MongoDB\Laravel\Tests\Models\PlanetOid; use MongoDB\Laravel\Tests\Models\Role; use MongoDB\Laravel\Tests\Models\Skill; use MongoDB\Laravel\Tests\Models\Soft; +use MongoDB\Laravel\Tests\Models\SpaceExplorerOid; use MongoDB\Laravel\Tests\Models\User; class RelationsTest extends TestCase @@ -35,6 +37,8 @@ public function tearDown(): void Photo::truncate(); Label::truncate(); Skill::truncate(); + SpaceExplorerOid::truncate(); + PlanetOid::truncate(); } public function testHasMany(): void @@ -1273,4 +1277,104 @@ public function testWhereBelongsTo() $this->assertCount(3, $items); } + + public function testBelongsToManyOid(): void + { + $explorer = SpaceExplorerOid::create(['name' => 'John Doe']); + + // Add 2 explorer + $explorer->planetsVisited()->save(new PlanetOid(['name' => 'Mars'])); + $explorer->planetsVisited()->create(['name' => 'Jupiter']); + + // Refetch + $explorer = SpaceExplorerOid::with('planetsVisited')->find($explorer->_id); + $planet = PlanetOid::with('visitors')->first(); + + // Check for relation attributes + $this->assertArrayHasKey('space_explorer_oid_ids', $planet->getAttributes()); + $this->assertArrayHasKey('planet_oid_ids', $explorer->getAttributes()); + + $planets = $explorer->getRelation('planetsVisited'); + $explorers = $planet->getRelation('visitors'); + + $this->assertInstanceOf(Collection::class, $planets); + $this->assertInstanceOf(Collection::class, $explorers); + $this->assertInstanceOf(SpaceExplorerOid::class, $explorers[0]); + $this->assertInstanceOf(PlanetOid::class, $planets[0]); + $this->assertCount(2, $explorer->planetsVisited); + $this->assertCount(1, $planet->visitors); + + // Now create a new explorer to an existing planet + $explorer = $planet->visitors()->create(['name' => 'skaywalker']); + + $this->assertInstanceOf(Collection::class, $explorer->planetsVisited); + $this->assertInstanceOf(PlanetOid::class, $explorer->planetsVisited->first()); + $this->assertCount(1, $explorer->planetsVisited); + + // Get explorer and unattached planet + $explorer = SpaceExplorerOid::where('name', '=', 'skaywalker')->first(); + $planet = PlanetOid::where('name', '=', 'Jupiter')->first(); + + // Check the models are what they should be + $this->assertInstanceOf(PlanetOid::class, $planet); + $this->assertInstanceOf(SpaceExplorerOid::class, $explorer); + + // Assert they are not attached + $this->assertNotContains($planet->_id, $explorer->planet_oid_ids); + $this->assertNotContains($explorer->_id, $planet->space_explorer_oid_ids); + $this->assertCount(1, $explorer->planetsVisited); + $this->assertCount(1, $planet->visitors); + + // Attach the planet to the explorer + $explorer->planetsVisited()->attach($planet); + + // Get the new explorer model + $explorer = SpaceExplorerOid::where('name', '=', 'skaywalker')->first(); + $planet = PlanetOid::where('name', '=', 'Mars')->first(); + + // Assert they are attached + $this->assertNotContains($planet->_id, $explorer->planet_oid_ids); + $this->assertNotContains($explorer->_id, $planet->space_explorer_oid_ids); + $this->assertCount(2, $explorer->planetsVisited); + $this->assertCount(2, $planet->visitors); + + // Detach planets from explorer + $explorer->planetsVisited()->sync([]); + + // Get the new user model + $explorer = SpaceExplorerOid::where('name', '=', 'skaywalker')->first(); + $planet = PlanetOid::where('name', '=', 'Mars')->first(); + + // Assert they are attached + $this->assertNotContains($planet->_id, $explorer->planet_oid_ids); + $this->assertNotContains($explorer->_id, $planet->space_explorer_oid_ids); + $this->assertCount(0, $explorer->planetsVisited); + $this->assertCount(1, $planet->visitors); + } + + public function testBelongsToManySyncOid(): void + { + // create test instances + $explorer = SpaceExplorerOid::create(['name' => 'John Doe']); + $planet1 = PlanetOid::create(['name' => 'Mars']); + $planet2 = PlanetOid::create(['name' => 'Jupiter']); + + // Sync multiple + $explorer->planetsVisited()->sync([$planet1->_id, $planet2->_id]); + $this->assertCount(2, $explorer->planetsVisited); + + // Sync single wrapped by an array + $explorer->planetsVisited()->sync([$planet1->_id]); + $explorer->load('planetsVisited'); + + $this->assertCount(1, $explorer->planetsVisited); + self::assertTrue($explorer->planetsVisited->first()->is($planet1)); + + // Sync single model + $explorer->planetsVisited()->sync($planet2); + $explorer->load('planetsVisited'); + + $this->assertCount(1, $explorer->planetsVisited); + self::assertTrue($explorer->planetsVisited->first()->is($planet2)); + } }