From b52d3143c6b4b5eacbb21a5c83873fd1d43289e9 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 14 Jan 2018 10:17:03 -0600 Subject: [PATCH] Fix pivot serialization (#22786) Currently, passing a custom pivot model to a queued job will cause errors when pulling the job back off the queue. This correct the storage of pivot model and morphed pivot model queueable IDs and also adjusts the restoration queries to use the new format. --- .../Database/Eloquent/Collection.php | 9 +- .../Eloquent/Relations/MorphPivot.php | 71 +++++++ .../Eloquent/Relations/MorphToMany.php | 16 ++ .../Database/Eloquent/Relations/Pivot.php | 67 ++++++ .../EloquentPivotSerializationTest.php | 194 ++++++++++++++++++ 5 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Database/EloquentPivotSerializationTest.php diff --git a/src/Illuminate/Database/Eloquent/Collection.php b/src/Illuminate/Database/Eloquent/Collection.php index 26d62b273fee..c9fd5868e9fa 100755 --- a/src/Illuminate/Database/Eloquent/Collection.php +++ b/src/Illuminate/Database/Eloquent/Collection.php @@ -4,6 +4,7 @@ use LogicException; use Illuminate\Support\Arr; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Contracts\Queue\QueueableCollection; use Illuminate\Support\Collection as BaseCollection; @@ -407,7 +408,13 @@ public function getQueueableClass() */ public function getQueueableIds() { - return $this->modelKeys(); + if ($this->isEmpty()) { + return []; + } + + return $this->first() instanceof Pivot + ? $this->map->getQueueableId()->all() + : $this->modelKeys(); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php b/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php index b7a2f34195b8..a8a9210f0e45 100644 --- a/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphPivot.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Builder; class MorphPivot extends Pivot @@ -76,4 +77,74 @@ public function setMorphClass($morphClass) return $this; } + + /** + * Get the queueable identity for the entity. + * + * @return mixed + */ + public function getQueueableId() + { + if (isset($this->attributes[$this->getKeyName()])) { + return $this->getKey(); + } + + return sprintf( + '%s:%s:%s:%s:%s:%s', + $this->foreignKey, $this->getAttribute($this->foreignKey), + $this->relatedKey, $this->getAttribute($this->relatedKey), + $this->morphType, $this->morphClass + ); + } + + /** + * Get a new query to restore one or more models by their queueable IDs. + * + * @param array|int $ids + * @return \Illuminate\Database\Eloquent\Builder + */ + public function newQueryForRestoration($ids) + { + if (is_array($ids)) { + return $this->newQueryForCollectionRestoration($ids); + } + + if (! Str::contains($ids, ':')) { + return parent::newQueryForRestoration($ids); + } + + $segments = explode(':', $ids); + + return $this->newQueryWithoutScopes() + ->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]) + ->where($segments[4], $segments[5]); + } + + /** + * Get a new query to restore multiple models by their queueable IDs. + * + * @param array|int $ids + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function newQueryForCollectionRestoration(array $ids) + { + if (! Str::contains($ids[0], ':')) { + return parent::newQueryForRestoration($ids); + } + + $query = $this->newQueryWithoutScopes(); + + foreach ($ids as $id) { + $segments = explode(':', $id); + + $query->orWhere(function ($query) use ($segments) { + return $query->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]) + ->where($segments[4], $segments[5]); + }); + } + + return $query; + } } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php index 15f5cfd028c4..da6658513af9 100644 --- a/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphToMany.php @@ -146,6 +146,22 @@ public function newPivot(array $attributes = [], $exists = false) return $pivot; } + /** + * Get the pivot columns for the relation. + * + * "pivot_" is prefixed ot each column for easy removal later. + * + * @return array + */ + protected function aliasedPivotColumns() + { + $defaults = [$this->foreignPivotKey, $this->relatedPivotKey, $this->morphType]; + + return collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { + return $this->table.'.'.$column.' as pivot_'.$column; + })->unique()->all(); + } + /** * Get the foreign key "type" name. * diff --git a/src/Illuminate/Database/Eloquent/Relations/Pivot.php b/src/Illuminate/Database/Eloquent/Relations/Pivot.php index e80aa911d567..c25bb011f3b9 100755 --- a/src/Illuminate/Database/Eloquent/Relations/Pivot.php +++ b/src/Illuminate/Database/Eloquent/Relations/Pivot.php @@ -222,4 +222,71 @@ public function getUpdatedAtColumn() { return $this->pivotParent->getUpdatedAtColumn(); } + + /** + * Get the queueable identity for the entity. + * + * @return mixed + */ + public function getQueueableId() + { + if (isset($this->attributes[$this->getKeyName()])) { + return $this->getKey(); + } + + return sprintf( + '%s:%s:%s:%s', + $this->foreignKey, $this->getAttribute($this->foreignKey), + $this->relatedKey, $this->getAttribute($this->relatedKey) + ); + } + + /** + * Get a new query to restore one or more models by their queueable IDs. + * + * @param array|int $ids + * @return \Illuminate\Database\Eloquent\Builder + */ + public function newQueryForRestoration($ids) + { + if (is_array($ids)) { + return $this->newQueryForCollectionRestoration($ids); + } + + if (! Str::contains($ids, ':')) { + return parent::newQueryForRestoration($ids); + } + + $segments = explode(':', $ids); + + return $this->newQueryWithoutScopes() + ->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]); + } + + /** + * Get a new query to restore multiple models by their queueable IDs. + * + * @param array|int $ids + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function newQueryForCollectionRestoration(array $ids) + { + if (! Str::contains($ids[0], ':')) { + return parent::newQueryForRestoration($ids); + } + + $query = $this->newQueryWithoutScopes(); + + foreach ($ids as $id) { + $segments = explode(':', $id); + + $query->orWhere(function ($query) use ($segments) { + return $query->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]); + }); + } + + return $query; + } } diff --git a/tests/Integration/Database/EloquentPivotSerializationTest.php b/tests/Integration/Database/EloquentPivotSerializationTest.php new file mode 100644 index 000000000000..6092be09b89b --- /dev/null +++ b/tests/Integration/Database/EloquentPivotSerializationTest.php @@ -0,0 +1,194 @@ +increments('id'); + $table->string('email'); + $table->timestamps(); + }); + + Schema::create('projects', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('project_users', function ($table) { + $table->integer('user_id'); + $table->integer('project_id'); + }); + + Schema::create('tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('taggables', function ($table) { + $table->integer('tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + }); + } + + public function test_pivot_can_be_serialized_and_restored() + { + $user = PivotSerializationTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + $project->collaborators()->attach($user); + + $project = $project->fresh(); + + $class = new PivotSerializationTestClass($project->collaborators->first()->pivot); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->collaborators->first()->pivot->user_id, $class->pivot->user_id); + $this->assertEquals($project->collaborators->first()->pivot->project_id, $class->pivot->project_id); + + $class->pivot->save(); + } + + public function test_morph_pivot_can_be_serialized_and_restored() + { + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + $tag = PivotSerializationTestTag::forceCreate(['name' => 'Test Tag']); + $project->tags()->attach($tag); + + $project = $project->fresh(); + + $class = new PivotSerializationTestClass($project->tags->first()->pivot); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->tags->first()->pivot->tag_id, $class->pivot->tag_id); + $this->assertEquals($project->tags->first()->pivot->taggable_id, $class->pivot->taggable_id); + $this->assertEquals($project->tags->first()->pivot->taggable_type, $class->pivot->taggable_type); + + $class->pivot->save(); + } + + public function test_collection_of_pivots_can_be_serialized_and_restored() + { + $user = PivotSerializationTestUser::forceCreate(['email' => 'taylor@laravel.com']); + $user2 = PivotSerializationTestUser::forceCreate(['email' => 'mohamed@laravel.com']); + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + + $project->collaborators()->attach($user); + $project->collaborators()->attach($user2); + + $project = $project->fresh(); + + $class = new PivotSerializationTestCollectionClass(DatabaseCollection::make($project->collaborators->map->pivot)); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->collaborators[0]->pivot->user_id, $class->pivots[0]->user_id); + $this->assertEquals($project->collaborators[1]->pivot->project_id, $class->pivots[1]->project_id); + } + + public function test_collection_of_morph_pivots_can_be_serialized_and_restored() + { + $tag = PivotSerializationTestTag::forceCreate(['name' => 'Test Tag 1']); + $tag2 = PivotSerializationTestTag::forceCreate(['name' => 'Test Tag 2']); + $project = PivotSerializationTestProject::forceCreate(['name' => 'Test Project']); + + $project->tags()->attach($tag); + $project->tags()->attach($tag2); + + $project = $project->fresh(); + + $class = new PivotSerializationTestCollectionClass(DatabaseCollection::make($project->tags->map->pivot)); + $class = unserialize(serialize($class)); + + $this->assertEquals($project->tags[0]->pivot->tag_id, $class->pivots[0]->tag_id); + $this->assertEquals($project->tags[0]->pivot->taggable_id, $class->pivots[0]->taggable_id); + $this->assertEquals($project->tags[0]->pivot->taggable_type, $class->pivots[0]->taggable_type); + + $this->assertEquals($project->tags[1]->pivot->tag_id, $class->pivots[1]->tag_id); + $this->assertEquals($project->tags[1]->pivot->taggable_id, $class->pivots[1]->taggable_id); + $this->assertEquals($project->tags[1]->pivot->taggable_type, $class->pivots[1]->taggable_type); + } +} + +class PivotSerializationTestClass +{ + use SerializesModels; + + public $pivot; + + public function __construct($pivot) + { + $this->pivot = $pivot; + } +} + +class PivotSerializationTestCollectionClass +{ + use SerializesModels; + + public $pivots; + + public function __construct($pivots) + { + $this->pivots = $pivots; + } +} + +class PivotSerializationTestUser extends Model +{ + public $table = 'users'; +} + +class PivotSerializationTestProject extends Model +{ + public $table = 'projects'; + + public function collaborators() + { + return $this->belongsToMany( + PivotSerializationTestUser::class, 'project_users', 'project_id', 'user_id' + )->using(PivotSerializationTestCollaborator::class); + } + + public function tags() + { + return $this->morphToMany(PivotSerializationTestTag::class, 'taggable', 'taggables', 'taggable_id', 'tag_id') + ->using(PivotSerializationTestTagAttachment::class); + } +} + +class PivotSerializationTestTag extends Model +{ + public $table = 'tags'; + + public function projects() + { + return $this->morphedByMany(PivotSerializationTestProject::class, 'taggable', 'taggables', 'tag_id', 'taggable_id') + ->using(PivotSerializationTestTagAttachment::class); + } +} + +class PivotSerializationTestCollaborator extends Pivot +{ + public $table = 'project_users'; +} + +class PivotSerializationTestTagAttachment extends MorphPivot +{ + public $table = 'taggables'; +}