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..fa0161520728 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -0,0 +1,157 @@ +chaperone($relation); + } + + /** + * 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 chaperone(?string $relation = null) + { + $relation ??= $this->guessInverseRelation(); + + if (! $relation || ! $this->getModel()->isRelation($relation)) { + throw RelationNotFoundException::make($this->getModel(), $relation ?: 'null'); + } + + if ($this->inverseRelationship === null && $relation) { + $this->query->afterQuery(function ($result) { + return $this->inverseRelationship + ? $this->applyInverseRelationToCollection($result, $this->getParent()) + : $result; + }); + } + + $this->inverseRelationship = $relation; + + return $this; + } + + /** + * Guess 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) + ); + } + + /** + * Get the possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + 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, + ])); + } + + /** + * Set 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 + */ + protected function applyInverseRelationToCollection($models, ?Model $parent = null) + { + $parent ??= $this->getParent(); + + foreach ($models as $model) { + $this->applyInverseRelationToModel($model, $parent); + } + + return $models; + } + + /** + * Set the inverse relation on a model. + * + * @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; + } + + /** + * 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/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..24344cfa4905 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,8 @@ 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..663578c88fc1 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 { @@ -78,7 +79,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 +93,8 @@ protected function setForeignAttributesForCreate(Model $model) $model->{$this->getForeignKeyName()} = $this->getParentKey(); $model->{$this->getMorphType()} = $this->morphClass; + + $this->applyInverseRelationToModel($model); } /** @@ -138,4 +141,17 @@ public function getMorphClass() { return $this->morphClass; } + + /** + * Get the 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/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..d7e66ef906d3 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php @@ -0,0 +1,376 @@ +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 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(); + $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 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(); + $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 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(); + } +} + +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..25ee5baf9e81 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php @@ -0,0 +1,276 @@ +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 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(); + + $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'); + } + + public function guessedImage(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse(); + } +} + +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 new file mode 100755 index 000000000000..100921ea18da --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -0,0 +1,364 @@ +shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + new HasInverseRelationStub($builder, new HasInverseRelationParentStub()); + } + + public function testBuilderCallbackIsNotSetIfInverseRelationIsEmptyString() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + $this->expectException(RelationNotFoundException::class); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse(''); + } + + public function testBuilderCallbackIsNotSetIfInverseRelationshipDoesNotExist() + { + $builder = m::mock(Builder::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(); + + $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() + { + $parent = new HasInverseRelationParentStub(); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $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 testInverseRelationIsNotSetIfInverseRelationIsUnset() + { + $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)); + $relation->inverse('test'); + + $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()); + } + } + + 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, array_values($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 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())); + } + + #[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()); + } + + #[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 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 + { + return $this->belongsTo(HasInverseRelationParentStub::class); + } +} + +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) + { + 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) + { + // + } + + // Expose access to protected methods for testing + public function exposeGetPossibleInverseRelations(): array + { + return $this->getPossibleInverseRelations(); + } + + public function exposeGuessInverseRelation(): string|null + { + return $this->guessInverseRelation(); + } +}