diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php index 14ab9b5eefa..7dfda3c90ce 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 42740bc0a1a..198abe5d8b0 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; + } }