diff --git a/src/Eloquent/HybridRelations.php b/src/Eloquent/HybridRelations.php index 9e11605a3..79b91aede 100644 --- a/src/Eloquent/HybridRelations.php +++ b/src/Eloquent/HybridRelations.php @@ -15,6 +15,8 @@ use MongoDB\Laravel\Relations\HasOne; use MongoDB\Laravel\Relations\MorphMany; use MongoDB\Laravel\Relations\MorphTo; +use MongoDB\Laravel\Relations\MorphToMany; + use function debug_backtrace; use function is_subclass_of; @@ -41,7 +43,7 @@ trait HybridRelations public function hasOne($related, $foreignKey = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (!is_subclass_of($related, MongoDBModel::class)) { return parent::hasOne($related, $foreignKey, $localKey); } @@ -70,7 +72,7 @@ public function hasOne($related, $foreignKey = null, $localKey = null) public function morphOne($related, $name, $type = null, $id = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (!is_subclass_of($related, MongoDBModel::class)) { return parent::morphOne($related, $name, $type, $id, $localKey); } @@ -97,7 +99,7 @@ public function morphOne($related, $name, $type = null, $id = null, $localKey = public function hasMany($related, $foreignKey = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (!is_subclass_of($related, MongoDBModel::class)) { return parent::hasMany($related, $foreignKey, $localKey); } @@ -126,7 +128,7 @@ public function hasMany($related, $foreignKey = null, $localKey = null) public function morphMany($related, $name, $type = null, $id = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (!is_subclass_of($related, MongoDBModel::class)) { return parent::morphMany($related, $name, $type, $id, $localKey); } @@ -166,7 +168,7 @@ public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relat } // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (!is_subclass_of($related, MongoDBModel::class)) { return parent::belongsTo($related, $foreignKey, $ownerKey, $relation); } @@ -278,7 +280,7 @@ public function belongsToMany( } // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (!is_subclass_of($related, MongoDBModel::class)) { return parent::belongsToMany( $related, $collection, @@ -323,6 +325,139 @@ public function belongsToMany( ); } + /** + * Define a many-to-many relationship. + * + * @param string $related + * @param string $collection + * @param string $foreignKey + * @param string $otherKey + * @param string $parentKey + * @param string $relatedKey + * @param string $relation + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + */ + public function morphToMany( + $related, + $name, + $table = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null, + $relation = null, + $inverse = false + ) { + + // Check if it is a relation with an original model. + if (!is_subclass_of($related, \Mongodb\Laravel\Eloquent\Model::class)) { + return parent::MorphToMany( + $related, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $inverse, + ); + } + + $caller = $this->guessBelongsToManyRelation(); + + $instance = new $related; + + $foreignPivotKey = $foreignPivotKey ?: $name . '_id'; + + $relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey() . 's'; + + // Now we're ready to create a new query builder for the related model and + // the relationship instances for this relation. This relation will set + // appropriate query constraints then entirely manage the hydrations. + if (!$table) { + $words = preg_split('/(_)/u', $name, -1, PREG_SPLIT_DELIM_CAPTURE); + + $lastWord = array_pop($words); + + $table = implode('', $words) . Str::plural($lastWord); + } + + return new MorphToMany( + $instance->newQuery(), + $this, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey ?: $this->getKeyName(), + $relatedKey ?: $instance->getKeyName(), + $caller, + $inverse, + ); + } + + /** + * Define a polymorphic, inverse many-to-many relationship. + * + * @param string $related + * @param string $name + * @param string|null $table + * @param string|null $foreignPivotKey + * @param string|null $relatedPivotKey + * @param string|null $parentKey + * @param string|null $relatedKey + * @param string|null $relation + * @param bool $inverse + * + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + */ + public function morphedByMany( + $related, + $name, + $table = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null, + $inverse = false + ) { + $caller = $this->guessBelongsToManyRelation(); + + // $instance = new $related; + // 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'; + + $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey() . 's'; + + return $this->morphToMany( + $related, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + null, + true, + ); + } + + /** + * Get the relationship name of the belongs to many. + * + * @return string + */ + protected function guessBelongsToManyRelation() + { + if (method_exists($this, 'getBelongsToManyCaller')) { + return $this->getBelongsToManyCaller(); + } + + return parent::guessBelongsToManyRelation(); + } + /** @inheritdoc */ public function newEloquentBuilder($query) { diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php new file mode 100644 index 000000000..8d559076c --- /dev/null +++ b/src/Relations/MorphToMany.php @@ -0,0 +1,342 @@ +getForeignKey(); + } + + /** @inheritdoc */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + return $query; + } + + /** @inheritdoc */ + protected function hydratePivotRelation(array $models) + { + // Do nothing. + } + + /** + * Set the select clause for the relation query. + * + * @return array + */ + protected function getSelectColumns(array $columns = ['*']) + { + return $columns; + } + + /** @inheritdoc */ + protected function shouldSelect(array $columns = ['*']) + { + return $columns; + } + + /** @inheritdoc */ + public function addConstraints() + { + if (static::$constraints) { + $this->setWhere(); + } + } + + /** + * Set the where clause for the relation query. + * + * @return $this + */ + protected function setWhere() + { + $foreign = $this->getForeignKey(); + + if ($this->getInverse()) { + $this->query->where($foreign, '=', $this->parent->getKey()); + } else { + $relatedModels = $this->parent->{$this->relatedPivotKey} ?? []; + $this->query->whereIn($this->relatedKey, $relatedModels); + } + + return $this; + } + + /** @inheritdoc */ + public function save(Model $model, array $joining = [], $touch = true) + { + $model->save(['touch' => false]); + + $this->attach($model, $joining, $touch); + + return $model; + } + + /** @inheritdoc */ + public function create(array $attributes = [], array $joining = [], $touch = true) + { + $instance = $this->related->newInstance($attributes); + + // Once we save the related model, we need to attach it to the base model via + // through intermediate table so we'll use the existing "attach" method to + // accomplish this which will insert the record and any more attributes. + $instance->save(['touch' => false]); + + $this->attach($instance, $joining, $touch); + + return $instance; + } + + /** @inheritdoc */ + public function sync($ids, $detaching = true) + { + $changes = [ + 'attached' => [], + 'detached' => [], + 'updated' => [], + ]; + + if ($ids instanceof Collection) { + $ids = $ids->modelKeys(); + } + + // 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 = $this->parent->{$this->relatedPivotKey} ?: []; + + // See issue #256. + if ($current instanceof Collection) { + $current = $ids->modelKeys(); + } + + $records = $this->formatSyncList($ids); + + $current = Arr::wrap($current); + + $detach = array_diff($current, array_keys($records)); + + // We need to make sure we pass a clean array, so that it is not interpreted + // as an associative array. + $detach = array_values($detach); + + // 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); + + $changes['detached'] = (array) array_map(function ($v) { + return is_numeric($v) ? (int) $v : (string) $v; + }, $detach); + } + + // 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), + ); + + if (count($changes['attached']) || count($changes['updated'])) { + $this->touchIfTouching(); + } + + return $changes; + } + + /** @inheritdoc */ + public function updateExistingPivot($id, array $attributes, $touch = true) + { + // Do nothing, we have no pivot table. + } + + /** @inheritdoc */ + public function attach($id, array $attributes = [], $touch = true) + { + if ($id instanceof Model) { + $model = $id; + + $id = $model->getKey(); + + // Attach the new parent id to the related model. + $model->push($this->table, [ + $this->foreignPivotKey => $this->parent->getKey(), + $this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null, + ], true); + } else { + if ($id instanceof Collection) { + $id = $id->modelKeys(); + } + + $query = $this->newRelatedQuery(); + + $query->whereIn($this->related->getKeyName(), (array) $id); + + // Attach the new parent id to the related model. + $query->push($this->table, [ + $this->foreignPivotKey => $this->parent->getKey(), + $this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null, + ], true); + } + + // Attach the new ids to the parent model. + $this->parent->push($this->relatedPivotKey, (array) $id, true); + + if ($touch) { + $this->touchIfTouching(); + } + } + + /** @inheritdoc */ + public function detach($ids = [], $touch = true) + { + if ($ids instanceof Model) { + $ids = (array) $ids->getKey(); + } + + $query = $this->newRelatedQuery(); + + // 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; + + // Detach all ids from the parent model. + $this->parent->pull($this->relatedPivotKey, $ids); + + // Prepare the query to select all related objects. + if (count($ids) > 0) { + $query->whereIn($this->related->getKeyName(), $ids); + } + + // Remove the relation to the parent. + // $query->pull($this->foreignPivotKey, $this->foreignPivotKey, $this->parent->getKey()); + $query->pull($this->table, [ + $this->foreignPivotKey => $this->parent->getKey(), + $this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null, + ]); + + if ($touch) { + $this->touchIfTouching(); + } + + return count($ids); + } + + /** @inheritdoc */ + protected function buildDictionary(Collection $results) + { + $foreign = $this->foreignPivotKey; + + // First we will build a dictionary of child models keyed by the foreign key + // of the relation so that we will easily and quickly match them to their + // parents without having a possibly slow inner loops for every models. + $dictionary = []; + + foreach ($results as $result) { + foreach ($result->$foreign as $item) { + $dictionary[$item][] = $result; + } + } + + return $dictionary; + } + + /** @inheritdoc */ + public function newPivotQuery() + { + return $this->newRelatedQuery(); + } + + /** + * Create a new query builder for the related model. + * + * @return \Illuminate\Database\Query\Builder + */ + public function newRelatedQuery() + { + return $this->related->newQuery(); + } + + /** + * Get the fully qualified foreign key for the relation. + * + * @return string + */ + public function getForeignKey() + { + return $this->foreignPivotKey; + } + + /** @inheritdoc */ + public function getQualifiedForeignPivotKeyName() + { + return $this->foreignPivotKey; + } + + /** @inheritdoc */ + public function getQualifiedRelatedPivotKeyName() + { + return $this->relatedPivotKey; + } + + /** + * Format the sync list so that it is keyed by ID. (Legacy Support) + * The original function has been renamed to formatRecordsList since Laravel 5.3. + * + * @deprecated + * + * @return array + */ + protected function formatSyncList(array $records) + { + $results = []; + foreach ($records as $id => $attributes) { + if (! is_array($attributes)) { + [$id, $attributes] = [$attributes, []]; + } + + $results[$id] = $attributes; + } + + return $results; + } + + /** + * Get the name of the "where in" method for eager loading. + * + * @param string $key + * + * @return string + */ + protected function whereInMethod(Model $model, $key) + { + return 'whereIn'; + } +} diff --git a/tests/Models/Client.php b/tests/Models/Client.php index 7ee8cec4a..1b7f823b4 100644 --- a/tests/Models/Client.php +++ b/tests/Models/Client.php @@ -29,4 +29,10 @@ public function addresses(): HasMany { return $this->hasMany(Address::class, 'data.client_id', 'data.client_id'); } + + // labels + public function labels() + { + return $this->morphToMany(Label::class, 'labelled'); + } } diff --git a/tests/Models/Label.php b/tests/Models/Label.php new file mode 100644 index 000000000..a1f796d4c --- /dev/null +++ b/tests/Models/Label.php @@ -0,0 +1,41 @@ +morphedByMany(User::class, 'labelled'); + } + + /** + * Get all of the videos that are assigned this tag. + */ + public function clients() + { + return $this->morphedByMany(Client::class, 'labelled'); + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php index 4e0d7294c..b2d826a92 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -38,12 +38,22 @@ class User extends Eloquent implements AuthenticatableContract, CanResetPassword use Notifiable; use MassPrunable; - protected $connection = 'mongodb'; - protected $casts = [ + protected $connection = 'mongodb'; + protected $casts = [ 'birthday' => 'datetime', 'entry.date' => 'datetime', 'member_status' => MemberStatus::class, ]; + + protected $fillable = [ + 'name', + 'email', + 'title', + 'age', + 'birthday', + 'username', + 'member_status', + ]; protected static $unguarded = true; public function books() @@ -123,4 +133,10 @@ public function prunable(): Builder { return $this->where('age', '>', 18); } + + // labels + public function labels() + { + return $this->morphToMany(Label::class, 'labelled'); + } } diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index b087ca481..c305238a3 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -11,6 +11,7 @@ use MongoDB\Laravel\Tests\Models\Client; use MongoDB\Laravel\Tests\Models\Group; use MongoDB\Laravel\Tests\Models\Item; +use MongoDB\Laravel\Tests\Models\Label; use MongoDB\Laravel\Tests\Models\Photo; use MongoDB\Laravel\Tests\Models\Role; use MongoDB\Laravel\Tests\Models\Soft; @@ -30,6 +31,7 @@ public function tearDown(): void Role::truncate(); Group::truncate(); Photo::truncate(); + Label::truncate(); } public function testHasMany(): void @@ -446,6 +448,53 @@ public function testMorph(): void $this->assertInstanceOf(Client::class, $photos[1]->hasImage); } + public function testMorphToMany(): void + { + + // create user + $user = User::updateOrCreate(['name' => 'John Doe']); + // create client + $client = Client::updateOrCreate(['name' => 'Jane Doe']); + // create label + $label = Label::updateOrCreate(['name' => 'My test label']); + $label2 = Label::updateOrCreate(['name' => 'My test label 2']); + + // check attach + + // attach label to models + $user->labels()->attach($label); + $client->labels()->attach($label); + + $this->assertEquals(1, $user->labels->count()); + $this->assertEquals($label->id, $user->labels->first()->id); + + $this->assertEquals(1, $client->labels->count()); + $this->assertEquals($label->id, $client->labels->first()->id); + + // check if label is attached to client + $this->assertEquals($label->id, $client->labels->first()->id); + + // check if label is attached to user + $this->assertEquals($label->id, $user->labels->first()->id); + + // check if client is attached to label + $this->assertEquals($client->id, $label->clients->first()->id); + + // check if user is attached to label + $this->assertEquals($user->id, $label->users->first()->id); + + // check detaching + $user->labels()->detach($label); + $this->assertNotContains($label->_id, $user->fresh()->labels->pluck('_id')->toArray()); + + // check if label still connected to client + $this->assertEquals($label->id, $client->fresh()->labels->first()->id); + + // check sync + $user->labels()->sync([$label->_id, $label2->_id]); + $this->assertCount(2, $user->fresh()->labels); + } + public function testHasManyHas(): void { $author1 = User::create(['name' => 'George R. R. Martin']);