From 875d2b0cb1c39d2a76517ae9da7c3260cb5bb356 Mon Sep 17 00:00:00 2001 From: Samuel Levy Date: Mon, 27 May 2024 13:33:14 +1000 Subject: [PATCH 1/5] [11.x] Added SupportsInverseRelation and tests --- .../Concerns/SupportsInverseRelations.php | 73 ++++++++++ .../DatabaseEloquentInverseRelationTest.php | 130 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php create mode 100755 tests/Database/DatabaseEloquentInverseRelationTest.php diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php new file mode 100644 index 000000000000..2eb2caf58859 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -0,0 +1,73 @@ +inverseRelationship; + } + + /** + * Links the related models back to the parent after the query has run + * + * @param string $relation + * @return $this + */ + public function inverse(string $relation) + { + if ($this->inverseRelationship === null && $relation) { + $this->query->afterQuery(function ($result) { + return $this->inverseRelationship + ? $this->applyInverseRelationToCollection($result, $this->getParent()) + : $result; + }); + } + + $this->inverseRelationship = $relation; + + return $this; + } + + /** + * @param \Illuminate\Database\Eloquent\Collection $models + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function applyInverseRelationToCollection($models, ?Model $parent = null) + { + $parent ??= $this->getParent(); + + foreach ($models as $model) { + $this->applyInverseRelationToModel($model, $parent); + } + + return $models; + } + + /** + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Model + */ + protected function applyInverseRelationToModel(Model $model, ?Model $parent = null) + { + if ($inverse = $this->getInverseRelationship()) { + $parent ??= $this->getParent(); + + $model->setRelation($inverse, $parent); + } + + return $model; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php new file mode 100755 index 000000000000..bfdfc076c5b6 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -0,0 +1,130 @@ +shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + new HasInverseRelationStub($builder, new HasInverseRelationParentStub(['id' => 1])); + } + + public function testInverseRelationCallbackIsNotSetIfInverseRelationIsEmpty() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse(''); + } + + public function testBuilderCallbackIsAppliedWhenInverseRelationIsSet() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + $parent = new HasInverseRelationParentStub(); + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use ($parent) { + $relation = (new \ReflectionFunction($callback))->getClosureThis(); + + return $relation instanceof HasInverseRelationStub && $relation->getParent() === $parent; + })->once()->andReturnSelf(); + + (new HasInverseRelationStub($builder, $parent))->inverse('test'); + } + + public function testBuilderCallbackAppliesInverseRelationToAllModelsInResult() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + // Capture the callback so that we can manually call it. + $afterQuery = null; + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use (&$afterQuery) { + return (bool) $afterQuery = $callback; + })->once()->andReturnSelf(); + + $parent = new HasInverseRelationParentStub(); + (new HasInverseRelationStub($builder, $parent))->inverse('test'); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + $this->assertFalse($model->relationLoaded('test')); + } + + $results = $afterQuery($results); + + foreach ($results as $model) { + $this->assertNotEmpty($model->getRelations()); + $this->assertTrue($model->relationLoaded('test')); + $this->assertSame($parent, $model->test); + } + } + + public function testInverseRelationIsNotSetIfInverseRelationNameIsEmpty() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + // Capture the callback so that we can manually call it. + $afterQuery = null; + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use (&$afterQuery) { + return (bool) $afterQuery = $callback; + })->once()->andReturnSelf(); + + $parent = new HasInverseRelationParentStub(); + $relation = (new HasInverseRelationStub($builder, $parent)); + // Set the callback + $relation->inverse('test'); + // Override the relation name to be blank + $relation->inverse(''); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + $results = $afterQuery($results); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + } +} + +class HasInverseRelationParentStub extends Model { + protected static $unguarded = true; +} + +class HasInverseRelationRelatedStub extends Model { + protected static $unguarded = true; +} + +class HasInverseRelationStub extends Relation +{ + use SupportsInverseRelations; + + // None of these methods will actually be called - they're just needed to fill out `Relation` + public function match(array $models, Collection $results, $relation) {return $models;} + public function initRelation(array $models, $relation) {return $models;} + public function getResults(){return $this->query->get();} + public function addConstraints() {} + public function addEagerConstraints(array $models) {} +} From ebf5fcc635ea36f8149dbf63b0b34a5748b66ecb Mon Sep 17 00:00:00 2001 From: Samuel Levy Date: Mon, 27 May 2024 15:29:32 +1000 Subject: [PATCH 2/5] [11.x] Added inverse() to Has* Relations --- .../Concerns/SupportsInverseRelations.php | 21 +- .../Database/Eloquent/Relations/HasMany.php | 17 +- .../Database/Eloquent/Relations/HasOne.php | 7 +- .../Eloquent/Relations/HasOneOrMany.php | 19 +- .../Database/Eloquent/Relations/MorphMany.php | 19 +- .../Database/Eloquent/Relations/MorphOne.php | 9 +- .../Eloquent/Relations/MorphOneOrMany.php | 4 +- ...baseEloquentInverseRelationHasManyTest.php | 309 ++++++++++++++++++ ...abaseEloquentInverseRelationHasOneTest.php | 246 ++++++++++++++ ...seEloquentInverseRelationMorphManyTest.php | 309 ++++++++++++++++++ ...aseEloquentInverseRelationMorphOneTest.php | 245 ++++++++++++++ .../DatabaseEloquentInverseRelationTest.php | 91 +++++- 12 files changed, 1259 insertions(+), 37 deletions(-) create mode 100755 tests/Database/DatabaseEloquentInverseRelationHasManyTest.php create mode 100755 tests/Database/DatabaseEloquentInverseRelationHasOneTest.php create mode 100755 tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php create mode 100755 tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php index 2eb2caf58859..14ab9b5eefa8 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -3,13 +3,14 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\RelationNotFoundException; trait SupportsInverseRelations { protected string|null $inverseRelationship = null; /** - * Gets the name of the inverse relationship + * Gets the name of the inverse relationship. * * @return string|null */ @@ -19,13 +20,17 @@ public function getInverseRelationship() } /** - * Links the related models back to the parent after the query has run + * Links the related models back to the parent after the query has run. * * @param string $relation * @return $this */ public function inverse(string $relation) { + if (! $this->getModel()->isRelation($relation)) { + throw RelationNotFoundException::make($this->getModel(), $relation); + } + if ($this->inverseRelationship === null && $relation) { $this->query->afterQuery(function ($result) { return $this->inverseRelationship @@ -39,6 +44,18 @@ public function inverse(string $relation) return $this; } + /** + * Removes the inverse relationship for this query. + * + * @return $this + */ + public function withoutInverse() + { + $this->inverseRelationship = null; + + return $this; + } + /** * @param \Illuminate\Database\Eloquent\Collection $models * @param \Illuminate\Database\Eloquent\Model|null $parent diff --git a/src/Illuminate/Database/Eloquent/Relations/HasMany.php b/src/Illuminate/Database/Eloquent/Relations/HasMany.php index 27bcd73e39b2..88ec0145b65f 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasMany.php @@ -13,11 +13,18 @@ class HasMany extends HasOneOrMany */ public function one() { - return HasOne::noConstraints(fn () => new HasOne( - $this->getQuery(), - $this->parent, - $this->foreignKey, - $this->localKey + return HasOne::noConstraints(fn () => tap( + new HasOne( + $this->getQuery(), + $this->parent, + $this->foreignKey, + $this->localKey + ), + function ($hasOne) { + if ($inverse = $this->getInverseRelationship()) { + $hasOne->inverse($inverse); + } + } )); } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOne.php b/src/Illuminate/Database/Eloquent/Relations/HasOne.php index ed85f1e910ee..74e0aabef98b 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOne.php @@ -119,9 +119,10 @@ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) */ public function newRelatedInstanceFor(Model $parent) { - return $this->related->newInstance()->setAttribute( - $this->getForeignKeyName(), $parent->{$this->localKey} - ); + return tap($this->related->newInstance(), function ($instance) use ($parent) { + $instance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}); + $this->applyInverseRelationToModel($instance, $parent); + }); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index e1d295d86be4..6461a6069fb5 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\Eloquent\Relations\Concerns\SupportsInverseRelations; use Illuminate\Database\UniqueConstraintViolationException; abstract class HasOneOrMany extends Relation { - use InteractsWithDictionary; + use InteractsWithDictionary, SupportsInverseRelations; /** * The foreign key of the parent model. @@ -53,6 +54,7 @@ public function make(array $attributes = []) { return tap($this->related->newInstance($attributes), function ($instance) { $this->setForeignAttributesForCreate($instance); + $this->applyInverseRelationToModel($instance); }); } @@ -151,9 +153,13 @@ protected function matchOneOrMany(array $models, Collection $results, $relation, // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { - $model->setRelation( - $relation, $this->getRelationValue($dictionary, $key, $type) - ); + $related = $this->getRelationValue($dictionary, $key, $type); + $model->setRelation($relation, $related); + + // Apply the inverse relation if we have one + $type === 'one' + ? $this->applyInverseRelationToModel($related, $model) + : $this->applyInverseRelationToCollection($related, $model); } } @@ -340,6 +346,8 @@ public function create(array $attributes = []) $this->setForeignAttributesForCreate($instance); $instance->save(); + + $this->applyInverseRelationToModel($instance); }); } @@ -364,7 +372,7 @@ public function forceCreate(array $attributes = []) { $attributes[$this->getForeignKeyName()] = $this->getParentKey(); - return $this->related->forceCreate($attributes); + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } /** @@ -415,6 +423,7 @@ public function createManyQuietly(iterable $records) protected function setForeignAttributesForCreate(Model $model) { $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); + $this->applyInverseRelationToModel($model); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php index 3636f25d06c2..6da28a04e91f 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php @@ -14,12 +14,19 @@ class MorphMany extends MorphOneOrMany */ public function one() { - return MorphOne::noConstraints(fn () => new MorphOne( - $this->getQuery(), - $this->getParent(), - $this->morphType, - $this->foreignKey, - $this->localKey + return MorphOne::noConstraints(fn () => tap( + new MorphOne( + $this->getQuery(), + $this->getParent(), + $this->morphType, + $this->foreignKey, + $this->localKey + ), + function ($morphOne) { + if ($inverse = $this->getInverseRelationship()) { + $morphOne->inverse($inverse); + } + } )); } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php index fc8f4dc8ca48..782b87d5393c 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php @@ -119,9 +119,12 @@ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) */ public function newRelatedInstanceFor(Model $parent) { - return $this->related->newInstance() - ->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}) - ->setAttribute($this->getMorphType(), $this->morphClass); + return tap($this->related->newInstance(), function ($instance) use ($parent) { + $instance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}) + ->setAttribute($this->getMorphType(), $this->morphClass); + + $this->applyInverseRelationToModel($instance, $parent); + }); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 3cfec895548d..6d29a131130e 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -78,7 +78,7 @@ public function forceCreate(array $attributes = []) $attributes[$this->getForeignKeyName()] = $this->getParentKey(); $attributes[$this->getMorphType()] = $this->morphClass; - return $this->related->forceCreate($attributes); + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } /** @@ -92,6 +92,8 @@ protected function setForeignAttributesForCreate(Model $model) $model->{$this->getForeignKeyName()} = $this->getParentKey(); $model->{$this->getMorphType()} = $this->morphClass; + + $this->applyInverseRelationToModel($model); } /** diff --git a/tests/Database/DatabaseEloquentInverseRelationHasManyTest.php b/tests/Database/DatabaseEloquentInverseRelationHasManyTest.php new file mode 100755 index 000000000000..47f597dc04b2 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationHasManyTest.php @@ -0,0 +1,309 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_users', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_users'); + $this->schema()->drop('test_posts'); + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('posts')); + foreach ($user->posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('posts')->get(); + + foreach ($users as $user) { + $posts = $user->getRelation('posts'); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + } + + public function testHasLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('lastPost')); + $post = $user->lastPost; + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasLatestOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('lastPost')->get(); + + foreach ($users as $user) { + $post = $user->getRelation('lastPost'); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('firstPost')); + $post = $user->firstPost; + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testOneOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('firstPost')->get(); + + foreach ($users as $user) { + $post = $user->getRelation('firstPost'); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenMakingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->makeMany(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenCreatingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->createMany(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenCreatingManyQuietly() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->createManyQuietly(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenSavingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = array_fill(0, 3, new HasManyInversePostModel); + + $user->posts()->saveMany($posts); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenUpdatingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = HasManyInversePostModel::factory()->count(3)->create(); + + foreach ($posts as $post) { + $this->assertTrue($user->isNot($post->user)); + } + + $user->posts()->saveMany($posts); + + foreach ($posts as $post) { + $this->assertSame($user, $post->user); + } + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class HasManyInverseUserModel extends Model +{ + use HasFactory; + + protected $table = 'test_users'; + protected $fillable = ['id']; + + protected static function newFactory() + { + return new HasManyInverseUserModelFactory(); + } + + public function posts(): HasMany + { + return $this->hasMany(HasManyInversePostModel::class, 'user_id')->inverse('user'); + } + + public function lastPost(): HasOne + { + return $this->hasOne(HasManyInversePostModel::class, 'user_id')->latestOfMany()->inverse('user'); + } + + public function firstPost(): HasOne + { + return $this->posts()->one(); + } +} + +class HasManyInverseUserModelFactory extends Factory +{ + protected $model = HasManyInverseUserModel::class; + + public function definition() + { + return []; + } + + public function withPosts(int $count = 3) + { + return $this->afterCreating(function (HasManyInverseUserModel $model) use ($count) { + HasManyInversePostModel::factory()->recycle($model)->count($count)->create(); + }); + } +} + +class HasManyInversePostModel extends Model +{ + use HasFactory; + + protected $table = 'test_posts'; + protected $fillable = ['id', 'user_id']; + + protected static function newFactory() + { + return new HasManyInversePostModelFactory(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(HasManyInverseUserModel::class, 'user_id'); + } +} + +class HasManyInversePostModelFactory extends Factory +{ + protected $model = HasManyInversePostModel::class; + + public function definition() + { + return [ + 'user_id' => HasManyInverseUserModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationHasOneTest.php b/tests/Database/DatabaseEloquentInverseRelationHasOneTest.php new file mode 100755 index 000000000000..4667ab091fc2 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationHasOneTest.php @@ -0,0 +1,246 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_parent', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_child', function ($table) { + $table->increments('id'); + $table->foreignId('parent_id')->unique(); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_parent'); + $this->schema()->drop('test_child'); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasOneInverseChildModel::factory(5)->create(); + $models = HasOneInverseParentModel::all(); + + foreach ($models as $parent) { + $this->assertFalse($parent->relationLoaded('child')); + $child = $parent->child; + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasOneInverseChildModel::factory(5)->create(); + + $models = HasOneInverseParentModel::with('child')->get(); + + foreach ($models as $parent) { + $child = $parent->child; + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenMaking() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->make(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenCreating() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->create(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenCreatingQuietly() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->createQuietly(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenForceCreating() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->forceCreate(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenSaving() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::make(); + + $this->assertFalse($child->relationLoaded('parent')); + $parent->child()->save($child); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenSavingQuietly() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::make(); + + $this->assertFalse($child->relationLoaded('parent')); + $parent->child()->saveQuietly($child); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenUpdating() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::factory()->create(); + + $this->assertTrue($parent->isNot($child->parent)); + + $parent->child()->save($child); + + $this->assertTrue($parent->is($child->parent)); + $this->assertSame($parent, $child->parent); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class HasOneInverseParentModel extends Model +{ + use HasFactory; + + protected $table = 'test_parent'; + + protected $fillable = ['id']; + + protected static function newFactory() + { + return new HasOneInverseParentModelFactory(); + } + + public function child(): HasOne + { + return $this->hasOne(HasOneInverseChildModel::class, 'parent_id')->inverse('parent'); + } +} + +class HasOneInverseParentModelFactory extends Factory +{ + protected $model = HasOneInverseParentModel::class; + + public function definition() + { + return []; + } +} + +class HasOneInverseChildModel extends Model +{ + use HasFactory; + + protected $table = 'test_child'; + protected $fillable = ['id', 'parent_id']; + + protected static function newFactory() + { + return new HasOneInverseChildModelFactory(); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(HasOneInverseParentModel::class, 'parent_id'); + } +} + +class HasOneInverseChildModelFactory extends Factory +{ + protected $model = HasOneInverseChildModel::class; + + public function definition() + { + return [ + 'parent_id' => HasOneInverseParentModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php new file mode 100755 index 000000000000..930b22f833fd --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php @@ -0,0 +1,309 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_comments', function ($table) { + $table->increments('id'); + $table->morphs('commentable'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_posts'); + $this->schema()->drop('test_comments'); + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('comments')); + $comments = $post->comments; + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::with('comments')->get(); + + foreach ($posts as $post) { + $comments = $post->getRelation('comments'); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('lastComment')); + $comment = $post->lastComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('lastComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('lastComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('firstComment')); + $comment = $post->firstComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('firstComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('firstComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenMakingMany() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->makeMany(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenCreatingMany() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->createMany(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenCreatingManyQuietly() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->createManyQuietly(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenSavingMany() + { + $post = MorphManyInversePostModel::create(); + $comments = array_fill(0, 3, new MorphManyInverseCommentModel); + + $post->comments()->saveMany($comments); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenUpdatingMany() + { + $post = MorphManyInversePostModel::create(); + $comments = MorphManyInverseCommentModel::factory()->count(3)->create(); + + foreach ($comments as $comment) { + $this->assertTrue($post->isNot($comment->commentable)); + } + + $post->comments()->saveMany($comments); + + foreach ($comments as $comment) { + $this->assertSame($post, $comment->commentable); + } + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class MorphManyInversePostModel extends Model +{ + use HasFactory; + + protected $table = 'test_posts'; + protected $fillable = ['id']; + + protected static function newFactory() + { + return new MorphManyInversePostModelFactory(); + } + + public function comments(): MorphMany + { + return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse('commentable'); + } + + public function lastComment(): MorphOne + { + return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse('commentable'); + } + + public function firstComment(): MorphOne + { + return $this->comments()->one(); + } +} + +class MorphManyInversePostModelFactory extends Factory +{ + protected $model = MorphManyInversePostModel::class; + + public function definition() + { + return []; + } + + public function withComments(int $count = 3) + { + return $this->afterCreating(function (MorphManyInversePostModel $model) use ($count) { + MorphManyInverseCommentModel::factory()->recycle($model)->count($count)->create(); + }); + } +} + +class MorphManyInverseCommentModel extends Model +{ + use HasFactory; + + protected $table = 'test_comments'; + protected $fillable = ['id', 'commentable_type', 'commentable_id']; + + protected static function newFactory() + { + return new MorphManyInverseCommentModelFactory(); + } + + public function commentable(): MorphTo + { + return $this->morphTo('commentable'); + } +} + +class MorphManyInverseCommentModelFactory extends Factory +{ + protected $model = MorphManyInverseCommentModel::class; + + public function definition() + { + return [ + 'commentable_type' => MorphManyInversePostModel::class, + 'commentable_id' => MorphManyInversePostModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php new file mode 100755 index 000000000000..06350658383f --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php @@ -0,0 +1,245 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_images', function ($table) { + $table->increments('id'); + $table->morphs('imageable'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_posts'); + $this->schema()->drop('test_images'); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('image')); + $image = $post->image; + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::with('image')->get(); + + foreach ($posts as $post) { + $image = $post->getRelation('image'); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenMaking() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->make(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenCreating() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->create(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenCreatingQuietly() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->createQuietly(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenForceCreating() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->forceCreate(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenSaving() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::make(); + + $this->assertFalse($image->relationLoaded('imageable')); + $post->image()->save($image); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenSavingQuietly() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::make(); + + $this->assertFalse($image->relationLoaded('imageable')); + $post->image()->saveQuietly($image); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenUpdating() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::factory()->create(); + + $this->assertTrue($post->isNot($image->imageable)); + + $post->image()->save($image); + + $this->assertTrue($post->is($image->imageable)); + $this->assertSame($post, $image->imageable); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class MorphOneInversePostModel extends Model +{ + use HasFactory; + + protected $table = 'test_posts'; + protected $fillable = ['id']; + + protected static function newFactory() + { + return new MorphOneInversePostModelFactory(); + } + + public function image(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse('imageable'); + } +} + +class MorphOneInversePostModelFactory extends Factory +{ + protected $model = MorphOneInversePostModel::class; + + public function definition() + { + return []; + } +} + +class MorphOneInverseImageModel extends Model +{ + use HasFactory; + + protected $table = 'test_images'; + protected $fillable = ['id', 'imageable_type', 'imageable_id']; + + protected static function newFactory() + { + return new MorphOneInverseImageModelFactory(); + } + + public function imageable(): MorphTo + { + return $this->morphTo('imageable'); + } +} + +class MorphOneInverseImageModelFactory extends Factory +{ + protected $model = MorphOneInverseImageModel::class; + + public function definition() + { + return [ + 'imageable_type' => MorphOneInversePostModel::class, + 'imageable_id' => MorphOneInversePostModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php index bfdfc076c5b6..42740bc0a1a1 100755 --- a/tests/Database/DatabaseEloquentInverseRelationTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -5,6 +5,8 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\RelationNotFoundException; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsInverseRelations; use Illuminate\Database\Eloquent\Relations\Relation; use Mockery as m; @@ -23,18 +25,48 @@ public function testBuilderCallbackIsNotAppliedWhenInverseRelationIsNotSet() $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); $builder->shouldReceive('afterQuery')->never(); - new HasInverseRelationStub($builder, new HasInverseRelationParentStub(['id' => 1])); + new HasInverseRelationStub($builder, new HasInverseRelationParentStub()); } public function testInverseRelationCallbackIsNotSetIfInverseRelationIsEmpty() { $builder = m::mock(Builder::class); + + $this->expectException(RelationNotFoundException::class); $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); $builder->shouldReceive('afterQuery')->never(); (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse(''); } + public function testInverseRelationCallbackIsNotSetIfInverseRelationshipDoesNotExist() + { + $builder = m::mock(Builder::class); + + $this->expectException(RelationNotFoundException::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse('foo'); + } + + public function testWithoutInverseMethodRemovesInverseRelation() + { + $builder = m::mock(Builder::class); + + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub())); + $this->assertNull($relation->getInverseRelationship()); + + $relation->inverse('test'); + $this->assertSame('test', $relation->getInverseRelationship()); + + $relation->withoutInverse(); + $this->assertNull($relation->getInverseRelationship()); + } + public function testBuilderCallbackIsAppliedWhenInverseRelationIsSet() { $builder = m::mock(Builder::class); @@ -80,7 +112,7 @@ public function testBuilderCallbackAppliesInverseRelationToAllModelsInResult() } } - public function testInverseRelationIsNotSetIfInverseRelationNameIsEmpty() + public function testInverseRelationIsNotSetIfInverseRelationIsUnset() { $builder = m::mock(Builder::class); $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); @@ -93,28 +125,44 @@ public function testInverseRelationIsNotSetIfInverseRelationNameIsEmpty() $parent = new HasInverseRelationParentStub(); $relation = (new HasInverseRelationStub($builder, $parent)); - // Set the callback $relation->inverse('test'); - // Override the relation name to be blank - $relation->inverse(''); $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); foreach ($results as $model) { $this->assertEmpty($model->getRelations()); } $results = $afterQuery($results); + foreach ($results as $model) { + $this->assertNotEmpty($model->getRelations()); + $this->assertSame($parent, $model->getRelation('test')); + } + + // Reset the inverse relation + $relation->withoutInverse(); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } foreach ($results as $model) { $this->assertEmpty($model->getRelations()); } } } -class HasInverseRelationParentStub extends Model { +class HasInverseRelationParentStub extends Model +{ protected static $unguarded = true; } -class HasInverseRelationRelatedStub extends Model { +class HasInverseRelationRelatedStub extends Model +{ protected static $unguarded = true; + + public function test(): BelongsTo + { + return $this->belongsTo(HasInverseRelationParentStub::class); + } } class HasInverseRelationStub extends Relation @@ -122,9 +170,28 @@ class HasInverseRelationStub extends Relation use SupportsInverseRelations; // None of these methods will actually be called - they're just needed to fill out `Relation` - public function match(array $models, Collection $results, $relation) {return $models;} - public function initRelation(array $models, $relation) {return $models;} - public function getResults(){return $this->query->get();} - public function addConstraints() {} - public function addEagerConstraints(array $models) {} + public function match(array $models, Collection $results, $relation) + { + return $models; + } + + public function initRelation(array $models, $relation) + { + return $models; + } + + public function getResults() + { + return $this->query->get(); + } + + public function addConstraints() + { + // + } + + public function addEagerConstraints(array $models) + { + // + } } From cc25c9d0a9743c1dee18e2426df7ea51e3efdb8b Mon Sep 17 00:00:00 2001 From: Samuel Levy Date: Wed, 29 May 2024 14:24:08 +1000 Subject: [PATCH 3/5] [11.x] Support for automatically guessing inverse relation --- .../Concerns/SupportsInverseRelations.php | 44 ++- .../DatabaseEloquentInverseRelationTest.php | 251 +++++++++++++++++- 2 files changed, 282 insertions(+), 13 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php index 14ab9b5eefa8..7dfda3c90ced 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\RelationNotFoundException; +use Illuminate\Support\Str; trait SupportsInverseRelations { @@ -22,13 +23,15 @@ public function getInverseRelationship() /** * Links the related models back to the parent after the query has run. * - * @param string $relation + * @param string|null $relation * @return $this */ - public function inverse(string $relation) + public function inverse(?string $relation = null) { - if (! $this->getModel()->isRelation($relation)) { - throw RelationNotFoundException::make($this->getModel(), $relation); + $relation ??= $this->guessInverseRelation(); + + if (! $relation || ! $this->getModel()->isRelation($relation)) { + throw RelationNotFoundException::make($this->getModel(), $relation ?: 'null'); } if ($this->inverseRelationship === null && $relation) { @@ -57,6 +60,37 @@ public function withoutInverse() } /** + * Gets possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + return collect([ + method_exists($this, 'getMorphType') ? Str::beforeLast($this->getMorphType(), '_type') : null, + Str::camel(Str::beforeLast($this->getForeignKeyName(), $this->getParent()->getKeyName())), + Str::camel(Str::beforeLast($this->getParent()->getForeignKey(), $this->getParent()->getKeyName())), + Str::camel(class_basename($this->getParent())), + 'owner', + get_class($this->getParent()) === get_class($this->getModel()) ? 'parent' : null, + ])->filter()->unique()->values()->all(); + } + + /** + * Guesses the name of the inverse relationship. + * + * @return string|null + */ + protected function guessInverseRelation(): string|null + { + return collect($this->getPossibleInverseRelations()) + ->filter() + ->firstWhere(fn ($relation) => $this->getModel()->isRelation($relation)); + } + + /** + * Sets the inverse relation on all models in a collection. + * * @param \Illuminate\Database\Eloquent\Collection $models * @param \Illuminate\Database\Eloquent\Model|null $parent * @return \Illuminate\Database\Eloquent\Collection @@ -73,6 +107,8 @@ protected function applyInverseRelationToCollection($models, ?Model $parent = nu } /** + * Sets the inverse relation on a model. + * * @param \Illuminate\Database\Eloquent\Model $model * @param \Illuminate\Database\Eloquent\Model|null $parent * @return \Illuminate\Database\Eloquent\Model diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php index 42740bc0a1a1..198abe5d8b01 100755 --- a/tests/Database/DatabaseEloquentInverseRelationTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -9,7 +9,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsInverseRelations; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Str; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class DatabaseEloquentInverseRelationTest extends TestCase @@ -28,32 +30,31 @@ public function testBuilderCallbackIsNotAppliedWhenInverseRelationIsNotSet() new HasInverseRelationStub($builder, new HasInverseRelationParentStub()); } - public function testInverseRelationCallbackIsNotSetIfInverseRelationIsEmpty() + public function testBuilderCallbackIsNotSetIfInverseRelationIsEmptyString() { $builder = m::mock(Builder::class); - - $this->expectException(RelationNotFoundException::class); $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); $builder->shouldReceive('afterQuery')->never(); + $this->expectException(RelationNotFoundException::class); + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse(''); } - public function testInverseRelationCallbackIsNotSetIfInverseRelationshipDoesNotExist() + public function testBuilderCallbackIsNotSetIfInverseRelationshipDoesNotExist() { $builder = m::mock(Builder::class); - - $this->expectException(RelationNotFoundException::class); $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); $builder->shouldReceive('afterQuery')->never(); + $this->expectException(RelationNotFoundException::class); + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse('foo'); } public function testWithoutInverseMethodRemovesInverseRelation() { $builder = m::mock(Builder::class); - $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); @@ -69,10 +70,10 @@ public function testWithoutInverseMethodRemovesInverseRelation() public function testBuilderCallbackIsAppliedWhenInverseRelationIsSet() { + $parent = new HasInverseRelationParentStub(); + $builder = m::mock(Builder::class); $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); - - $parent = new HasInverseRelationParentStub(); $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use ($parent) { $relation = (new \ReflectionFunction($callback))->getClosureThis(); @@ -148,16 +149,205 @@ public function testInverseRelationIsNotSetIfInverseRelationIsUnset() $this->assertEmpty($model->getRelations()); } } + + public function testProvidesPossibleInverseRelationBasedOnParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $possibleRelations = ['hasInverseRelationParentStub', 'parentStub', 'owner']; + $this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations()); + } + + public function testProvidesPossibleInverseRelationBasedOnForeignKey() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id')); + + $this->assertTrue(in_array('test', $relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesPossiblePolymorphicRelationsIfRelationHasGetMorphType() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel); + + $relation = new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'fooable_type'); + + $this->assertTrue(in_array('fooable', $relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesPossibleRecursiveRelationsIfRelatedIsTheSameClassAsParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $this->assertTrue(in_array('parent', $relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesAllPossibleRelationsIfRelationHasGetMorphTypeForeignKeyAndRelatedIsTheSameClassAsParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'barable_type', 'test_id'); + + $possibleRelations = ['barable', 'test', 'parentStub', 'hasInverseRelationParentStub', 'owner', 'parent']; + $this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations()); + } + + #[DataProvider('guessedParentRelationsDataProvider')] + public function testGuessesInverseRelationBasedOnParent($guessedRelation) + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === $guessedRelation); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $this->assertSame($guessedRelation, $relation->exposeGuessInverseRelation()); + } + + public function testGuessesPossibleInverseRelationBasedOnForeignKey() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'test'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id')); + + $this->assertSame('test', $relation->exposeGuessInverseRelation()); + } + + public function testGuessesRecursiveInverseRelationsIfRelatedIsSameClassAsParent() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'parent'); + + $parent = clone $related; + $parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id'); + $parent->shouldReceive('getKeyName')->andReturn('id'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, $parent)); + + $this->assertSame('parent', $relation->exposeGuessInverseRelation()); + } + + public function testGuessesPolymorphicInverseRelationsIfRelationHasGetMorphType() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'bazable'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'bazable_type'); + + $this->assertSame('bazable', $relation->exposeGuessInverseRelation()); + } + + #[DataProvider('guessedParentRelationsDataProvider')] + public function testSetsGuessedInverseRelationBasedOnParent($guessedRelation) + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === $guessedRelation); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub))->inverse(); + + $this->assertSame($guessedRelation, $relation->getInverseRelationship()); + } + + public function testSetsRecursiveInverseRelationsIfRelatedIsSameClassAsParent() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'parent'); + + $parent = clone $related; + $parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id'); + $parent->shouldReceive('getKeyName')->andReturn('id'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, $parent))->inverse(); + + $this->assertSame('parent', $relation->getInverseRelationship()); + } + + public function testSetsPolymorphicInverseRelationsIfRelationHasGetMorphType() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'bingable'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'bingable_type'))->inverse(); + + $this->assertSame('bingable', $relation->getInverseRelationship()); + } + + public function testSetsGuessedInverseRelationBasedOnForeignKey() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'test'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id'))->inverse(); + + $this->assertSame('test', $relation->getInverseRelationship()); + } + + public static function guessedParentRelationsDataProvider() + { + yield ['hasInverseRelationParentStub']; + yield ['parentStub']; + yield ['owner']; + } } class HasInverseRelationParentStub extends Model { protected static $unguarded = true; + protected $primaryKey = 'id'; + + public function getForeignKey() + { + return 'parent_stub_id'; + } } class HasInverseRelationRelatedStub extends Model { protected static $unguarded = true; + protected $primaryKey = 'id'; + + public function getForeignKey() + { + return 'child_stub_id'; + } public function test(): BelongsTo { @@ -169,6 +359,20 @@ class HasInverseRelationStub extends Relation { use SupportsInverseRelations; + public function __construct( + Builder $query, + Model $parent, + protected ?string $foreignKey = null, + ) { + parent::__construct($query, $parent); + $this->foreignKey ??= Str::of(class_basename($parent))->snake()->finish('_id')->toString(); + } + + public function getForeignKeyName() + { + return $this->foreignKey; + } + // None of these methods will actually be called - they're just needed to fill out `Relation` public function match(array $models, Collection $results, $relation) { @@ -194,4 +398,33 @@ public function addEagerConstraints(array $models) { // } + + // Expose access to protected methods for testing + public function exposeGetPossibleInverseRelations(): array + { + return $this->getPossibleInverseRelations(); + } + + public function exposeGuessInverseRelation(): string|null + { + return $this->guessInverseRelation(); + } +} + +class HasInversePolymorphicRelationStub extends HasInverseRelationStub +{ + public function __construct( + Builder $query, + Model $parent, + protected string $morphType, + ?string $foreignKey = null, + ) { + parent::__construct($query, $parent, $foreignKey); + $this->morphType = Str::of($morphType)->snake()->finish('_type')->toString(); + } + + protected function getMorphType() + { + return $this->morphType; + } } From bea61e619b858f87385ec73ba04a47e0ba980000 Mon Sep 17 00:00:00 2001 From: Samuel Levy Date: Sat, 1 Jun 2024 10:44:48 +1000 Subject: [PATCH 4/5] [11.x] Cleaned up Morph* relation guessing --- .../Concerns/SupportsInverseRelations.php | 13 ++-- .../Eloquent/Relations/MorphOneOrMany.php | 14 ++++ ...seEloquentInverseRelationMorphManyTest.php | 67 ++++++++++++++++++ ...aseEloquentInverseRelationMorphOneTest.php | 31 +++++++++ .../DatabaseEloquentInverseRelationTest.php | 68 +------------------ 5 files changed, 120 insertions(+), 73 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php index 7dfda3c90ced..b68263062fdc 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\RelationNotFoundException; +use Illuminate\Support\Arr; use Illuminate\Support\Str; trait SupportsInverseRelations @@ -66,14 +67,13 @@ public function withoutInverse() */ protected function getPossibleInverseRelations(): array { - return collect([ - method_exists($this, 'getMorphType') ? Str::beforeLast($this->getMorphType(), '_type') : null, + return array_filter(array_unique([ Str::camel(Str::beforeLast($this->getForeignKeyName(), $this->getParent()->getKeyName())), Str::camel(Str::beforeLast($this->getParent()->getForeignKey(), $this->getParent()->getKeyName())), Str::camel(class_basename($this->getParent())), 'owner', get_class($this->getParent()) === get_class($this->getModel()) ? 'parent' : null, - ])->filter()->unique()->values()->all(); + ])); } /** @@ -83,9 +83,10 @@ protected function getPossibleInverseRelations(): array */ protected function guessInverseRelation(): string|null { - return collect($this->getPossibleInverseRelations()) - ->filter() - ->firstWhere(fn ($relation) => $this->getModel()->isRelation($relation)); + return Arr::first( + $this->getPossibleInverseRelations(), + fn ($relation) => $relation && $this->getModel()->isRelation($relation) + ); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 6d29a131130e..2c5da7b44b62 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; abstract class MorphOneOrMany extends HasOneOrMany { @@ -140,4 +141,17 @@ public function getMorphClass() { return $this->morphClass; } + + /** + * Gets possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + return array_unique([ + Str::beforeLast($this->getMorphType(), '_type'), + ...parent::getPossibleInverseRelations(), + ]); + } } diff --git a/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php index 930b22f833fd..d7e66ef906d3 100755 --- a/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php @@ -88,6 +88,36 @@ public function testMorphManyInverseRelationIsProperlySetToParentWhenEagerLoaded } } + public function testMorphManyGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedComments')); + $comments = $post->guessedComments; + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::with('guessedComments')->get(); + + foreach ($posts as $post) { + $comments = $post->getRelation('guessedComments'); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() { MorphManyInversePostModel::factory()->count(3)->withComments()->create(); @@ -115,6 +145,33 @@ public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenEag } } + public function testMorphLatestOfManyGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedLastComment')); + $comment = $post->guessedLastComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('guessedLastComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('guessedLastComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() { MorphManyInversePostModel::factory()->count(3)->withComments()->create(); @@ -249,11 +306,21 @@ public function comments(): MorphMany return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse('commentable'); } + public function guessedComments(): MorphMany + { + return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse(); + } + public function lastComment(): MorphOne { return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse('commentable'); } + public function guessedLastComment(): MorphOne + { + return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse(); + } + public function firstComment(): MorphOne { return $this->comments()->one(); diff --git a/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php index 06350658383f..25ee5baf9e81 100755 --- a/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php @@ -83,6 +83,32 @@ public function testMorphOneInverseRelationIsProperlySetToParentWhenEagerLoaded( } } + public function testMorphOneGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedImage')); + $image = $post->guessedImage; + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::with('guessedImage')->get(); + + foreach ($posts as $post) { + $image = $post->getRelation('guessedImage'); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + public function testMorphOneInverseRelationIsProperlySetToParentWhenMaking() { $post = MorphOneInversePostModel::create(); @@ -201,6 +227,11 @@ public function image(): MorphOne { return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse('imageable'); } + + public function guessedImage(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse(); + } } class MorphOneInversePostModelFactory extends Factory diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php index 198abe5d8b01..100921ea18da 100755 --- a/tests/Database/DatabaseEloquentInverseRelationTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -158,7 +158,7 @@ public function testProvidesPossibleInverseRelationBasedOnParent() $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); $possibleRelations = ['hasInverseRelationParentStub', 'parentStub', 'owner']; - $this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations()); + $this->assertSame($possibleRelations, array_values($relation->exposeGetPossibleInverseRelations())); } public function testProvidesPossibleInverseRelationBasedOnForeignKey() @@ -171,16 +171,6 @@ public function testProvidesPossibleInverseRelationBasedOnForeignKey() $this->assertTrue(in_array('test', $relation->exposeGetPossibleInverseRelations())); } - public function testProvidesPossiblePolymorphicRelationsIfRelationHasGetMorphType() - { - $builder = m::mock(Builder::class); - $builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel); - - $relation = new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'fooable_type'); - - $this->assertTrue(in_array('fooable', $relation->exposeGetPossibleInverseRelations())); - } - public function testProvidesPossibleRecursiveRelationsIfRelatedIsTheSameClassAsParent() { $builder = m::mock(Builder::class); @@ -191,17 +181,6 @@ public function testProvidesPossibleRecursiveRelationsIfRelatedIsTheSameClassAsP $this->assertTrue(in_array('parent', $relation->exposeGetPossibleInverseRelations())); } - public function testProvidesAllPossibleRelationsIfRelationHasGetMorphTypeForeignKeyAndRelatedIsTheSameClassAsParent() - { - $builder = m::mock(Builder::class); - $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); - - $relation = new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'barable_type', 'test_id'); - - $possibleRelations = ['barable', 'test', 'parentStub', 'hasInverseRelationParentStub', 'owner', 'parent']; - $this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations()); - } - #[DataProvider('guessedParentRelationsDataProvider')] public function testGuessesInverseRelationBasedOnParent($guessedRelation) { @@ -246,19 +225,6 @@ public function testGuessesRecursiveInverseRelationsIfRelatedIsSameClassAsParent $this->assertSame('parent', $relation->exposeGuessInverseRelation()); } - public function testGuessesPolymorphicInverseRelationsIfRelationHasGetMorphType() - { - $related = m::mock(Model::class); - $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'bazable'); - - $builder = m::mock(Builder::class); - $builder->shouldReceive('getModel')->andReturn($related); - - $relation = new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'bazable_type'); - - $this->assertSame('bazable', $relation->exposeGuessInverseRelation()); - } - #[DataProvider('guessedParentRelationsDataProvider')] public function testSetsGuessedInverseRelationBasedOnParent($guessedRelation) { @@ -292,20 +258,6 @@ public function testSetsRecursiveInverseRelationsIfRelatedIsSameClassAsParent() $this->assertSame('parent', $relation->getInverseRelationship()); } - public function testSetsPolymorphicInverseRelationsIfRelationHasGetMorphType() - { - $related = m::mock(Model::class); - $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'bingable'); - - $builder = m::mock(Builder::class); - $builder->shouldReceive('getModel')->andReturn($related); - $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); - - $relation = (new HasInversePolymorphicRelationStub($builder, new HasInverseRelationParentStub, 'bingable_type'))->inverse(); - - $this->assertSame('bingable', $relation->getInverseRelationship()); - } - public function testSetsGuessedInverseRelationBasedOnForeignKey() { $related = m::mock(Model::class); @@ -410,21 +362,3 @@ public function exposeGuessInverseRelation(): string|null return $this->guessInverseRelation(); } } - -class HasInversePolymorphicRelationStub extends HasInverseRelationStub -{ - public function __construct( - Builder $query, - Model $parent, - protected string $morphType, - ?string $foreignKey = null, - ) { - parent::__construct($query, $parent, $foreignKey); - $this->morphType = Str::of($morphType)->snake()->finish('_type')->toString(); - } - - protected function getMorphType() - { - return $this->morphType; - } -} From 98400bf2821bdacc2a7a92fe4a96d7f3a9bef127 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Wed, 21 Aug 2024 22:31:28 -0500 Subject: [PATCH 5/5] add alias method --- .../Concerns/SupportsInverseRelations.php | 86 +++++++++++++------ .../Eloquent/Relations/HasOneOrMany.php | 3 +- .../Eloquent/Relations/MorphOneOrMany.php | 2 +- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php index b68263062fdc..fa0161520728 100644 --- a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -9,25 +9,33 @@ trait SupportsInverseRelations { + /** + * The name of the inverse relationship. + * + * @var string|null + */ protected string|null $inverseRelationship = null; /** - * Gets the name of the inverse relationship. + * Instruct Eloquent to link the related models back to the parent after the relationship query has run. * - * @return string|null + * Alias of "chaperone". + * + * @param string|null $relation + * @return $this */ - public function getInverseRelationship() + public function inverse(?string $relation = null) { - return $this->inverseRelationship; + return $this->chaperone($relation); } /** - * Links the related models back to the parent after the query has run. + * Instruct Eloquent to link the related models back to the parent after the relationship query has run. * * @param string|null $relation * @return $this */ - public function inverse(?string $relation = null) + public function chaperone(?string $relation = null) { $relation ??= $this->guessInverseRelation(); @@ -49,19 +57,20 @@ public function inverse(?string $relation = null) } /** - * Removes the inverse relationship for this query. + * Guess the name of the inverse relationship. * - * @return $this + * @return string|null */ - public function withoutInverse() + protected function guessInverseRelation(): string|null { - $this->inverseRelationship = null; - - return $this; + return Arr::first( + $this->getPossibleInverseRelations(), + fn ($relation) => $relation && $this->getModel()->isRelation($relation) + ); } /** - * Gets possible inverse relations for the parent model. + * Get the possible inverse relations for the parent model. * * @return array */ @@ -77,20 +86,7 @@ protected function getPossibleInverseRelations(): array } /** - * Guesses the name of the inverse relationship. - * - * @return string|null - */ - protected function guessInverseRelation(): string|null - { - return Arr::first( - $this->getPossibleInverseRelations(), - fn ($relation) => $relation && $this->getModel()->isRelation($relation) - ); - } - - /** - * Sets the inverse relation on all models in a collection. + * Set the inverse relation on all models in a collection. * * @param \Illuminate\Database\Eloquent\Collection $models * @param \Illuminate\Database\Eloquent\Model|null $parent @@ -108,7 +104,7 @@ protected function applyInverseRelationToCollection($models, ?Model $parent = nu } /** - * Sets the inverse relation on a model. + * Set the inverse relation on a model. * * @param \Illuminate\Database\Eloquent\Model $model * @param \Illuminate\Database\Eloquent\Model|null $parent @@ -124,4 +120,38 @@ protected function applyInverseRelationToModel(Model $model, ?Model $parent = nu return $model; } + + /** + * Get the name of the inverse relationship. + * + * @return string|null + */ + public function getInverseRelationship() + { + return $this->inverseRelationship; + } + + /** + * Remove the chaperone / inverse relationship for this query. + * + * Alias of "withoutChaperone". + * + * @return $this + */ + public function withoutInverse() + { + return $this->withoutChaperone(); + } + + /** + * Remove the chaperone / inverse relationship for this query. + * + * @return $this + */ + public function withoutChaperone() + { + $this->inverseRelationship = null; + + return $this; + } } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 6461a6069fb5..24344cfa4905 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -156,7 +156,7 @@ protected function matchOneOrMany(array $models, Collection $results, $relation, $related = $this->getRelationValue($dictionary, $key, $type); $model->setRelation($relation, $related); - // Apply the inverse relation if we have one + // Apply the inverse relation if we have one... $type === 'one' ? $this->applyInverseRelationToModel($related, $model) : $this->applyInverseRelationToCollection($related, $model); @@ -423,6 +423,7 @@ public function createManyQuietly(iterable $records) protected function setForeignAttributesForCreate(Model $model) { $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); + $this->applyInverseRelationToModel($model); } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 2c5da7b44b62..663578c88fc1 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -143,7 +143,7 @@ public function getMorphClass() } /** - * Gets possible inverse relations for the parent model. + * Get the possible inverse relations for the parent model. * * @return array */