diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 9f683fc5f2e0..e12ec14a2591 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -53,6 +53,13 @@ class Builder implements BuilderContract */ protected $model; + /** + * The attributes that should be added to new models created by this builder. + * + * @var array + */ + public $pendingAttributes = []; + /** * The relationships that should be eager loaded. * @@ -1622,6 +1629,8 @@ public function withOnly($relations) */ public function newModelInstance($attributes = []) { + $attributes = array_merge($this->pendingAttributes, $attributes); + return $this->model->newInstance($attributes)->setConnection( $this->query->getConnection()->getName() ); @@ -1782,6 +1791,30 @@ protected function addNestedWiths($name, $results) return $results; } + /** + * Specify attributes that should be added to any new models created by this builder. + * + * The given key / value pairs will also be added as where conditions to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|array|string $attributes + * @param mixed $value + * @return $this + */ + public function withAttributes(Expression|array|string $attributes, $value = null) + { + if (! is_array($attributes)) { + $attributes = [$attributes => $value]; + } + + foreach ($attributes as $column => $value) { + $this->where($this->qualifyColumn($column), $value); + } + + $this->pendingAttributes = array_merge($this->pendingAttributes, $attributes); + + return $this; + } + /** * Apply query-time casts to the model instance. * diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index 500dcc20842a..a51d4e16fc71 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -1351,6 +1351,8 @@ public function saveManyQuietly($models, array $pivotAttributes = []) */ public function create(array $attributes = [], array $joining = [], $touch = true) { + $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes); + $instance = $this->related->newInstance($attributes); // Once we save the related model, we need to attach it to the base model via diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 0ba60ccc9cf7..de94098dcfeb 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -447,6 +447,12 @@ protected function setForeignAttributesForCreate(Model $model) { $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); + foreach ($this->getQuery()->pendingAttributes as $key => $value) { + if (! $model->hasAttribute($key)) { + $model->setAttribute($key, $value); + } + } + $this->applyInverseRelationToModel($model); } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 3478f73859d4..44531957d5b7 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -96,6 +96,12 @@ protected function setForeignAttributesForCreate(Model $model) $model->{$this->getMorphType()} = $this->morphClass; + foreach ($this->getQuery()->pendingAttributes as $key => $value) { + if (! $model->hasAttribute($key)) { + $model->setAttribute($key, $value); + } + } + $this->applyInverseRelationToModel($model); } diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithAttributesTest.php new file mode 100755 index 000000000000..618616ed14ce --- /dev/null +++ b/tests/Database/DatabaseEloquentBelongsToManyWithAttributesTest.php @@ -0,0 +1,263 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + $this->createSchema(); + } + + public function testCreatesWithAttributesAndPivotValues(): void + { + $post = ManyToManyWithAttributesPost::create(); + $tag = $post->metaTags()->create(['name' => 'long article']); + + $this->assertSame('long article', $tag->name); + $this->assertTrue($tag->visible); + + $pivot = DB::table('with_attributes_pivot')->first(); + $this->assertSame('meta', $pivot->type); + $this->assertSame($post->id, $pivot->post_id); + $this->assertSame($tag->id, $pivot->tag_id); + } + + public function testQueriesWithAttributesAndPivotValues(): void + { + $post = new ManyToManyWithAttributesPost(['id' => 2]); + $wheres = $post->metaTags()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_tags.visible', + 'operator' => '=', + 'value' => true, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_pivot.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + } + + public function testMorphToManyWithAttributes(): void + { + $post = new ManyToManyWithAttributesPost(['id' => 2]); + $wheres = $post->morphedTags()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_tags.visible', + 'operator' => '=', + 'value' => true, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.taggable_type', + 'operator' => '=', + 'value' => ManyToManyWithAttributesPost::class, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.taggable_id', + 'operator' => '=', + 'value' => 2, + 'boolean' => 'and', + ], $wheres); + + $tag = $post->morphedTags()->create(['name' => 'new tag']); + + $this->assertTrue($tag->visible); + $this->assertSame('new tag', $tag->name); + $this->assertSame($tag->id, $post->morphedTags()->first()->id); + } + + public function testMorphedByManyWithAttributes(): void + { + $tag = new ManyToManyWithAttributesTag(['id' => 4]); + $wheres = $tag->morphedPosts()->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_posts.title', + 'operator' => '=', + 'value' => 'Title!', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.type', + 'operator' => '=', + 'value' => 'meta', + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.taggable_type', + 'operator' => '=', + 'value' => ManyToManyWithAttributesPost::class, + 'boolean' => 'and', + ], $wheres); + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_taggables.tag_id', + 'operator' => '=', + 'value' => 4, + 'boolean' => 'and', + ], $wheres); + + $post = $tag->morphedPosts()->create(); + $this->assertSame('Title!', $post->title); + $this->assertSame($post->id, $tag->morphedPosts()->first()->id); + } + + protected function createSchema() + { + $this->schema()->create('with_attributes_posts', function ($table) { + $table->increments('id'); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('with_attributes_tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->boolean('visible')->nullable(); + $table->timestamps(); + }); + + $this->schema()->create('with_attributes_pivot', function ($table) { + $table->integer('post_id'); + $table->integer('tag_id'); + $table->string('type'); + }); + + $this->schema()->create('with_attributes_taggables', function ($table) { + $table->integer('tag_id'); + $table->integer('taggable_id'); + $table->string('taggable_type'); + $table->string('type'); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('with_attributes_posts'); + $this->schema()->drop('with_attributes_tags'); + $this->schema()->drop('with_attributes_pivot'); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Model::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class ManyToManyWithAttributesPost extends Model +{ + protected $guarded = []; + protected $table = 'with_attributes_posts'; + + public function tags(): BelongsToMany + { + return $this->belongsToMany( + ManyToManyWithAttributesTag::class, + 'with_attributes_pivot', + 'tag_id', + 'post_id', + ); + } + + public function metaTags(): BelongsToMany + { + return $this->tags() + ->withAttributes('visible', true) + ->withPivotValue('type', 'meta'); + } + + public function morphedTags(): MorphToMany + { + return $this + ->morphToMany( + ManyToManyWithAttributesTag::class, + 'taggable', + 'with_attributes_taggables', + relatedPivotKey: 'tag_id' + ) + ->withAttributes('visible', true) + ->withPivotValue('type', 'meta'); + } +} + +class ManyToManyWithAttributesTag extends Model +{ + protected $guarded = []; + protected $table = 'with_attributes_tags'; + + public function morphedPosts(): MorphToMany + { + return $this + ->morphedByMany( + ManyToManyWithAttributesPost::class, + 'taggable', + 'with_attributes_taggables', + 'tag_id', + ) + ->withAttributes('title', 'Title!') + ->withPivotValue('type', 'meta'); + } +} diff --git a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php new file mode 100755 index 000000000000..80f1e677eb37 --- /dev/null +++ b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php @@ -0,0 +1,275 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + } + + public function testHasManyAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testHasOneAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasOne(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testMorphManyAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testMorphOneAddsAttributes(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphOne(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } + + public function testWithAttributesCanBeOverriden(): void + { + $key = 'a key'; + $defaultValue = 'a value'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $defaultValue]); + + $relatedModel = $relationship->make([$key => $value]); + + $this->assertSame($value, $relatedModel->$key); + } + + public function testQueryingDoesNotBreakWither(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->where($key, $value) + ->withAttributes([$key => $value]); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testAttributesCanBeAppended(): void + { + $parent = new RelatedWithAttributesModel; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes(['a' => 'A']) + ->withAttributes(['b' => 'B']) + ->withAttributes(['a' => 'AA']); + + $relatedModel = $relationship->make([ + 'b' => 'BB', + 'c' => 'C', + ]); + + $this->assertSame('AA', $relatedModel->a); + $this->assertSame('BB', $relatedModel->b); + $this->assertSame('C', $relatedModel->c); + } + + public function testSingleAttributeApi(): void + { + $parent = new RelatedWithAttributesModel; + $key = 'attr'; + $value = 'Value'; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes($key, $value); + + $relatedModel = $relationship->make(); + + $this->assertSame($value, $relatedModel->$key); + } + + public function testWheresAreSet(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]); + + $wheres = $relationship->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'related_with_attributes_models.'.$key, + 'operator' => '=', + 'value' => $value, + 'boolean' => 'and', + ], $wheres); + + // Ensure this doesn't break the default where either. + $this->assertContains([ + 'type' => 'Basic', + 'column' => $parent->qualifyColumn('parent_id'), + 'operator' => '=', + 'value' => $parentId, + 'boolean' => 'and', + ], $wheres); + } + + public function testNullValueIsAccepted(): void + { + $parentId = 123; + $key = 'a key'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => null]); + + $wheres = $relationship->toBase()->wheres; + $relatedModel = $relationship->make(); + + $this->assertNull($relatedModel->$key); + + $this->assertContains([ + 'type' => 'Null', + 'column' => 'related_with_attributes_models.'.$key, + 'boolean' => 'and', + ], $wheres); + } + + public function testOneKeepsAttributesFromHasMany(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->hasMany(RelatedWithAttributesModel::class, 'parent_id') + ->withAttributes([$key => $value]) + ->one(); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->parent_id); + $this->assertSame($value, $relatedModel->$key); + } + + public function testOneKeepsAttributesFromMorphMany(): void + { + $parentId = 123; + $key = 'a key'; + $value = 'the value'; + + $parent = new RelatedWithAttributesModel; + $parent->id = $parentId; + + $relationship = $parent + ->morphMany(RelatedWithAttributesModel::class, 'relatable') + ->withAttributes([$key => $value]) + ->one(); + + $relatedModel = $relationship->make(); + + $this->assertSame($parentId, $relatedModel->relatable_id); + $this->assertSame($parent::class, $relatedModel->relatable_type); + $this->assertSame($value, $relatedModel->$key); + } +} + +class RelatedWithAttributesModel extends Model +{ + protected $guarded = []; +} diff --git a/tests/Database/DatabaseEloquentWithAttributesTest.php b/tests/Database/DatabaseEloquentWithAttributesTest.php new file mode 100755 index 000000000000..85b11d7991f3 --- /dev/null +++ b/tests/Database/DatabaseEloquentWithAttributesTest.php @@ -0,0 +1,59 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + } + + public function testAddsAttributes(): void + { + $key = 'a key'; + $value = 'the value'; + + $query = WithAttributesModel::query() + ->withAttributes([$key => $value]); + + $model = $query->make(); + + $this->assertSame($value, $model->$key); + } + + public function testAddsWheres(): void + { + $key = 'a key'; + $value = 'the value'; + + $query = WithAttributesModel::query() + ->withAttributes([$key => $value]); + + $wheres = $query->toBase()->wheres; + + $this->assertContains([ + 'type' => 'Basic', + 'column' => 'with_attributes_models.'.$key, + 'operator' => '=', + 'value' => $value, + 'boolean' => 'and', + ], $wheres); + } +} + +class WithAttributesModel extends Model +{ + protected $guarded = []; +}