Skip to content

Commit

Permalink
[11.x] Support for automatically guessing inverse relation
Browse files Browse the repository at this point in the history
  • Loading branch information
samlev committed May 31, 2024
1 parent 85da77e commit 58a9f01
Show file tree
Hide file tree
Showing 2 changed files with 282 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\RelationNotFoundException;
use Illuminate\Support\Str;

trait SupportsInverseRelations
{
Expand All @@ -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) {
Expand Down Expand Up @@ -57,6 +60,37 @@ public function withoutInverse()
}

/**
* Gets possible inverse relations for the parent model.
*
* @return array<non-empty-string>
*/
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
Expand All @@ -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
Expand Down
251 changes: 242 additions & 9 deletions tests/Database/DatabaseEloquentInverseRelationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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
{
Expand All @@ -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)
{
Expand All @@ -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;
}
}

0 comments on commit 58a9f01

Please sign in to comment.