From e0a54d83fd8765674c7a4bcb1ae4f80e2892d0f6 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sun, 18 Oct 2026 01:30:50 +0600 Subject: [PATCH 1/2] Optional relationship keys and typed relation classes --- src/Phaseolies/Database/Entity/Model.php | 163 +++++++++--- .../Database/Entity/Relations/BindTo.php | 10 + .../Database/Entity/Relations/BindToMany.php | 10 + .../Database/Entity/Relations/LinkMany.php | 10 + .../Database/Entity/Relations/LinkOne.php | 10 + .../Relations/RelationDefaultsBehavior.php | 231 ++++++++++++++++++ .../Relations/RelationDefaultsMysqlTest.php | 17 ++ .../Relations/RelationDefaultsPgsqlTest.php | 17 ++ .../Relations/RelationDefaultsSqliteTest.php | 13 + 9 files changed, 441 insertions(+), 40 deletions(-) create mode 100644 src/Phaseolies/Database/Entity/Relations/BindTo.php create mode 100644 src/Phaseolies/Database/Entity/Relations/BindToMany.php create mode 100644 src/Phaseolies/Database/Entity/Relations/LinkMany.php create mode 100644 src/Phaseolies/Database/Entity/Relations/LinkOne.php create mode 100644 tests/Model/Relations/RelationDefaultsBehavior.php create mode 100644 tests/Model/Relations/RelationDefaultsMysqlTest.php create mode 100644 tests/Model/Relations/RelationDefaultsPgsqlTest.php create mode 100644 tests/Model/Relations/RelationDefaultsSqliteTest.php diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index e1f62151..841bfe12 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -13,6 +13,10 @@ use Phaseolies\Database\Database; use Phaseolies\Database\Contracts\Support\Jsonable; use Phaseolies\Database\Entity\Attributes\Hook; +use Phaseolies\Database\Entity\Relations\BindTo; +use Phaseolies\Database\Entity\Relations\BindToMany; +use Phaseolies\Database\Entity\Relations\LinkMany; +use Phaseolies\Database\Entity\Relations\LinkOne; /** * @phpstan-consistent-constructor @@ -829,81 +833,102 @@ public function getLastPivotTable(): ?string * Define a one-to-one relationship * * @param string $related - * @param string $foreignKey - * @param string $localKey - * @return \Phaseolies\Database\Entity\Builder + * @param string|null $foreignKey + * @param string|null $localKey + * @return LinkOne */ - public function linkOne(string $related, string $foreignKey, string $localKey) + public function linkOne(string $related, ?string $foreignKey = null, ?string $localKey = null): LinkOne { - $this->lastRelationType = 'linkOne'; - $this->lastRelatedModel = $related; - $this->lastForeignKey = $foreignKey; - $this->lastLocalKey = $localKey; + $foreignKey ??= $this->defaultForeignKey(); + $localKey ??= $this->getKeyName(); - $relatedInstance = app($related); + $this->rememberRelation('linkOne', $related, $foreignKey, $localKey); + + $query = $this->newRelationBuilder(LinkOne::class, $related); + $query->where($foreignKey, '=', $this->$localKey); - return $relatedInstance->query()->where($foreignKey, '=', $this->$localKey); + return $query; } /** - * Define a one-to-one relationship + * Define the inverse of a one-to-one or one-to-many relationship * * @param string $related - * @param string $foreignKey - * @param string $localKey - * @return \Phaseolies\Database\Entity\Builder + * @param string|null $foreignKey + * @param string|null $localKey + * @return BindTo */ - public function bindTo(string $related, string $foreignKey, string $localKey) + public function bindTo(string $related, ?string $foreignKey = null, ?string $localKey = null): BindTo { - $this->lastRelationType = 'bindTo'; - $this->lastRelatedModel = $related; - $this->lastForeignKey = $foreignKey; - $this->lastLocalKey = $localKey; - $relatedInstance = app($related); - return $relatedInstance->query()->where($foreignKey, '=', $this->$localKey); + $foreignKey ??= $relatedInstance->getKeyName(); + $localKey ??= self::snakeCase(class_basename($related)) . '_' . $relatedInstance->getKeyName(); + + $this->rememberRelation('bindTo', $related, $foreignKey, $localKey); + + $query = $this->newRelationBuilder(BindTo::class, $related); + $query->where($foreignKey, '=', $this->$localKey); + + return $query; } /** * Define a one-to-many relationship * * @param string $related - * @param string $foreignKey - * @param string $localKey - * @return \Phaseolies\Database\Entity\Builder + * @param string|null $foreignKey + * @param string|null $localKey + * @return LinkMany */ - public function linkMany(string $related, string $foreignKey, string $localKey) + public function linkMany(string $related, ?string $foreignKey = null, ?string $localKey = null): LinkMany { - $this->lastRelationType = 'linkMany'; - $this->lastRelatedModel = $related; - $this->lastForeignKey = $foreignKey; - $this->lastLocalKey = $localKey; + $foreignKey ??= $this->defaultForeignKey(); + $localKey ??= $this->getKeyName(); - $relatedInstance = app($related); + $this->rememberRelation('linkMany', $related, $foreignKey, $localKey); - return $relatedInstance->query()->where($foreignKey, '=', $this->$localKey); + $query = $this->newRelationBuilder(LinkMany::class, $related); + $query->where($foreignKey, '=', $this->$localKey); + + return $query; } /** * Define a many-to-many relationship * * @param string $related - * @param string $foreignKey - * @param string $relatedKey - * @param string $pivotTable - * @return \Phaseolies\Database\Entity\Builder - */ - public function bindToMany(string $related, string $foreignKey, string $relatedKey, string $pivotTable) - { + * @param string|null $foreignKey + * @param string|null $relatedKey + * @param string|null $pivotTable + * @return BindToMany + */ + public function bindToMany( + string $related, + ?string $foreignKey = null, + ?string $relatedKey = null, + ?string $pivotTable = null + ): BindToMany { + $relatedModel = app($related); + + $thisName = self::snakeCase(class_basename(static::class)); + $relatedName = self::snakeCase(class_basename($related)); + + $foreignKey ??= $this->defaultForeignKey(); + $relatedKey ??= $relatedName . '_' . $relatedModel->getKeyName(); + if ($pivotTable === null) { + $names = [$thisName, $relatedName]; + sort($names); + $pivotTable = implode('_', $names); + } + $this->lastRelationType = 'bindToMany'; $this->lastRelatedModel = $related; $this->lastForeignKey = $foreignKey; $this->lastRelatedKey = $relatedKey; $this->lastPivotTable = $pivotTable; - $relatedModel = app($related); - $query = $relatedModel->query(); + $query = $this->newRelationBuilder(BindToMany::class, $related); $query->setRelationInfo([ 'type' => 'bindToMany', @@ -916,6 +941,64 @@ public function bindToMany(string $related, string $foreignKey, string $relatedK return $query; } + /** + * Remember the relationship that was just defined, for eager loading + * + * @param string $type + * @param string $related + * @param string $foreignKey + * @param string $localKey + * @return void + */ + private function rememberRelation(string $type, string $related, string $foreignKey, string $localKey): void + { + $this->lastRelationType = $type; + $this->lastRelatedModel = $related; + $this->lastForeignKey = $foreignKey; + $this->lastLocalKey = $localKey; + } + + /** + * The conventional foreign key that points at this model, for example user_id + * + * @return string + */ + private function defaultForeignKey(): string + { + return self::snakeCase(class_basename(static::class)) . '_' . $this->getKeyName(); + } + + /** + * Create a query on the related model that has the given relationship type + * + * @template T of Builder + * @param class-string $builder + * @param string $related + * @return T + */ + private function newRelationBuilder(string $builder, string $related): Builder + { + $relatedInstance = app($related); + $connection = $relatedInstance->connection; + + return new $builder( + Database::getPdoInstance($connection), + $relatedInstance->getTable(), + $related, + $relatedInstance->pageSize, + $connection + ); + } + + /** + * @param string $value + * @return string + */ + private static function snakeCase(string $value): string + { + return strtolower((string) preg_replace('/(? [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'bios' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'writer_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'line', 'type' => 'string'], + ], + 'articles' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ], + 'topics' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'article_topic' => [ + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'topic_id', 'type' => 'integer', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + return [ + 'authors' => [['name' => 'Ann'], ['name' => 'Ben']], + 'bios' => [['author_id' => 1, 'writer_id' => 2, 'line' => 'Ann writes']], + 'articles' => [ + ['author_id' => 1, 'title' => 'Alpha'], + ['author_id' => 1, 'title' => 'Beta'], + ['author_id' => 2, 'title' => 'Gamma'], + ], + 'topics' => [['name' => 'php'], ['name' => 'sql']], + 'article_topic' => [ + ['article_id' => 1, 'topic_id' => 1], + ['article_id' => 1, 'topic_id' => 2], + ['article_id' => 2, 'topic_id' => 2], + ], + ]; + } + + public function testRelationMethodsCanBeTypeHintedWithRelationClasses(): void + { + foreach ([ + [Author::class, 'bio', LinkOne::class], + [Author::class, 'articles', LinkMany::class], + [Bio::class, 'author', BindTo::class], + [Article::class, 'topics', BindToMany::class], + ] as [$model, $method, $type]) { + $returnType = (new \ReflectionMethod($model, $method))->getReturnType(); + $this->assertSame($type, $returnType->getName()); + } + + $author = Author::find(1); + + $this->assertInstanceOf(LinkOne::class, $author->bio()); + $this->assertInstanceOf(LinkMany::class, $author->articles()); + $this->assertInstanceOf(Builder::class, $author->bio(), 'relation classes are query builders'); + $this->assertInstanceOf(BindTo::class, Bio::find(1)->author()); + $this->assertInstanceOf(BindToMany::class, Article::find(1)->topics()); + } + + public function testLinkOneAndLinkManyDefaultTheirKeys(): void + { + $author = Author::find(1); + + $this->assertSame('Ann writes', $author->bio->line); + $this->assertSame(['Alpha', 'Beta'], $author->articles->pluck('title')->all()); + $this->assertSame(['Gamma'], Author::find(2)->articles->pluck('title')->all()); + $this->assertNull(Author::find(2)->bio); + } + + public function testBindToDefaultsItsKeys(): void + { + $this->assertSame('Ann', Bio::find(1)->author->name); + $this->assertSame('Ben', Article::find(3)->author->name); + } + + public function testBindToManyDefaultsKeysAndPivotTableInBothDirections(): void + { + $this->assertSame(['php', 'sql'], Article::find(1)->topics->pluck('name')->all()); + $this->assertSame(['sql'], Article::find(2)->topics->pluck('name')->all()); + $this->assertSame(['Alpha'], Topic::find(1)->articles->pluck('title')->all()); + $this->assertSame(['Alpha', 'Beta'], Topic::find(2)->articles->pluck('title')->all()); + } + + public function testExplicitKeysStillWork(): void + { + $author = Author::find(1); + + $this->assertSame('Ann writes', $author->explicitBio->line); + $this->assertSame(['Alpha', 'Beta'], $author->explicitArticles->pluck('title')->all()); + $this->assertSame('Ann', Bio::find(1)->explicitAuthor->name); + $this->assertSame(['php', 'sql'], Article::find(1)->explicitTopics->pluck('name')->all()); + } + + public function testOnlyTheForeignKeyCanBeGiven(): void + { + $this->assertSame('Ann writes', Author::find(2)->bioByWriter->line); + } + + public function testEagerLoadingWorksWithDefaultedRelations(): void + { + $authors = Author::embed('bio')->embed('articles')->get(); + + $this->assertSame('Ann writes', $authors[0]->bio->line); + $this->assertSame(['Alpha', 'Beta'], $authors[0]->articles->pluck('title')->all()); + $this->assertSame(['Gamma'], $authors[1]->articles->pluck('title')->all()); + + $articles = Article::embed('topics')->get(); + $this->assertSame(['php', 'sql'], $articles[0]->topics->pluck('name')->all()); + } + + public function testRelationQueriesCanStillBeConstrained(): void + { + $this->assertSame(['Beta'], Author::find(1)->articles()->where('title', 'Beta')->get()->pluck('title')->all()); + $this->assertSame(1, Author::find(1)->articles()->where('title', 'Alpha')->count()); + } +} + +class Author extends Model +{ + protected $table = 'authors'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['name']; + + public function bio(): LinkOne + { + return $this->linkOne(Bio::class); + } + + public function articles(): LinkMany + { + return $this->linkMany(Article::class); + } + + public function explicitBio(): LinkOne + { + return $this->linkOne(Bio::class, 'author_id', 'id'); + } + + public function explicitArticles(): LinkMany + { + return $this->linkMany(Article::class, 'author_id', 'id'); + } + + public function bioByWriter(): LinkOne + { + return $this->linkOne(Bio::class, 'writer_id'); + } +} + +class Bio extends Model +{ + protected $table = 'bios'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['author_id', 'line']; + + public function author(): BindTo + { + return $this->bindTo(Author::class); + } + + public function explicitAuthor(): BindTo + { + return $this->bindTo(Author::class, 'id', 'author_id'); + } +} + +class Article extends Model +{ + protected $table = 'articles'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['author_id', 'title']; + + public function author(): BindTo + { + return $this->bindTo(Author::class); + } + + public function topics(): BindToMany + { + return $this->bindToMany(Topic::class); + } + + public function explicitTopics(): BindToMany + { + return $this->bindToMany(Topic::class, 'article_id', 'topic_id', 'article_topic'); + } +} + +class Topic extends Model +{ + protected $table = 'topics'; + protected $connection = 'default'; + protected $timeStamps = false; + protected $creatable = ['name']; + + public function articles(): BindToMany + { + return $this->bindToMany(Article::class); + } +} diff --git a/tests/Model/Relations/RelationDefaultsMysqlTest.php b/tests/Model/Relations/RelationDefaultsMysqlTest.php new file mode 100644 index 00000000..6c76e8cd --- /dev/null +++ b/tests/Model/Relations/RelationDefaultsMysqlTest.php @@ -0,0 +1,17 @@ + Date: Sun, 18 Oct 2026 01:35:00 +0600 Subject: [PATCH 2/2] PHPDoc convetion missing for snake_case function --- src/Phaseolies/Database/Entity/Model.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index 841bfe12..fa0dd786 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -991,6 +991,8 @@ private function newRelationBuilder(string $builder, string $related): Builder } /** + * Convert a string to snake_case. + * * @param string $value * @return string */