diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index bfc913094d8e..bb103a56850a 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -127,6 +127,13 @@ class Builder implements BuilderContract */ protected $removedScopes = []; + /** + * The models that the queried models will be related to via a foreign key. + * + * @var array + */ + protected $for = []; + /** * Create a new Eloquent query builder instance. * @@ -146,7 +153,7 @@ public function __construct(QueryBuilder $query) */ public function make(array $attributes = []) { - return $this->newModelInstance($attributes); + return $this->newModelInstance($this->mergeForeignKeys($attributes)); } /** @@ -530,11 +537,11 @@ public function findOr($id, $columns = ['*'], Closure $callback = null) */ public function firstOrNew(array $attributes = [], array $values = []) { - if (! is_null($instance = $this->where($attributes)->first())) { + if (! is_null($instance = $this->where($this->mergeForeignKeys($attributes))->first())) { return $instance; } - return $this->newModelInstance(array_merge($attributes, $values)); + return $this->newModelInstance($this->mergeForeignKeys(array_merge($attributes, $values))); } /** @@ -546,11 +553,11 @@ public function firstOrNew(array $attributes = [], array $values = []) */ public function firstOrCreate(array $attributes = [], array $values = []) { - if (! is_null($instance = $this->where($attributes)->first())) { + if (! is_null($instance = $this->where($this->mergeForeignKeys($attributes))->first())) { return $instance; } - return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) { + return tap($this->newModelInstance($this->mergeForeignKeys(array_merge($attributes, $values))), function ($instance) { $instance->save(); }); } @@ -565,7 +572,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) public function updateOrCreate(array $attributes, array $values = []) { return tap($this->firstOrNew($attributes), function ($instance) use ($values) { - $instance->fill($values)->save(); + $instance->fill($this->mergeForeignKeys($values))->save(); }); } @@ -970,7 +977,7 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) */ public function create(array $attributes = []) { - return tap($this->newModelInstance($attributes), function ($instance) { + return tap($this->newModelInstance($this->mergeForeignKeys($attributes)), function ($instance) { $instance->save(); }); } @@ -984,7 +991,7 @@ public function create(array $attributes = []) public function forceCreate(array $attributes) { return $this->model->unguarded(function () use ($attributes) { - return $this->newModelInstance()->create($attributes); + return $this->newModelInstance()->create($this->mergeForeignKeys($attributes)); }); } @@ -996,7 +1003,7 @@ public function forceCreate(array $attributes) */ public function update(array $values) { - return $this->toBase()->update($this->addUpdatedAtColumn($values)); + return $this->toBase()->update($this->addUpdatedAtColumn($this->mergeForeignKeys($values))); } /** @@ -1140,6 +1147,21 @@ protected function addUpdatedAtToUpsertColumns(array $update) return $update; } + /** + * Merge the attributes that will be used in the query with the specific foreign keys. + * + * @param array $attributes + * @return array + */ + protected function mergeForeignKeys($attributes) + { + foreach ($this->for as $model) { + $attributes[$model['foreignKey']] = $model['model']->getKey(); + } + + return $attributes; + } + /** * Delete records from the database. * @@ -1409,6 +1431,27 @@ public function withOnly($relations) return $this->with($relations); } + /** + * Set the foreign key for a relationship to another model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string|null $relationship + * @return $this + */ + public function for($model, $relationship = null) + { + $relationship ??= Str::camel(class_basename($model)); + + $foreignKey = $this->model->{$relationship}()->getForeignKeyName(); + + $this->for[] = [ + 'model' => $model, + 'foreignKey' => $foreignKey, + ]; + + return $this; + } + /** * Create a new instance of the model being queried. * diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 52ad4051d689..76aa0aa72852 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -453,6 +453,24 @@ public function forceFill(array $attributes) }); } + /** + * Set the foreign key for a relationship to another model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string|null $relationship + * @return $this + */ + public function forModel($model, $relationship = null) + { + $relationship ??= Str::camel(class_basename($model)); + + $foreignKey = $this->{$relationship}()->getForeignKeyName(); + + $this->fill([$foreignKey => $model->getKey()]); + + return $this; + } + /** * Qualify the given column name by the model's table. * @@ -2156,6 +2174,10 @@ public function __call($method, $parameters) return $this->$method(...$parameters); } + if ($method === 'for') { + return $this->forModel(...$parameters); + } + if ($resolver = (static::$relationResolvers[get_class($this)][$method] ?? null)) { return $resolver($this); } @@ -2172,6 +2194,12 @@ public function __call($method, $parameters) */ public static function __callStatic($method, $parameters) { + if ($method === 'for') { + $model = new static; + + return $model->forwardCallTo($model->newQuery(), $method, $parameters); + } + return (new static)->$method(...$parameters); } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 2d42f88f0ed2..77a6d2803af8 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Support\Str; abstract class HasOneOrMany extends Relation { @@ -25,6 +26,13 @@ abstract class HasOneOrMany extends Relation */ protected $localKey; + /** + * The models that the queried models will be related to via a foreign key. + * + * @var array + */ + protected $for = []; + /** * Create a new has one or many relationship instance. * @@ -50,7 +58,7 @@ public function __construct(Builder $query, Model $parent, $foreignKey, $localKe */ public function make(array $attributes = []) { - return tap($this->related->newInstance($attributes), function ($instance) { + return tap($this->related->newInstance($this->mergeForeignKeys($attributes)), function ($instance) { $this->setForeignAttributesForCreate($instance); }); } @@ -186,6 +194,21 @@ protected function buildDictionary(Collection $results) })->all(); } + /** + * Merge the attributes that will be used in the query with the specific foreign keys. + * + * @param array $attributes + * @return array + */ + protected function mergeForeignKeys($attributes) + { + foreach ($this->for as $model) { + $attributes[$model['foreignKey']] = $model['model']->getKey(); + } + + return $attributes; + } + /** * Find a model by its primary key or return a new instance of the related model. * @@ -213,8 +236,8 @@ public function findOrNew($id, $columns = ['*']) */ public function firstOrNew(array $attributes = [], array $values = []) { - if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->related->newInstance(array_merge($attributes, $values)); + if (is_null($instance = $this->where($this->mergeForeignKeys($attributes))->first())) { + $instance = $this->related->newInstance($this->mergeForeignKeys(array_merge($attributes, $values))); $this->setForeignAttributesForCreate($instance); } @@ -231,7 +254,7 @@ public function firstOrNew(array $attributes = [], array $values = []) */ public function firstOrCreate(array $attributes = [], array $values = []) { - if (is_null($instance = $this->where($attributes)->first())) { + if (is_null($instance = $this->where($this->mergeForeignKeys($attributes))->first())) { $instance = $this->create(array_merge($attributes, $values)); } @@ -295,6 +318,27 @@ public function saveMany($models) return $models; } + /** + * Set the foreign key for a relationship to another model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string|null $relationship + * @return $this + */ + public function for($model, $relationship = null) + { + $relationship ??= Str::camel(class_basename($model)); + + $foreignKey = $this->getModel()->{$relationship}()->getForeignKeyName(); + + $this->for[] = [ + 'model' => $model, + 'foreignKey' => $foreignKey, + ]; + + return $this; + } + /** * Create a new instance of the related model. * @@ -303,7 +347,7 @@ public function saveMany($models) */ public function create(array $attributes = []) { - return tap($this->related->newInstance($attributes), function ($instance) { + return tap($this->related->newInstance($this->mergeForeignKeys($attributes)), function ($instance) { $this->setForeignAttributesForCreate($instance); $instance->save(); @@ -320,7 +364,7 @@ public function forceCreate(array $attributes = []) { $attributes[$this->getForeignKeyName()] = $this->getParentKey(); - return $this->related->forceCreate($attributes); + return $this->related->forceCreate($this->mergeForeignKeys($attributes)); } /** diff --git a/tests/Integration/Database/EloquentForTest.php b/tests/Integration/Database/EloquentForTest.php new file mode 100644 index 000000000000..34b1e8c88e19 --- /dev/null +++ b/tests/Integration/Database/EloquentForTest.php @@ -0,0 +1,579 @@ +increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('title'); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->integer('user_id'); + $table->integer('post_id'); + $table->string('content'); + $table->timestamps(); + }); + } + + public function testForCanBeUsedOnBuilderCreate() + { + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comment = Comment::for($user) + ->for($post, 'blogPost') + ->create([ + 'content' => 'hello', + ]) + ->fresh(); + + $this->assertSame('hello', $comment->content); + + $this->assertSame($user->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($post->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnBuilderMake() + { + $user = User::query()->create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comment = Comment::query() + ->for($user) + ->for($post, 'blogPost') + ->make([ + 'content' => 'hello', + ]); + + $this->assertSame('hello', $comment->content); + + $this->assertSame($user->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($post->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnFirstOrNewAndIsNotAppliedIfTheModelAlreadyExists() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + + $existingComment = Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = Comment::query() + ->for($user) + ->firstOrNew([ + 'user_id' => $anotherUser->id, // This will be overridden by the $user in for() + ], [ + 'value' => 'Goodbye', + ]); + + $this->assertSame($existingComment->id, $comment->id); + $this->assertSame($user->id, $comment->user_id); + } + + public function testForCanBeUsedOnFirstOrNewAndIsAppliedIfTheModelDoesNotAlreadyExist() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + + $existingComment = Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = Comment::query() + ->for($anotherUser) + ->firstOrNew([ + 'user_id' => 123, + ], [ + 'value' => 'Goodbye', + ]); + + $this->assertNull($comment->id); + $this->assertSame($anotherUser->id, $comment->user_id); + } + + public function testForCanBeUsedOnFirstOrCreateIfTheModelAlreadyExists() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + $anotherPost = Post::create(['title' => 'Another title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = Comment::query() + ->for($user) + ->for($post, 'blogPost') + ->firstOrCreate([ + 'user_id' => $anotherUser->id, // This will be overridden by the $user in the for() + ], [ + 'content' => 'Goodbye', + ]); + + $this->assertSame($user->id, $comment->user_id); + $this->assertSame($post->id, $comment->post_id); + } + + public function testForCanBeUsedOnFirstOrCreateIfTheModelDoesNotAlreadyExist() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + $anotherPost = Post::create(['title' => 'Another title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = Comment::query() + ->for($anotherUser) + ->for($anotherPost, 'blogPost') + ->firstOrCreate([ + 'user_id' => 123, // This will be overridden by the $anotherUser in the for() + ], [ + 'content' => 'Goodbye', + ]); + + $this->assertSame($anotherUser->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($anotherPost->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnBuilderUpdate() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + $anotherPost = Post::create(['title' => 'Another title']); + + $commentOne = Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello1', + ]); + + $commentTwo = Comment::create([ + 'user_id' => $user->id, + 'post_id' => $anotherPost->id, + 'content' => 'Hello2', + ]); + + Comment::query() + ->for($anotherUser) + ->update([ + 'content' => 'Hello3', + ]); + + $commentOne->refresh(); + $commentTwo->refresh(); + + $this->assertSame($anotherUser->id, $commentOne->user_id); + $this->assertSame('Hello3', $commentOne->content); + + $this->assertSame($anotherUser->id, $commentTwo->user_id); + $this->assertSame('Hello3', $commentTwo->content); + } + + public function testForCanBeUsedOnUpdateOrCreate() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + $anotherPost = Post::create(['title' => 'Another title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = Comment::query() + ->for($anotherUser) + ->for($anotherPost, 'blogPost') + ->updateOrCreate([ + 'user_id' => 123, + ], [ + 'content' => 'Goodbye', + ]); + + $this->assertSame($anotherUser->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($anotherPost->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnForceCreate() + { + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comment = Comment::for($user) + ->for($post, 'blogPost') + ->forceCreate([ + 'content' => 'hello', + ]) + ->fresh(); + + $this->assertSame('hello', $comment->content); + + $this->assertSame($user->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($post->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnModelUpdate() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + $anotherPost = Post::create(['title' => 'Another title']); + + $comment = Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + // Make sure this comment isn't updated accidentally. + $anotherComment = Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello123', + ]); + + $comment->for($anotherUser) + ->for($anotherPost, 'blogPost') + ->update([ + 'content' => 'goodbye', + ]); + + $comment->refresh(); + $anotherComment->refresh(); + + $this->assertSame('goodbye', $comment->content); + + $this->assertSame($anotherUser->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($anotherPost->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + + $this->assertSame('Hello123', $anotherComment->content); + $this->assertSame($user->id, $anotherComment->user_id); + $this->assertSame($post->id, $anotherComment->post_id); + } + + public function testForCanBeUsedOnRelationshipCreate() + { + /** @var User $user */ + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comment = $user->comments() + ->for($post, 'blogPost') + ->create([ + 'content' => 'hello', + ]); + + $this->assertSame('hello', $comment->content); + + $this->assertSame($user->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($post->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnRelationshipMake() + { + /** @var User $user */ + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comment = $user->comments() + ->for($post, 'blogPost') + ->make([ + 'content' => 'hello', + ]); + + $this->assertSame('hello', $comment->content); + + $this->assertSame($user->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($post->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnRelationshipMakeMany() + { + /** @var User $user */ + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comments = $user->comments() + ->for($post, 'blogPost') + ->makeMany([ + ['content' => 'hello'], + ['content' => 'second'], + ]); + + $this->assertSame('hello', $comments[0]->content); + + $this->assertSame($user->id, $comments[0]->user_id); + $this->assertInstanceOf(User::class, $comments[0]->user); + + $this->assertSame($post->id, $comments[0]->post_id); + $this->assertInstanceOf(Post::class, $comments[0]->blogPost); + + $this->assertSame('second', $comments[1]->content); + + $this->assertSame($user->id, $comments[1]->user_id); + $this->assertInstanceOf(User::class, $comments[1]->user); + + $this->assertSame($post->id, $comments[1]->post_id); + $this->assertInstanceOf(Post::class, $comments[1]->blogPost); + } + + public function testForCanBeUsedOnRelationshipFirstOrNewIfTheModelExists() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = $post->comments() + ->for($anotherUser) + ->firstOrNew([ + 'user_id' => $user->id, // This will be overridden by the $anotherUser in the for() + ], [ + 'content' => 'hello', + ]); + + $this->assertSame($anotherUser->id, $comment->user_id); + } + + public function testForCanBeUsedOnRelationshipsFirstOrNewIfTheModelDoesNotExist() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = $post->comments() + ->for($anotherUser) + ->firstOrNew([ + 'user_id' => 123, + ], [ + 'content' => 'hello', + ]); + + $this->assertSame($anotherUser->id, $comment->user_id); + } + + public function testForCanBeUsedOnRelationshipFirstOrCreateIfTheModelExists() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = $post->comments() + ->for($user) + ->firstOrCreate([ + 'user_id' => $anotherUser->id, // This will be overridden by the $user in the for() + ], [ + 'content' => 'hello', + ]); + + $this->assertSame($user->id, $comment->user_id); + } + + public function testForCanBeUsedOnRelationshipFirstOrCreateIfTheModelDoesNotExist() + { + $user = User::create(['name' => 'My name']); + $anotherUser = User::create(['name' => 'Another name']); + $post = Post::create(['title' => 'My title']); + + Comment::create([ + 'user_id' => $user->id, + 'post_id' => $post->id, + 'content' => 'Hello', + ]); + + $comment = $post->comments() + ->for($anotherUser) + ->firstOrCreate([ + 'user_id' => $user->id, // This will be overridden by the $anotherUser in the for() + ], [ + 'content' => 'hello', + ]); + + $this->assertSame($anotherUser->id, $comment->user_id); + } + + public function testForCanBeUsedOnRelationshipForceCreate() + { + /** @var User $user */ + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comment = $user->comments() + ->for($post, 'blogPost') + ->forceCreate([ + 'content' => 'hello', + ]); + + $this->assertSame('hello', $comment->content); + + $this->assertSame($user->id, $comment->user_id); + $this->assertInstanceOf(User::class, $comment->user); + + $this->assertSame($post->id, $comment->post_id); + $this->assertInstanceOf(Post::class, $comment->blogPost); + } + + public function testForCanBeUsedOnRelationshipCreateMany() + { + /** @var User $user */ + $user = User::create(['name' => 'My name']); + $post = Post::create(['title' => 'My title']); + + $comments = $user->comments() + ->for($post, 'blogPost') + ->createMany([ + ['content' => 'hello'], + ['content' => 'second'], + ]); + + $this->assertSame('hello', $comments[0]->content); + + $this->assertSame($user->id, $comments[0]->user_id); + $this->assertInstanceOf(User::class, $comments[0]->user); + + $this->assertSame($post->id, $comments[0]->post_id); + $this->assertInstanceOf(Post::class, $comments[0]->blogPost); + + $this->assertSame('second', $comments[1]->content); + + $this->assertSame($user->id, $comments[1]->user_id); + $this->assertInstanceOf(User::class, $comments[1]->user); + + $this->assertSame($post->id, $comments[1]->post_id); + $this->assertInstanceOf(Post::class, $comments[1]->blogPost); + } +} + +class User extends Model +{ + public $table = 'users'; + + protected $guarded = []; + + protected $casts = [ + 'post_id' => 'integer', + 'comment_id' => 'integer', + ]; + + public function posts() + { + return $this->hasMany(Post::class); + } + + public function comments() + { + return $this->hasMany(Comment::class); + } +} + +class Post extends Model +{ + public $table = 'posts'; + + protected $guarded = []; + + public function comments() + { + return $this->hasMany(Comment::class); + } +} + +class Comment extends Model +{ + use HasFactory; + + protected $guarded = []; + + protected $casts = [ + 'post_id' => 'integer', + 'user_id' => 'integer', + ]; + + public function blogPost(): BelongsTo + { + return $this->belongsTo(Post::class, 'post_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +}