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 29, 2024
1 parent 94ebe80 commit 2cf444f
Show file tree
Hide file tree
Showing 2 changed files with 230 additions and 8 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,41 @@ public function withoutInverse()
}

/**
* Gets possible inverse relations for the parent model.
*
* @return array<non-empty-string>
*/
protected function getPossibleInverseRelations(): array
{
$possibleInverseRelations = [
Str::camel(Str::beforeLast($this->getParent()->getForeignKey(), $this->getParent()->getKeyName())),
Str::camel(class_basename($this->getParent())),
'ownedBy',
'owner',
];

if (get_class($this->getParent()) === get_class($this->getModel())) {
array_push($possibleInverseRelations, 'parent', 'ancestor');
}

return array_filter($possibleInverseRelations);
}

/**
* 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 +111,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
190 changes: 186 additions & 4 deletions tests/Database/DatabaseEloquentInverseRelationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Relations\Concerns\SupportsInverseRelations;
use Illuminate\Database\Eloquent\Relations\Relation;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class DatabaseEloquentInverseRelationTest extends TestCase
Expand All @@ -28,7 +29,7 @@ public function testBuilderCallbackIsNotAppliedWhenInverseRelationIsNotSet()
new HasInverseRelationStub($builder, new HasInverseRelationParentStub());
}

public function testInverseRelationCallbackIsNotSetIfInverseRelationIsEmpty()
public function testBuilderCallbackIsNotSetIfInverseRelationIsEmptyString()
{
$builder = m::mock(Builder::class);

Expand All @@ -39,7 +40,7 @@ public function testInverseRelationCallbackIsNotSetIfInverseRelationIsEmpty()
(new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse('');
}

public function testInverseRelationCallbackIsNotSetIfInverseRelationshipDoesNotExist()
public function testBuilderCallbackIsNotSetIfInverseRelationshipDoesNotExist()
{
$builder = m::mock(Builder::class);

Expand Down Expand Up @@ -90,7 +91,7 @@ public function testBuilderCallbackAppliesInverseRelationToAllModelsInResult()
// 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;
return (bool)$afterQuery = $callback;
})->once()->andReturnSelf();

$parent = new HasInverseRelationParentStub();
Expand Down Expand Up @@ -120,7 +121,7 @@ public function testInverseRelationIsNotSetIfInverseRelationIsUnset()
// 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;
return (bool)$afterQuery = $callback;
})->once()->andReturnSelf();

$parent = new HasInverseRelationParentStub();
Expand Down Expand Up @@ -148,16 +149,186 @@ public function testInverseRelationIsNotSetIfInverseRelationIsUnset()
$this->assertEmpty($model->getRelations());
}
}

public function testProvidesPossibleRelationBasedOnParent()
{
$builder = m::mock(Builder::class);
$builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel);

$parent = new HasInverseRelationParentStub;
$relation = (new HasInverseRelationStub($builder, $parent));

$possibleRelations = ['parentStub', 'hasInverseRelationParentStub', 'ownedBy', 'owner'];
$this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations());
}

public function testProvidesPossibleRecursiveRelationsIfRelatedIsTheSameClassAsParent()
{
$builder = m::mock(Builder::class);
$builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub);

$parent = new HasInverseRelationParentStub;
$relation = (new HasInverseRelationStub($builder, $parent));

$possibleRelations = ['parentStub', 'hasInverseRelationParentStub', 'ownedBy', 'owner', 'parent', 'ancestor'];
$this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations());
}

public function testDoesNotProvidePossibleRecursiveRelationsIfRelatedIsNotTheSameClassAsParent()
{
$builder = m::mock(Builder::class);
$builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel);

$parent = new HasInverseRelationParentStub;
$relation = (new HasInverseRelationStub($builder, $parent));

$possibleRelations = ['parentStub', 'hasInverseRelationParentStub', 'ownedBy', 'owner'];
$this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations());
}

public function testDoesNotProvidePossibleRecursiveRelationsIfRelatedClassIsAncestorOfParent()
{
$builder = m::mock(Builder::class);
$builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub);

$parent = new HasInverseRelationParentSubclassStub;
$relation = (new HasInverseRelationStub($builder, $parent));

$possibleRelations = ['parentStub', 'hasInverseRelationParentSubclassStub', 'ownedBy', 'owner'];
$this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations());
}

public function testDoesNotProvidePossibleRecursiveRelationsIfRelatedClassIsSubclassOfParent()
{
$builder = m::mock(Builder::class);
$builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentSubclassStub);

$parent = new HasInverseRelationParentStub;
$relation = (new HasInverseRelationStub($builder, $parent));

$possibleRelations = ['parentStub', 'hasInverseRelationParentStub', 'ownedBy', 'owner'];
$this->assertSame($possibleRelations, $relation->exposeGetPossibleInverseRelations());
}

#[DataProvider('guessedParentRelationsDataProvider')]
public function testGuessesInverseRelationBasedOnParent($guessedRelation)
{
$builder = m::mock(Builder::class);
$related = m::mock(Model::class);
$builder->shouldReceive('getModel')->andReturn($related);

$parent = new HasInverseRelationParentStub;
$related->shouldReceive('isRelation')->andReturnUsing(fn($relation) => $relation === $guessedRelation);
$relation = (new HasInverseRelationStub($builder, $parent));

$this->assertSame($guessedRelation, $relation->exposeGuessInverseRelation());
}

#[DataProvider('guessedRecursiveRelationsDataProvider')]
public function testGuessesRecursiveInverseRelationsIfRelatedIsSameClassAsParent($guessedRelation)
{
$builder = m::mock(Builder::class);
$related = m::mock(Model::class);
$builder->shouldReceive('getModel')->andReturn($related);

$parent = clone $related;
$parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id');
$parent->shouldReceive('getKeyName')->andReturn('id');
$related->shouldReceive('isRelation')->andReturnUsing(fn($relation) => $relation === $guessedRelation);

$relation = (new HasInverseRelationStub($builder, $parent));

$this->assertSame($guessedRelation, $relation->exposeGuessInverseRelation());
}

#[DataProvider('guessedRecursiveRelationsDataProvider')]
public function testDoesNotGuessRecursiveInverseRelationsIfRelatedIsNotSameClassAsParent($guessedRelation)
{
$builder = m::mock(Builder::class);
$related = m::mock(Model::class);
$builder->shouldReceive('getModel')->andReturn($related);

$related->shouldReceive('isRelation')->andReturn(false);
$related->shouldReceive('isRelation')->with($guessedRelation)->never();

$relation = new HasInverseRelationStub($builder, new HasInverseRelationParentStub);

$this->assertNull($relation->exposeGuessInverseRelation());
}

#[DataProvider('guessedParentRelationsDataProvider')]
public function testSetsGuessedInverseRelationBasedOnParent($guessedRelation)
{
$builder = m::mock(Builder::class);
$related = m::mock(Model::class);
$builder->shouldReceive('getModel')->andReturn($related);

$parent = new HasInverseRelationParentStub;
$builder->shouldReceive('afterQuery')->once()->andReturnSelf();
$related->shouldReceive('isRelation')->andReturnUsing(fn($relation) => $relation === $guessedRelation);
$relation = (new HasInverseRelationStub($builder, $parent))->inverse();

$this->assertSame($guessedRelation, $relation->getInverseRelationship());
}

#[DataProvider('guessedRecursiveRelationsDataProvider')]
public function testDoesNotSetRecursiveInverseRelationsIfRelatedIsNotSameClassAsParent($guessedRelation)
{
$builder = m::mock(Builder::class);
$related = m::mock(Model::class);
$builder->shouldReceive('getModel')->andReturn($related);

$parent = new HasInverseRelationParentStub;
$builder->shouldReceive('afterQuery')->never();
foreach (self::guessedParentRelationsDataProvider() as $notRelated) {
$related->shouldReceive('isRelation')->with($notRelated[0])->once()->andReturn(false);
}

$related->shouldReceive('isRelation')->with($guessedRelation)->never();
$this->expectException(RelationNotFoundException::class);
$relation = (new HasInverseRelationStub($builder, $parent))->inverse();

$this->assertNull($relation->getInverseRelationship());
}

public static function guessedParentRelationsDataProvider()
{
yield ['parentStub'];
yield ['hasInverseRelationParentStub'];
yield ['ownedBy'];
yield ['owner'];
}

public static function guessedRecursiveRelationsDataProvider()
{
yield ['parent'];
yield ['ancestor'];
}
}

class HasInverseRelationParentStub extends Model
{
protected static $unguarded = true;
protected $primaryKey = 'id';

public function getForeignKey()
{
return 'parent_stub_id';
}
}
class HasInverseRelationParentSubclassStub extends HasInverseRelationParentStub
{
}

class HasInverseRelationRelatedStub extends Model
{
protected static $unguarded = true;
protected $primaryKey = 'id';

public function getForeignKey()
{
return 'child_stub_id';
}

public function test(): BelongsTo
{
Expand Down Expand Up @@ -194,4 +365,15 @@ 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();
}
}

0 comments on commit 2cf444f

Please sign in to comment.