From 58999dfb09f7fe1ab8c3d4e97d022d66e4c2af69 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 25 Jul 2025 14:06:43 +0100 Subject: [PATCH 1/3] Add Eloquent Relationship Attributes --- .../Attributes/Relations/BelongsTo.php | 52 ++ .../Attributes/Relations/BelongsToMany.php | 40 ++ .../Attributes/Relations/HasArguments.php | 19 + .../Eloquent/Attributes/Relations/HasMany.php | 40 ++ .../Attributes/Relations/HasManyThrough.php | 47 ++ .../Eloquent/Attributes/Relations/HasOne.php | 40 ++ .../Attributes/Relations/HasOneThrough.php | 47 ++ .../Attributes/Relations/MorphMany.php | 43 ++ .../Attributes/Relations/MorphOne.php | 43 ++ .../Eloquent/Attributes/Relations/MorphTo.php | 32 + .../Attributes/Relations/MorphToMany.php | 43 ++ .../Attributes/Relations/MorphedByMany.php | 43 ++ .../Relations/RelationAttribute.php | 16 + .../Eloquent/Concerns/HasRelationships.php | 56 ++ ...oquentBelongsToManyWithoutTouchingTest.php | 6 +- ...baseEloquentRelationshipAttributesTest.php | 625 ++++++++++++++++++ 16 files changed, 1189 insertions(+), 3 deletions(-) create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsToMany.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/HasMany.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/HasManyThrough.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/HasOne.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/HasOneThrough.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/MorphMany.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/MorphOne.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/MorphTo.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/MorphToMany.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/MorphedByMany.php create mode 100644 src/Illuminate/Database/Eloquent/Attributes/Relations/RelationAttribute.php create mode 100644 tests/Database/DatabaseEloquentRelationshipAttributesTest.php diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php new file mode 100644 index 000000000000..4f3a1cb42b12 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php @@ -0,0 +1,52 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->name = $name; + $this->arguments = [$related, ...$arguments]; + + $this->arguments = array_pad($this->arguments, 4, null); + + if ($this->arguments[1] === null) { + $this->arguments[1] = Str::snake(class_basename($this->related)) . '_id'; + } + + if ($this->arguments[2] === null) { + $this->arguments[2] = 'id'; + } + + $this->arguments[3] = $this->relationName(); + } + + public function relationName(): string + { + return $this->name ?? Str::singular(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsToMany.php new file mode 100644 index 000000000000..11704e9bec11 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsToMany.php @@ -0,0 +1,40 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->name = $name; + $this->arguments = [$related, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::plural(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php new file mode 100644 index 000000000000..b3c8d402c1e0 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php @@ -0,0 +1,19 @@ + + */ + public function relationArguments(): array + { + return $this->arguments; + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasMany.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasMany.php new file mode 100644 index 000000000000..9a5eafcb33dc --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasMany.php @@ -0,0 +1,40 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->name = $name; + $this->arguments = [$related, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::plural(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasManyThrough.php new file mode 100644 index 000000000000..ccd41818c3d0 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasManyThrough.php @@ -0,0 +1,47 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param class-string $through + * @param array ...$arguments + */ + public function __construct(string $related, string $through, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->through = $through; + $this->name = $name; + $this->arguments = [$related, $through, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::plural(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasOne.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasOne.php new file mode 100644 index 000000000000..ff9213d416bc --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasOne.php @@ -0,0 +1,40 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->name = $name; + $this->arguments = [$related, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::singular(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasOneThrough.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasOneThrough.php new file mode 100644 index 000000000000..ab4d3cd81afb --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasOneThrough.php @@ -0,0 +1,47 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param class-string $through + * @param array ...$arguments + */ + public function __construct(string $related, string $through, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->through = $through; + $this->name = $name; + $this->arguments = [$related, $through, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::singular(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphMany.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphMany.php new file mode 100644 index 000000000000..7d51473eac60 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphMany.php @@ -0,0 +1,43 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, string $morphName, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->morphName = $morphName; + $this->name = $name; + $this->arguments = [$related, $morphName, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::plural(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphOne.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphOne.php new file mode 100644 index 000000000000..5d5e942188d3 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphOne.php @@ -0,0 +1,43 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, string $morphName, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->morphName = $morphName; + $this->name = $name; + $this->arguments = [$related, $morphName, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::singular(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphTo.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphTo.php new file mode 100644 index 000000000000..2d6e42e6f28d --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphTo.php @@ -0,0 +1,32 @@ + + */ + public array $arguments = []; + + /** + * @param array ...$arguments + */ + public function __construct(string $morphName, string ...$arguments) + { + $this->morphName = $morphName; + $this->arguments = [$morphName, ...$arguments]; + } + + public function relationName(): string + { + return $this->morphName; + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphToMany.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphToMany.php new file mode 100644 index 000000000000..9dd3b6a11584 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphToMany.php @@ -0,0 +1,43 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, string $morphName, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->morphName = $morphName; + $this->name = $name; + $this->arguments = [$related, $morphName, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::plural(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphedByMany.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphedByMany.php new file mode 100644 index 000000000000..a29a2d549b7f --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/MorphedByMany.php @@ -0,0 +1,43 @@ + + */ + public array $arguments = []; + + private ?string $name; + + /** + * @param class-string $related + * @param array ...$arguments + */ + public function __construct(string $related, string $morphName, ?string $name = null, string ...$arguments) + { + $this->related = $related; + $this->morphName = $morphName; + $this->name = $name; + $this->arguments = [$related, $morphName, ...$arguments]; + } + + public function relationName(): string + { + return $this->name ?? Str::plural(Str::camel(class_basename($this->related))); + } +} diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/RelationAttribute.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/RelationAttribute.php new file mode 100644 index 000000000000..f24e3250e8ad --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/RelationAttribute.php @@ -0,0 +1,16 @@ + + */ + public function relationArguments(): array; +} diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index 8382cc183f4a..f2cf2f155d82 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Database\ClassMorphViolationException; +use Illuminate\Database\Eloquent\Attributes\Relations\RelationAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; @@ -22,6 +23,8 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use ReflectionAttribute; +use ReflectionClass; trait HasRelationships { @@ -69,6 +72,13 @@ trait HasRelationships */ protected static $relationResolvers = []; + /** + * The relations attributes configuration + * + * @var array + */ + private static $relationsAttributes = []; + /** * Get the dynamic relation resolver if defined or inherited, or return null. * @@ -77,9 +87,16 @@ trait HasRelationships * @param class-string $class * @param string $key * @return Closure|null + * + * @throws \ReflectionException */ public function relationResolver($class, $key) { + if (! isset(self::$relationsAttributes[$class])) { + self::$relationsAttributes[$class] = []; + $this->resolveRelationshipAttributes($class); + } + if ($resolver = static::$relationResolvers[$class][$key] ?? null) { return $resolver; } @@ -91,6 +108,45 @@ public function relationResolver($class, $key) return null; } + /** + * @param class-string $class + * + * @throws \ReflectionException + */ + private function resolveRelationshipAttributes(string $class): void + { + $classReflection = new ReflectionClass($class); + $attributes = $classReflection->getAttributes(RelationAttribute::class, ReflectionAttribute::IS_INSTANCEOF); + + foreach ($attributes as $attribute) { + /** @var RelationAttribute $relation */ + $relation = $attribute->newInstance(); + $relationArguments = $relation->relationArguments(); + + $this::resolveRelationUsing( + $relation->relationName(), + function (Model $model) use ($relation, $relationArguments, &$relationObject): Relation { + $method = lcfirst(class_basename(get_class($relation))); + + return $model->{$method}(...$relationArguments); + } + ); + + if ($relation instanceof BelongsTo) { + self::$relationsAttributes[$class][$relation->related ?? $relation->morphName] = $relation; + } + } + + foreach (self::$relationsAttributes[$class] as $relatedClass => $relationConfig) { + $related = new $relatedClass(); + + $foreignKey = $relationConfig->relationArguments()[1] ?? Str::snake($relationConfig->relationName()) . '_' . $related->getKeyName(); + if (! isset($this->{$foreignKey}) || blank($this->{$foreignKey})) { + $this->{$foreignKey} = $this->getAttribute($foreignKey); + } + } + } + /** * Define a dynamic relation resolver. * diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithoutTouchingTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithoutTouchingTest.php index 7b6ad7b510f8..e8a355dd8eba 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithoutTouchingTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithoutTouchingTest.php @@ -28,7 +28,7 @@ public function testItWillNotTouchRelatedModelsWhenUpdatingChild(): void $builder = m::mock(Builder::class); $builder->shouldReceive('join'); - $parent = m::mock(User::class); + $parent = m::mock(DummyUser::class); $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); $builder->shouldReceive('getModel')->andReturn($related); @@ -46,7 +46,7 @@ public function testItWillNotTouchRelatedModelsWhenUpdatingChild(): void } } -class User extends Model +class DummyUser extends Model { protected $table = 'users'; protected $fillable = ['id', 'email']; @@ -65,6 +65,6 @@ class Article extends Model public function users(): BelongsToMany { - return $this->belongsToMany(User::class, 'article_user', 'article_id', 'user_id'); + return $this->belongsToMany(DummyUser::class, 'article_user', 'article_id', 'user_id'); } } diff --git a/tests/Database/DatabaseEloquentRelationshipAttributesTest.php b/tests/Database/DatabaseEloquentRelationshipAttributesTest.php new file mode 100644 index 000000000000..946bcff260d2 --- /dev/null +++ b/tests/Database/DatabaseEloquentRelationshipAttributesTest.php @@ -0,0 +1,625 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + public function testLoadsBelongsToRelationship() + { + $user = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $post = Post::create([ + 'title' => fake()->sentence, + 'content' => fake()->paragraph, + ]); + + $post->author()->associate($user); + $post->save(); + + $this->assertEquals($post->author->id, $user->id); + $this->assertEquals($post->user_id, $user->id); + + $post = Post::query()->find($post->id); + $this->assertEquals($post->author->id, $user->id); + $this->assertEquals($post->user_id, $user->id); + + $postWithoutUser = Post::create([ + 'title' => fake()->sentence, + 'content' => fake()->paragraph, + ]); + + $this->assertNull($postWithoutUser->author); + + $postWithoutUser = Post::query()->find($postWithoutUser->id); + $this->assertNull($postWithoutUser->author); + } + + public function testLoadsBelongsToManyRelationship() + { + $user = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $role = Role::create(); + $user->roleList()->attach($role); + + $this->assertCount(1, $user->roleList); + $this->assertEquals($role->id, $user->roleList->first()->id); + $this->assertCount(1, $role->users); + $this->assertEquals($user->id, $role->users->first()->id); + + $user = User::query()->find($user->id); + $this->assertCount(1, $user->roleList); + $this->assertEquals($role->id, $user->roleList->first()->id); + + $role = Role::query()->find($role->id); + $this->assertCount(1, $role->users); + $this->assertEquals($user->id, $role->users->first()->id); + + $userWithoutRoles = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + $this->assertCount(0, $userWithoutRoles->roleList); + + $userWithoutRoles = User::query()->find($userWithoutRoles->id); + $this->assertCount(0, $userWithoutRoles->roleList); + + $roleWithoutUsers = Role::create(); + $this->assertCount(0, $roleWithoutUsers->users); + + $roleWithoutUsers = Role::query()->find($roleWithoutUsers->id); + $this->assertCount(0, $roleWithoutUsers->users); + } + + public function testLoadsHasManyRelationship() + { + $user = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $post = Post::create([ + 'title' => fake()->sentence, + 'content' => fake()->paragraph, + ]); + $user->articles()->save($post); + + $this->assertCount(1, $user->articles); + $this->assertEquals($post->id, $user->articles->first()->id); + $this->assertEquals($user->id, $post->author->id); + $this->assertEquals($user->id, $post->user_id); + + $user = User::query()->find($user->id); + $this->assertCount(1, $user->articles); + $this->assertEquals($post->id, $user->articles->first()->id); + + $post = Post::query()->find($post->id); + $this->assertEquals($user->id, $post->author->id); + $this->assertEquals($user->id, $post->user_id); + + $userWithoutPosts = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $this->assertCount(0, $userWithoutPosts->articles); + } + + public function testLoadsHasManyThroughRelationship() + { + $country = Country::create(); + + $user = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $post = Post::create([ + 'title' => fake()->sentence, + 'content' => fake()->paragraph, + ]); + + $country->users()->save($user); + $user->articles()->save($post); + + $this->assertCount(1, $country->posts); + $this->assertEquals($post->id, $country->posts->first()->id); + + $country = Country::query()->find($country->id); + $this->assertCount(1, $country->posts); + $this->assertEquals($post->id, $country->posts->first()->id); + + $countryWithoutPosts = Country::create(); + $this->assertCount(0, $countryWithoutPosts->posts); + } + + public function testLoadsHasOneRelationship() + { + $user = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $phone = Phone::create(); + $user->phone()->save($phone); + + $this->assertEquals($phone->id, $user->phone->id); + $this->assertEquals($user->id, $phone->user->id); + $this->assertEquals($user->id, $phone->user_id); + + $user = User::query()->find($user->id); + $this->assertEquals($phone->id, $user->phone->id); + + $phone = Phone::query()->find($phone->id); + $this->assertEquals($user->id, $phone->user->id); + $this->assertEquals($user->id, $phone->user_id); + + $userWithoutPhone = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + + $this->assertNull($userWithoutPhone->phone); + } + + public function testLoadsHasOneThroughRelationship() + { + $seller = Seller::create(); + $computer = Computer::create(); + $manufacturer = Manufacturer::create(); + + $seller->computer()->save($computer); + $computer->manufacturer()->save($manufacturer); + + $this->assertEquals($manufacturer->id, $seller->manufacturer->id); + $this->assertEquals($computer->id, $seller->computer->id); + $this->assertEquals($manufacturer->id, $computer->manufacturer->id); + + $seller = Seller::query()->find($seller->id); + $this->assertEquals($manufacturer->id, $seller->manufacturer->id); + + $sellerWithoutManufacturer = Seller::create(); + $this->assertNull($sellerWithoutManufacturer->manufacturer); + } + + public function testLoadsMorphyManyAndMorphToRelationship() + { + $post = Post::create([ + 'title' => fake()->sentence, + 'content' => fake()->paragraph, + ]); + $image = $post->images()->save(new Image()); + + $this->assertCount(1, $post->images); + $this->assertEquals($image->id, $post->images->first()->id); + $this->assertEquals($post->id, $image->imageable->id); + + $post = Post::query()->find($post->id); + $this->assertCount(1, $post->images); + $this->assertEquals($image->id, $post->images->first()->id); + + $image = Image::query()->find($image->id); + $this->assertEquals($post->id, $image->imageable->id); + } + + public function testLoadsMorphyOneRelationship() + { + $user = User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ]); + $image = $user->image()->save(new Image()); + + $this->assertEquals($image->id, $user->image->id); + $this->assertEquals($user->id, $image->imageable->id); + + $user = User::query()->find($user->id); + $this->assertEquals($image->id, $user->image->id); + + $image = Image::query()->find($image->id); + $this->assertEquals($user->id, $image->imageable->id); + } + + public function testLoadsMorphyToManyAndMorphedByManyRelationship() + { + $post = Post::create([ + 'title' => fake()->sentence, + 'content' => fake()->paragraph, + ]); + $tag = Tag::create(); + + $post->tags()->attach($tag); + + $this->assertCount(1, $post->tags); + $this->assertEquals($tag->id, $post->tags->first()->id); + $this->assertCount(1, $tag->posts); + $this->assertEquals($post->id, $tag->posts->first()->id); + } + + public function testLoadsCamelCaseRelationship() + { + $bookCase = BookCase::create([ + 'name' => fake()->name, + ]); + + $book = $bookCase->books()->create([ + 'name' => fake()->name, + ]); + + $this->assertCount(1, $bookCase->books); + $this->assertEquals($book->id, $bookCase->books->first()->id); + $this->assertEquals($bookCase->id, $book->bookCase->id); + $this->assertEquals($bookCase->id, $book->book_case_id); + + $bookCase = BookCase::query()->find($bookCase->id); + $this->assertCount(1, $bookCase->books); + $this->assertEquals($book->id, $bookCase->books->first()->id); + + $book = Book::query()->find($book->id); + $this->assertEquals($bookCase->id, $book->bookCase->id); + $this->assertEquals($bookCase->id, $book->book_case_id); + } + + public function testWillNotAddUnnecessaryKeys() + { + User::create([ + 'name' => fake()->name, + 'email' => fake()->unique()->safeEmail, + 'password' => 's3Cr3T@!!!', + ])->workBooks()->create([ + 'name' => fake()->name, + ]); + + Library::create()->libraryBooks()->create([ + 'name' => fake()->name, + ]); + + $workBook = WorkBook::query()->first(); + $libraryBook = LibraryBook::query()->first(); + + $this->assertTrue($workBook->save()); + $this->assertTrue($libraryBook->save()); + } + + public function testLoadsRelationshipWithArguments() + { + $book = Book::create([ + 'name' => fake()->name, + ]); + + $price = $book->prices()->create([ + 'price' => fake()->numberBetween(1, 500), + ]); + + $this->assertCount(1, $book->prices); + $this->assertEquals($price->id, $book->prices->first()->id); + $this->assertEquals($book->id, $price->book->id); + $this->assertEquals($book->id, $price->custom_id); + + $book = Book::query()->find($book->id); + $this->assertCount(1, $book->prices); + $this->assertEquals($price->id, $book->prices->first()->id); + + $price = Price::query()->find($price->id); + $this->assertEquals($book->id, $price->book->id); + $this->assertEquals($book->id, $price->custom_id); + } + + /** + * Setup the database schema. + * + * @return void + */ + public function createSchema() + { + $this->schema()->create('users', function ($table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->string('password'); + $table->foreignId('country_id')->nullable()->constrained(); + $table->boolean('active')->default(false); + $table->timestamps(); + }); + + $this->schema()->create('products', function ($table) { + $table->id(); + $table->string('name'); + $table->float('price'); + $table->integer('random_number'); + $table->integer('another_random_number')->nullable(); + $table->json('json_column')->nullable(); + $table->timestamp('expires_at'); + $table->timestamps(); + }); + + $this->schema()->create('posts', function ($table) { + $table->id(); + $table->string('title'); + $table->float('content'); + $table->foreignId('user_id')->nullable()->constrained('users'); + $table->timestamps(); + }); + + $this->schema()->create('roles', function ($table) { + $table->id(); + $table->timestamps(); + }); + + $this->schema()->create('role_user', function ($table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->foreignId('role_id')->constrained(); + $table->timestamps(); + }); + + $this->schema()->create('countries', function ($table) { + $table->id(); + $table->timestamps(); + }); + + $this->schema()->create('phones', function ($table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained(); + $table->timestamps(); + }); + + $this->schema()->create('sellers', function ($table) { + $table->id(); + $table->timestamps(); + }); + + $this->schema()->create('computers', function ($table) { + $table->id(); + $table->foreignId('seller_id')->nullable()->constrained(); + $table->timestamps(); + }); + + $this->schema()->create('manufacturers', function ($table) { + $table->id(); + $table->foreignId('computer_id')->nullable()->constrained(); + $table->timestamps(); + }); + + $this->schema()->create('images', function ($table) { + $table->id(); + $table->morphs('imageable'); + $table->timestamps(); + }); + + $this->schema()->create('tags', function ($table) { + $table->id(); + $table->timestamps(); + }); + + $this->schema()->create('taggables', function ($table) { + $table->id(); + $table->foreignId('tag_id')->constrained(); + $table->morphs('taggable'); + $table->timestamps(); + }); + + $this->schema()->create('book_cases', function ($table) { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema()->create('books', function ($table) { + $table->id(); + $table->foreignId('book_case_id')->nullable()->constrained(); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema()->create('libraries', function ($table) { + $table->id(); + $table->timestamps(); + }); + + $this->schema()->create('library_books', function ($table) { + $table->id(); + $table->foreignId('library_id')->nullable()->constrained(); + $table->foreignId('book_case_id')->nullable()->constrained(); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema()->create('work_books', function ($table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained(); + $table->foreignId('book_case_id')->nullable()->constrained(); + $table->string('name'); + $table->timestamps(); + }); + + $this->schema()->create('prices', function ($table) { + $table->id(); + $table->foreignId('custom_id')->nullable()->constrained(); + $table->decimal('price'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('users'); + $this->schema()->drop('products'); + $this->schema()->drop('posts'); + $this->schema()->drop('roles'); + $this->schema()->drop('role_user'); + $this->schema()->drop('countries'); + $this->schema()->drop('phones'); + $this->schema()->drop('sellers'); + $this->schema()->drop('computers'); + $this->schema()->drop('manufacturers'); + $this->schema()->drop('images'); + $this->schema()->drop('tags'); + $this->schema()->drop('taggables'); + $this->schema()->drop('book_cases'); + $this->schema()->drop('books'); + $this->schema()->drop('libraries'); + $this->schema()->drop('library_books'); + $this->schema()->drop('work_books'); + $this->schema()->drop('prices'); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema() + { + return $this->connection()->getSchemaBuilder(); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\ConnectionInterface + */ + protected function connection() + { + return Model::getConnectionResolver()->connection(); + } +} + +#[BelongsTo(BookCase::class)] +#[HasMany(Price::class, null, 'custom_id', 'id')] +class Book extends Model { + protected $guarded = []; +} + +#[HasMany(Book::class)] +class BookCase extends Model { + protected $guarded = []; +} + +#[HasOne(Manufacturer::class)] +class Computer extends Model { + protected $guarded = []; +} + +#[HasMany(User::class)] +#[HasManyThrough(Post::class, User::class)] +class Country extends Model { + protected $guarded = []; +} + +#[MorphTo('imageable')] +class Image extends Model { + protected $guarded = []; +} + +#[HasMany(LibraryBook::class)] +class Library extends Model { + protected $guarded = []; +} + +#[BelongsTo(Library::class)] +class LibraryBook extends Book { + protected $guarded = []; +} + +class Manufacturer extends Model { + protected $guarded = []; +} + +#[BelongsTo(User::class)] +class Phone extends Model { + protected $guarded = []; +} + +#[BelongsTo(related: User::class, name: 'author')] +#[MorphMany(Image::class, 'imageable')] +#[MorphToMany(Tag::class, 'taggable')] +class Post extends Model { + protected $guarded = []; +} + +#[BelongsTo(Book::class, null, 'custom_id', 'id')] +class Price extends Model { + protected $guarded = []; +} + +#[BelongsToMany(User::class)] +class Role extends Model { + protected $guarded = []; +} + +#[HasOneThrough(Manufacturer::class, Computer::class)] +#[HasOne(Computer::class)] +class Seller extends Model { + protected $guarded = []; +} + +#[MorphedByMany(Post::class, 'taggable')] +class Tag extends Model { + protected $guarded = []; +} + +#[BelongsToMany(related: Role::class, name: 'roleList')] +#[HasMany(related: Post::class, name: 'articles')] +#[HasMany(WorkBook::class)] +#[HasOne(Phone::class)] +#[MorphOne(Image::class, 'imageable')] +class User extends Model { + protected $table = 'users'; + + protected $guarded = []; +} + +#[BelongsTo(User::class)] +final class WorkBook extends Book { + protected $guarded = []; +} From 51120583c4a0c146d427209220e36438d4ae38bd Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 25 Jul 2025 14:21:02 +0100 Subject: [PATCH 2/3] style fixes --- .../Attributes/Relations/BelongsTo.php | 2 +- .../Attributes/Relations/HasArguments.php | 2 - .../Eloquent/Concerns/HasRelationships.php | 4 +- ...baseEloquentRelationshipAttributesTest.php | 50 ++++++++++++------- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php index 4f3a1cb42b12..f5b32f5bd84b 100644 --- a/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/BelongsTo.php @@ -35,7 +35,7 @@ public function __construct(string $related, ?string $name = null, string ...$ar $this->arguments = array_pad($this->arguments, 4, null); if ($this->arguments[1] === null) { - $this->arguments[1] = Str::snake(class_basename($this->related)) . '_id'; + $this->arguments[1] = Str::snake(class_basename($this->related)).'_id'; } if ($this->arguments[2] === null) { diff --git a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php index b3c8d402c1e0..72c0723d1b81 100644 --- a/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php +++ b/src/Illuminate/Database/Eloquent/Attributes/Relations/HasArguments.php @@ -1,7 +1,5 @@ */ @@ -140,7 +140,7 @@ function (Model $model) use ($relation, $relationArguments, &$relationObject): R foreach (self::$relationsAttributes[$class] as $relatedClass => $relationConfig) { $related = new $relatedClass(); - $foreignKey = $relationConfig->relationArguments()[1] ?? Str::snake($relationConfig->relationName()) . '_' . $related->getKeyName(); + $foreignKey = $relationConfig->relationArguments()[1] ?? Str::snake($relationConfig->relationName()).'_'.$related->getKeyName(); if (! isset($this->{$foreignKey}) || blank($this->{$foreignKey})) { $this->{$foreignKey} = $this->getAttribute($foreignKey); } diff --git a/tests/Database/DatabaseEloquentRelationshipAttributesTest.php b/tests/Database/DatabaseEloquentRelationshipAttributesTest.php index 946bcff260d2..519664da6325 100644 --- a/tests/Database/DatabaseEloquentRelationshipAttributesTest.php +++ b/tests/Database/DatabaseEloquentRelationshipAttributesTest.php @@ -536,75 +536,89 @@ protected function connection() #[BelongsTo(BookCase::class)] #[HasMany(Price::class, null, 'custom_id', 'id')] -class Book extends Model { +class Book extends Model +{ protected $guarded = []; } #[HasMany(Book::class)] -class BookCase extends Model { +class BookCase extends Model +{ protected $guarded = []; } #[HasOne(Manufacturer::class)] -class Computer extends Model { +class Computer extends Model +{ protected $guarded = []; } #[HasMany(User::class)] #[HasManyThrough(Post::class, User::class)] -class Country extends Model { +class Country extends Model +{ protected $guarded = []; } #[MorphTo('imageable')] -class Image extends Model { +class Image extends Model +{ protected $guarded = []; } #[HasMany(LibraryBook::class)] -class Library extends Model { +class Library extends Model +{ protected $guarded = []; } #[BelongsTo(Library::class)] -class LibraryBook extends Book { +class LibraryBook extends Book +{ protected $guarded = []; } -class Manufacturer extends Model { +class Manufacturer extends Model +{ protected $guarded = []; } #[BelongsTo(User::class)] -class Phone extends Model { +class Phone extends Model +{ protected $guarded = []; } #[BelongsTo(related: User::class, name: 'author')] #[MorphMany(Image::class, 'imageable')] #[MorphToMany(Tag::class, 'taggable')] -class Post extends Model { +class Post extends Model +{ protected $guarded = []; } #[BelongsTo(Book::class, null, 'custom_id', 'id')] -class Price extends Model { +class Price extends Model +{ protected $guarded = []; } #[BelongsToMany(User::class)] -class Role extends Model { +class Role extends Model +{ protected $guarded = []; } #[HasOneThrough(Manufacturer::class, Computer::class)] #[HasOne(Computer::class)] -class Seller extends Model { +class Seller extends Model +{ protected $guarded = []; } #[MorphedByMany(Post::class, 'taggable')] -class Tag extends Model { +class Tag extends Model +{ protected $guarded = []; } @@ -613,13 +627,13 @@ class Tag extends Model { #[HasMany(WorkBook::class)] #[HasOne(Phone::class)] #[MorphOne(Image::class, 'imageable')] -class User extends Model { - protected $table = 'users'; - +class User extends Model +{ protected $guarded = []; } #[BelongsTo(User::class)] -final class WorkBook extends Book { +final class WorkBook extends Book +{ protected $guarded = []; } From 3fdf71062f4280ab91db7624869a546efeb2eac4 Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Fri, 25 Jul 2025 14:28:03 +0100 Subject: [PATCH 3/3] static analysis fix --- src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index e67c111af79e..a12d83b21aa8 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -125,7 +125,7 @@ private function resolveRelationshipAttributes(string $class): void $this::resolveRelationUsing( $relation->relationName(), - function (Model $model) use ($relation, $relationArguments, &$relationObject): Relation { + function (Model $model) use ($relation, $relationArguments): Relation { $method = lcfirst(class_basename(get_class($relation))); return $model->{$method}(...$relationArguments);