Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bug fix] Fix BelongToMany when using ObjectId in relation #3014

Open
wants to merge 10 commits into
base: 5.x
Choose a base branch
from
48 changes: 48 additions & 0 deletions src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
176 changes: 155 additions & 21 deletions src/Relations/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' => [],
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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]);
Expand All @@ -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);
}

Expand All @@ -229,14 +348,19 @@ 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) {
$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);
}

Expand All @@ -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);
Expand All @@ -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;
}
}
Expand Down
23 changes: 23 additions & 0 deletions tests/Models/PlanetOid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace MongoDB\Laravel\Tests\Models;

use MongoDB\Laravel\Eloquent\Model as Eloquent;

class PlanetOid extends Eloquent
{
protected $connection = 'mongodb';
protected static $unguarded = true;

public function getIdAttribute($value = null)
{
return $value;
}

public function visitors()
{
return $this->belongsToMany(SpaceExplorerOid::class);
}
}
23 changes: 23 additions & 0 deletions tests/Models/SpaceExplorerOid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace MongoDB\Laravel\Tests\Models;

use MongoDB\Laravel\Eloquent\Model as Eloquent;

class SpaceExplorerOid extends Eloquent
{
protected $connection = 'mongodb';
protected static $unguarded = true;

public function getIdAttribute($value = null)
{
return $value;
}

public function planetsVisited()
{
return $this->belongsToMany(PlanetOid::class);
}
}
Loading