From 478f7ec359351c1824e88728b011b9226b7c1dd2 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 10:21:36 +0000 Subject: [PATCH 01/14] fix(database): use property name as table alias for eager relations When a model has multiple BelongsTo relations to the same table, the query builder generated duplicate JOINs with the same bare table name. Fix by using the property name as the SQL alias for root-level eager relations (parent='') in HasTableAlias::getTableAlias(). Add getOwnerTableAlias() so nested relations reference their parent's aliased name instead of the raw table name. Applied consistently across BelongsTo, HasOne, HasMany, and BelongsToMany relation types. Fixes #2079 --- packages/database/src/BelongsTo.php | 34 ++++++++++++++++++++---- packages/database/src/BelongsToMany.php | 5 ++-- packages/database/src/HasMany.php | 5 ++-- packages/database/src/HasManyThrough.php | 5 ++-- packages/database/src/HasOne.php | 5 ++-- packages/database/src/HasOneThrough.php | 5 ++-- packages/database/src/HasTableAlias.php | 34 +++++++++++++++++++++++- 7 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 437d9b2ef..2db882d8a 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -105,7 +105,7 @@ public function getJoinStatement(): JoinStatement ? sprintf('%s AS %s', $tableName, $tableAlias) : $tableName; - // LEFT JOIN authors ON authors.id = books.author_id + // LEFT JOIN authors AS author ON author.id = books.author_id return new JoinStatement(sprintf( 'LEFT JOIN %s ON %s = %s', $tableRef, @@ -117,6 +117,7 @@ public function getJoinStatement(): JoinStatement private function getRelationJoin(ModelInspector $relationModel, string $tableAlias): string { $relationJoin = $this->relationJoin; + $tableName = $relationModel->getTableName(); $tableReference = $this->isSelfReferencing() ? $this->property->getName() : $tableAlias; @@ -126,7 +127,11 @@ private function getRelationJoin(ModelInspector $relationModel, string $tableAli } if ($relationJoin) { - return $relationJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $relationJoin, + originalTable: $tableName, + aliasedTable: $tableAlias, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -167,19 +172,38 @@ private function isSelfReferencing(): bool private function getOwnerJoin(ModelInspector $ownerModel): string { $ownerJoin = $this->ownerJoin; + $ownerTableName = $ownerModel->getTableName(); + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerTableName); if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = sprintf('%s.%s', $ownerModel->getTableName(), $ownerJoin); + $ownerJoin = sprintf('%s.%s', $ownerTable, $ownerJoin); } if ($ownerJoin) { - return $ownerJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $ownerJoin, + originalTable: $ownerTableName, + aliasedTable: $ownerTable, + ); } return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $this->getOwnerFieldName(), ); } + + private function rewriteTablePrefix(string $qualifiedColumn, string $originalTable, string $aliasedTable): string + { + if ($aliasedTable === $originalTable) { + return $qualifiedColumn; + } + + if (str_starts_with($qualifiedColumn, $originalTable . '.')) { + return $aliasedTable . substr($qualifiedColumn, strlen($originalTable)); + } + + return $qualifiedColumn; + } } diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index cd2206396..afd288769 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -247,6 +247,7 @@ private function resolveOwnerJoin( private function resolveRelationJoin(ModelInspector $ownerModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); if ( $relationJoin @@ -257,7 +258,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string ) { return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $relationJoin, ); } @@ -277,7 +278,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $primaryKey, ); } diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index fef15dca2..82cbfb852 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -183,11 +183,12 @@ private function isSelfReferencing(): bool private function getRelationJoin(ModelInspector $relationModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); if ($relationJoin && ! strpos($relationJoin, '.')) { $relationJoin = sprintf( '%s.%s', - $relationModel->getTableName(), + $ownerTable, $relationJoin, ); } @@ -204,7 +205,7 @@ private function getRelationJoin(ModelInspector $relationModel): string return sprintf( '%s.%s', - $relationModel->getTableName(), + $ownerTable, $primaryKey, ); } diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index 60f843454..5129c48d4 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -216,6 +216,7 @@ private function resolveOwnerJoin( private function resolveRelationJoin(ModelInspector $ownerModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); if ( $relationJoin @@ -226,7 +227,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string ) { return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $relationJoin, ); } @@ -246,7 +247,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $primaryKey, ); } diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index b800df938..58e698cb7 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -155,11 +155,12 @@ private function isSelfReferencing(): bool private function getRelationJoin(ModelInspector $relationModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); if ($relationJoin && ! strpos($relationJoin, '.')) { $relationJoin = sprintf( '%s.%s', - $relationModel->getTableName(), + $ownerTable, $relationJoin, ); } @@ -176,7 +177,7 @@ private function getRelationJoin(ModelInspector $relationModel): string return sprintf( '%s.%s', - $relationModel->getTableName(), + $ownerTable, $primaryKey, ); } diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 6c20af2d6..03811ca16 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -173,6 +173,7 @@ private function resolveOwnerJoin( private function resolveRelationJoin(ModelInspector $ownerModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); if ( $relationJoin @@ -183,7 +184,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string ) { return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $relationJoin, ); } @@ -203,7 +204,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $primaryKey, ); } diff --git a/packages/database/src/HasTableAlias.php b/packages/database/src/HasTableAlias.php index e379f5a72..b39a0865c 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -10,10 +10,14 @@ trait HasTableAlias { private function getTableAlias(string $tableName): string { - if ($this->parent === null || $this->parent === '') { + if ($this->parent === null) { return $tableName; } + if ($this->parent === '') { + return $this->property->getName(); + } + return str(string: $this->parent) ->replace( search: '.', @@ -25,4 +29,32 @@ private function getTableAlias(string $tableName): string ) ->toString(); } + + private function getOwnerTableAlias(string $ownerTableName): string + { + if ($this->parent === null || $this->parent === '') { + return $ownerTableName; + } + + return str(string: $this->parent) + ->replace( + search: '.', + replace: '_', + ) + ->toString(); + } + + private function rewriteTablePrefix(string $qualifiedColumn, string $originalTable, string $aliasedTable): string + { + if ($aliasedTable === $originalTable) { + return $qualifiedColumn; + } + + return str(string: $qualifiedColumn) + ->replaceFirst( + search: $originalTable . '.', + replace: $aliasedTable . '.', + ) + ->toString(); + } } From ef831da6d80424e9873d9a0f0e32dab42fb088d9 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 10:22:58 +0000 Subject: [PATCH 02/14] test(database): add unit tests for same-table BelongsTo aliasing Add test models and assertions for multiple BelongsTo relations to the same table: distinct JOIN aliases, full table.column syntax, and SELECT field aliasing. Update BelongsToMany parent join expectation to use aliased owner reference. --- packages/database/src/BelongsTo.php | 11 +-- .../ModelInspector/BelongsToManyTest.php | 2 +- .../Database/ModelInspector/BelongsToTest.php | 90 +++++++++++++++++++ 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 2db882d8a..af835c8f6 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -200,10 +200,11 @@ private function rewriteTablePrefix(string $qualifiedColumn, string $originalTab return $qualifiedColumn; } - if (str_starts_with($qualifiedColumn, $originalTable . '.')) { - return $aliasedTable . substr($qualifiedColumn, strlen($originalTable)); - } - - return $qualifiedColumn; + return str($qualifiedColumn) + ->replaceFirst( + search: $originalTable . '.', + replace: $aliasedTable . '.', + ) + ->toString(); } } diff --git a/tests/Integration/Database/ModelInspector/BelongsToManyTest.php b/tests/Integration/Database/ModelInspector/BelongsToManyTest.php index ac2d06d4f..7f5e5a2ee 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToManyTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToManyTest.php @@ -67,7 +67,7 @@ public function test_belongs_to_many_with_parent_join_uses_alias(): void ->setParent(name: 'parent'); $this->assertSame( - expected: 'LEFT JOIN owner_target ON owner_target.owner_id = owner.id LEFT JOIN target AS parent_targets ON parent_targets.id = owner_target.target_id', + expected: 'LEFT JOIN owner_target ON owner_target.owner_id = parent.id LEFT JOIN target AS parent_targets ON parent_targets.id = owner_target.target_id', actual: $relation ->getJoinStatement() ->compile(dialect: DatabaseDialect::SQLITE), diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index 7c2747510..86cdb445e 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -103,6 +103,60 @@ public function test_belongs_to_throws_exception_for_model_without_primary_key() $relation->getJoinStatement(); } + public function test_multiple_belongs_to_same_table_generates_distinct_joins(): void + { + $model = inspect(BelongsToTestRoleWithMultipleSameTableRelationsModel::class); + + $createdByRelation = $model->getRelation('createdBy')->setParent(''); + $updatedByRelation = $model->getRelation('updatedBy')->setParent(''); + + $this->assertEquals( + 'LEFT JOIN users AS createdBy ON createdBy.id = roles.created_by', + $createdByRelation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + + $this->assertEquals( + 'LEFT JOIN users AS updatedBy ON updatedBy.id = roles.updated_by', + $updatedByRelation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_multiple_belongs_to_same_table_with_full_table_column_syntax(): void + { + $model = inspect(BelongsToTestRoleWithFullSpecRelationsModel::class); + + $createdByRelation = $model->getRelation('created_by')->setParent(''); + $updatedByRelation = $model->getRelation('updated_by')->setParent(''); + + $this->assertEquals( + 'LEFT JOIN users AS created_by ON created_by.id = roles.created_by', + $createdByRelation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + + $this->assertEquals( + 'LEFT JOIN users AS updated_by ON updated_by.id = roles.updated_by', + $updatedByRelation->getJoinStatement()->compile(DatabaseDialect::SQLITE), + ); + } + + public function test_multiple_belongs_to_same_table_select_fields(): void + { + $model = inspect(BelongsToTestRoleWithMultipleSameTableRelationsModel::class); + + $createdByFields = $model->getRelation('createdBy')->setParent('')->getSelectFields(); + $updatedByFields = $model->getRelation('updatedBy')->setParent('')->getSelectFields(); + + $this->assertSame( + 'createdBy.id AS `createdBy.id`', + $createdByFields[0]->compile(DatabaseDialect::SQLITE), + ); + + $this->assertSame( + 'updatedBy.id AS `updatedBy.id`', + $updatedByFields[0]->compile(DatabaseDialect::SQLITE), + ); + } + public function test_self_referencing_belongs_to(): void { $model = inspect(SelfReferencingCategoryModel::class); @@ -247,6 +301,42 @@ final class BelongsToTestOwnerWithoutIdModel public string $name; } +#[Table('users')] +final class BelongsToTestUserModel +{ + public PrimaryKey $id; + + public string $name; +} + +#[Table('roles')] +final class BelongsToTestRoleWithMultipleSameTableRelationsModel +{ + public PrimaryKey $id; + + #[BelongsTo(ownerJoin: 'created_by')] + public BelongsToTestUserModel $createdBy; + + #[BelongsTo(ownerJoin: 'updated_by')] + public BelongsToTestUserModel $updatedBy; + + public string $name; +} + +#[Table('roles')] +final class BelongsToTestRoleWithFullSpecRelationsModel +{ + public PrimaryKey $id; + + #[BelongsTo(relationJoin: 'users.id', ownerJoin: 'roles.created_by')] + public ?BelongsToTestUserModel $created_by = null; + + #[BelongsTo(relationJoin: 'users.id', ownerJoin: 'roles.updated_by')] + public ?BelongsToTestUserModel $updated_by = null; + + public string $name; +} + #[Table('categories')] final class SelfReferencingCategoryModel { From 985f55198b5f96e1a009f7d6b3b10f4d01570c38 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 10:23:07 +0000 Subject: [PATCH 03/14] test(database): add integration tests for same-table relation data loading Verify actual data fetching works for: - two BelongsTo to same table via explicit .with() - two #[Eager] BelongsTo to same table - parent -> child with two eager -> subchild chain Update SelectQueryBuilder test expectations to match aliased SQL. --- .../Builder/SelectQueryBuilderTest.php | 8 +- .../MultipleSameTableBelongsToTest.php | 250 ++++++++++++++++++ 2 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/Database/MultipleSameTableBelongsToTest.php diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index acf94a7a8..0653bf2a5 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -458,7 +458,7 @@ public function test_with_belongs_to_relation(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id`, authors.id AS `author.id`, authors.name AS `author.name`, authors.type AS `author.type`, authors.publisher_id AS `author.publisher_id`, chapters.id AS `chapters.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, isbns.id AS `isbn.id`, isbns.value AS `isbn.value`, isbns.book_id AS `isbn.book_id` FROM `books` LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id', + 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id`, author.id AS `author.id`, author.name AS `author.name`, author.type AS `author.type`, author.publisher_id AS `author.publisher_id`, chapters.id AS `chapters.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, isbn.id AS `isbn.id`, isbn.value AS `isbn.value`, isbn.book_id AS `isbn.book_id` FROM `books` LEFT JOIN authors AS author ON author.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns AS isbn ON isbn.book_id = books.id', $query->compile(), ); } @@ -610,7 +610,7 @@ public function test_paginate_preserves_relations(): void $page1 = query(Chapter::class) ->select() ->with('book') - ->whereRaw('books.title = ?', 'LOTR 1') + ->whereRaw('book.title = ?', 'LOTR 1') ->paginate(itemsPerPage: 5, currentPage: 1); $this->assertSame(3, $page1->totalItems); @@ -734,7 +734,7 @@ public function test_select_with_has_one_through_relation(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT tags.id AS `tags.id`, tags.label AS `tags.label`, reviewers.id AS `topReviewer.id`, reviewers.name AS `topReviewer.name`, reviewers.book_review_id AS `topReviewer.book_review_id` FROM `tags` LEFT JOIN book_reviews ON book_reviews.tag_id = tags.id LEFT JOIN reviewers ON reviewers.book_review_id = book_reviews.id', + 'SELECT tags.id AS `tags.id`, tags.label AS `tags.label`, topReviewer.id AS `topReviewer.id`, topReviewer.name AS `topReviewer.name`, topReviewer.book_review_id AS `topReviewer.book_review_id` FROM `tags` LEFT JOIN book_reviews ON book_reviews.tag_id = tags.id LEFT JOIN reviewers AS topReviewer ON topReviewer.book_review_id = book_reviews.id', $query->compile(), ); } @@ -747,7 +747,7 @@ public function test_select_with_duplicate_belongs_to_many_target_table(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT users.id AS `users.id`, users.name AS `users.name`, users.role_id AS `users.role_id`, roles.id AS `role.id`, roles.name AS `role.name`, role_permissions.id AS `role.permissions.id`, role_permissions.label AS `role.permissions.label`, permissions.id AS `permissions.id`, permissions.label AS `permissions.label` FROM `users` LEFT JOIN roles ON roles.id = users.role_id LEFT JOIN permissions_roles ON permissions_roles.role_id = roles.id LEFT JOIN permissions AS role_permissions ON role_permissions.id = permissions_roles.permission_id LEFT JOIN permissions_users ON permissions_users.user_id = users.id LEFT JOIN permissions ON permissions.id = permissions_users.permission_id', + 'SELECT users.id AS `users.id`, users.name AS `users.name`, users.role_id AS `users.role_id`, role.id AS `role.id`, role.name AS `role.name`, role_permissions.id AS `role.permissions.id`, role_permissions.label AS `role.permissions.label`, permissions.id AS `permissions.id`, permissions.label AS `permissions.label` FROM `users` LEFT JOIN roles AS role ON role.id = users.role_id LEFT JOIN permissions_roles ON permissions_roles.role_id = role.id LEFT JOIN permissions AS role_permissions ON role_permissions.id = permissions_roles.permission_id LEFT JOIN permissions_users ON permissions_users.user_id = users.id LEFT JOIN permissions ON permissions.id = permissions_users.permission_id', $query->compile(), ); } diff --git a/tests/Integration/Database/MultipleSameTableBelongsToTest.php b/tests/Integration/Database/MultipleSameTableBelongsToTest.php new file mode 100644 index 000000000..e083d4995 --- /dev/null +++ b/tests/Integration/Database/MultipleSameTableBelongsToTest.php @@ -0,0 +1,250 @@ +database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestRoleMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + + query(model: SameTableTestRole::class) + ->create( + code: 'admin', + created_by: $alice->id->value, + updated_by: $bob->id->value, + ); + + $role = query(model: SameTableTestRole::class) + ->select() + ->with('createdBy', 'updatedBy') + ->first(); + + $this->assertSame(expected: 'admin', actual: $role->code); + $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->createdBy); + $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->updatedBy); + $this->assertSame(expected: 'Alice', actual: $role->createdBy->name); + $this->assertSame(expected: 'Bob', actual: $role->updatedBy->name); + } + + #[Test] + public function two_eager_belongs_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestEagerRoleMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + + query(model: SameTableTestEagerRole::class) + ->create( + code: 'player', + created_by: $alice->id->value, + updated_by: $bob->id->value, + ); + + $role = query(model: SameTableTestEagerRole::class) + ->select() + ->first(); + + $this->assertSame(expected: 'player', actual: $role->code); + $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->createdBy); + $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->updatedBy); + $this->assertSame(expected: 'Alice', actual: $role->createdBy->name); + $this->assertSame(expected: 'Bob', actual: $role->updatedBy->name); + } + + #[Test] + public function parent_to_child_with_two_eager_to_same_table_and_subchild(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestEagerRoleMigration::class, + CreateSameTableTestTaskMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + + $role = query(model: SameTableTestEagerRole::class) + ->create( + code: 'admin', + created_by: $alice->id->value, + updated_by: $bob->id->value, + ); + + query(model: SameTableTestTask::class) + ->create( + title: 'Task 1', + role_id: $role->id->value, + ); + query(model: SameTableTestTask::class) + ->create( + title: 'Task 2', + role_id: $role->id->value, + ); + + $task = query(model: SameTableTestTask::class) + ->select() + ->with('role', 'role.createdBy', 'role.updatedBy') + ->first(); + + $this->assertSame(expected: 'Task 1', actual: $task->title); + $this->assertInstanceOf(expected: SameTableTestEagerRole::class, actual: $task->role); + $this->assertSame(expected: 'admin', actual: $task->role->code); + $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $task->role->createdBy); + $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $task->role->updatedBy); + $this->assertSame(expected: 'Alice', actual: $task->role->createdBy->name); + $this->assertSame(expected: 'Bob', actual: $task->role->updatedBy->name); + } +} + +#[Table('same_table_test_users')] +final class SameTableTestUser +{ + use IsDatabaseModel; + + public function __construct( + public string $name, + ) {} +} + +#[Table('same_table_test_roles')] +final class SameTableTestRole +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'created_by')] + public ?SameTableTestUser $createdBy = null; + + #[BelongsTo(ownerJoin: 'updated_by')] + public ?SameTableTestUser $updatedBy = null; + + public function __construct( + public string $code, + public ?int $created_by = null, + public ?int $updated_by = null, + ) {} +} + +#[Table('same_table_test_eager_roles')] +final class SameTableTestEagerRole +{ + use IsDatabaseModel; + + #[Eager] + #[BelongsTo(ownerJoin: 'created_by')] + public ?SameTableTestUser $createdBy = null; + + #[Eager] + #[BelongsTo(ownerJoin: 'updated_by')] + public ?SameTableTestUser $updatedBy = null; + + /** @var \Tests\Tempest\Integration\Database\SameTableTestTask[] */ + #[HasMany(ownerJoin: 'role_id')] + public array $tasks = []; + + public function __construct( + public string $code, + public ?int $created_by = null, + public ?int $updated_by = null, + ) {} +} + +#[Table('same_table_test_tasks')] +final class SameTableTestTask +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'role_id')] + public ?SameTableTestEagerRole $role = null; + + public function __construct( + public string $title, + public ?int $role_id = null, + ) {} +} + +final class CreateSameTableTestUserMigration implements MigratesUp +{ + public string $name = '001_create_same_table_test_users'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestUser::class) + ->primary() + ->text(name: 'name'); + } +} + +final class CreateSameTableTestRoleMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo('same_table_test_roles.created_by', 'same_table_test_users.id') + ->belongsTo('same_table_test_roles.updated_by', 'same_table_test_users.id'); + } +} + +final class CreateSameTableTestEagerRoleMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_eager_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestEagerRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo('same_table_test_eager_roles.created_by', 'same_table_test_users.id') + ->belongsTo('same_table_test_eager_roles.updated_by', 'same_table_test_users.id'); + } +} + +final class CreateSameTableTestTaskMigration implements MigratesUp +{ + public string $name = '003_create_same_table_test_tasks'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestTask::class) + ->primary() + ->text(name: 'title') + ->belongsTo('same_table_test_tasks.role_id', 'same_table_test_eager_roles.id'); + } +} From 45c19806c3a6e7ccf2387a367053d2d444ac1d33 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 10:30:40 +0000 Subject: [PATCH 04/14] docs(database): update HasOneThrough SQL example to show aliased join The target table is now aliased by property name when loaded via eager/with relations. fix(database): use named arg in str() call --- docs/1-essentials/03-database.md | 2 +- packages/database/src/BelongsTo.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index 64eb3f168..e03625fbd 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -272,7 +272,7 @@ The `through` parameter specifies the intermediate model class. The target model ```sql LEFT JOIN profiles ON profiles.author_id = authors.id -LEFT JOIN addresses ON addresses.profile_id = profiles.id +LEFT JOIN addresses AS address ON address.profile_id = profiles.id ``` When conventions don't match, optional parameters can override the join fields: diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index af835c8f6..0cb3fbadd 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -200,7 +200,7 @@ private function rewriteTablePrefix(string $qualifiedColumn, string $originalTab return $qualifiedColumn; } - return str($qualifiedColumn) + return str(string: $qualifiedColumn) ->replaceFirst( search: $originalTable . '.', replace: $aliasedTable . '.', From 7f0c9da47f694c0bee6678e67cea52a4e1037621 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 10:43:55 +0000 Subject: [PATCH 05/14] fix(database): apply rewriteTablePrefix consistently across all relation types Move rewriteTablePrefix from BelongsTo to HasTableAlias trait so all relation types can rewrite explicit table.column references when the table is aliased. Applied in HasMany, HasOne, BelongsToMany, HasOneThrough, and HasManyThrough. --- packages/database/src/BelongsTo.php | 14 -------------- packages/database/src/BelongsToMany.php | 6 +++++- packages/database/src/HasMany.php | 14 ++++++++++++-- packages/database/src/HasManyThrough.php | 6 +++++- packages/database/src/HasOne.php | 12 ++++++++++-- packages/database/src/HasOneThrough.php | 6 +++++- 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 0cb3fbadd..2660f96c8 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -193,18 +193,4 @@ private function getOwnerJoin(ModelInspector $ownerModel): string $this->getOwnerFieldName(), ); } - - private function rewriteTablePrefix(string $qualifiedColumn, string $originalTable, string $aliasedTable): string - { - if ($aliasedTable === $originalTable) { - return $qualifiedColumn; - } - - return str(string: $qualifiedColumn) - ->replaceFirst( - search: $originalTable . '.', - replace: $aliasedTable . '.', - ) - ->toString(); - } } diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index afd288769..ea86e74b2 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -264,7 +264,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string } if ($relationJoin) { - return $relationJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $relationJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $ownerModel->getPrimaryKey(); diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 82cbfb852..83976f54c 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -138,7 +138,13 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati } if ($ownerJoin) { - return $ownerJoin; + $ownerModel = inspect($this->property->getIterableType()->asClass()); + + return $this->rewriteTablePrefix( + qualifiedColumn: $ownerJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $tableReference, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -194,7 +200,11 @@ private function getRelationJoin(ModelInspector $relationModel): string } if ($relationJoin) { - return $relationJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $relationJoin, + originalTable: $relationModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $relationModel->getPrimaryKey(); diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index 5129c48d4..c086481aa 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -233,7 +233,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string } if ($relationJoin) { - return $relationJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $relationJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $ownerModel->getPrimaryKey(); diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 58e698cb7..495d7fc2c 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -110,7 +110,11 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati } if ($ownerJoin) { - return $ownerJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $ownerJoin, + originalTable: inspect($this->property->getType()->asClass())->getTableName(), + aliasedTable: $tableReference, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -166,7 +170,11 @@ private function getRelationJoin(ModelInspector $relationModel): string } if ($relationJoin) { - return $relationJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $relationJoin, + originalTable: $relationModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $relationModel->getPrimaryKey(); diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 03811ca16..399db033c 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -190,7 +190,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string } if ($relationJoin) { - return $relationJoin; + return $this->rewriteTablePrefix( + qualifiedColumn: $relationJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $ownerModel->getPrimaryKey(); From 238f3d05c326106e21cd6a8fffae44cfe7e3367c Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 11:19:32 +0000 Subject: [PATCH 06/14] fix(database): quote table aliases in JOINs for PostgreSQL compatibility Wrap aliases in backticks from getTableAlias/getOwnerTableAlias when they differ from the raw table name. JoinStatement::compile() converts backticks to double quotes for PostgreSQL and strips them for SQLite. This prevents reserved keywords like 'user' from causing syntax errors when used as property names that become table aliases. --- packages/database/src/BelongsTo.php | 2 +- packages/database/src/HasTableAlias.php | 8 +++++++- packages/database/src/QueryStatements/JoinStatement.php | 8 ++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 2660f96c8..39d73071b 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -82,7 +82,7 @@ public function getJoinStatement(): JoinStatement { $relationModel = inspect($this->property->getType()->asClass()); $ownerModel = inspect($this->property->getClass()); - $tableAlias = $this->getTableAlias($relationModel->getTableName()); + $tableAlias = $this->getTableAlias(tableName: $relationModel->getTableName()); $relationJoin = $this->getRelationJoin( relationModel: $relationModel, diff --git a/packages/database/src/HasTableAlias.php b/packages/database/src/HasTableAlias.php index b39a0865c..96022408f 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -15,7 +15,11 @@ private function getTableAlias(string $tableName): string } if ($this->parent === '') { - return $this->property->getName(); + $alias = $this->property->getName(); + + return $alias === $tableName + ? $tableName + : str(string: $alias)->wrap('`')->toString(); } return str(string: $this->parent) @@ -27,6 +31,7 @@ private function getTableAlias(string $tableName): string '_', $this->property->getName(), ) + ->wrap('`') ->toString(); } @@ -41,6 +46,7 @@ private function getOwnerTableAlias(string $ownerTableName): string search: '.', replace: '_', ) + ->wrap('`') ->toString(); } diff --git a/packages/database/src/QueryStatements/JoinStatement.php b/packages/database/src/QueryStatements/JoinStatement.php index a05d9f7f9..aec2bdf07 100644 --- a/packages/database/src/QueryStatements/JoinStatement.php +++ b/packages/database/src/QueryStatements/JoinStatement.php @@ -18,9 +18,13 @@ public function compile(DatabaseDialect $dialect): string $statement = $this->statement; if (! str($statement)->lower()->startsWith(['join', 'inner join', 'left join', 'right join', 'full join', 'full outer join', 'self join'])) { - return sprintf('INNER JOIN %s', $statement); + $statement = sprintf('INNER JOIN %s', $statement); } - return $statement; + return match ($dialect) { + DatabaseDialect::POSTGRESQL => str_replace('`', '"', $statement), + DatabaseDialect::SQLITE => str_replace('`', '', $statement), + default => $statement, + }; } } From 24a04bcc3781fd24af19ff4914414b41b7006b6a Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 11:28:37 +0000 Subject: [PATCH 07/14] test(database): add same-table relation tests for HasOne, HasMany, BelongsToMany MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify distinct aliased JOINs when two properties of the same relation type point to the same table. Covers root-level and nested (parent → child with 2 same-table → subchild) scenarios. --- .../ModelInspector/SameTableRelationsTest.php | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/Integration/Database/ModelInspector/SameTableRelationsTest.php diff --git a/tests/Integration/Database/ModelInspector/SameTableRelationsTest.php b/tests/Integration/Database/ModelInspector/SameTableRelationsTest.php new file mode 100644 index 000000000..a1383e9bf --- /dev/null +++ b/tests/Integration/Database/ModelInspector/SameTableRelationsTest.php @@ -0,0 +1,180 @@ +getRelation(name: 'homeAddress')->setParent(name: ''); + $work = $model->getRelation(name: 'workAddress')->setParent(name: ''); + + $homeJoin = $home->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + $workJoin = $work->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + + $this->assertNotEquals($homeJoin, $workJoin); + $this->assertStringContainsString('addresses AS homeAddress', $homeJoin); + $this->assertStringContainsString('addresses AS workAddress', $workJoin); + } + + #[Test] + public function has_many_two_properties_to_same_table(): void + { + $model = inspect(model: UserWithTwoMessageRelations::class); + + $sent = $model->getRelation(name: 'sentMessages')->setParent(name: ''); + $received = $model->getRelation(name: 'receivedMessages')->setParent(name: ''); + + $sentJoin = $sent->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + $receivedJoin = $received->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + + $this->assertNotEquals($sentJoin, $receivedJoin); + $this->assertStringContainsString('messages AS sentMessages', $sentJoin); + $this->assertStringContainsString('messages AS receivedMessages', $receivedJoin); + } + + #[Test] + public function belongs_to_many_two_properties_to_same_table(): void + { + $model = inspect(model: UserWithTwoBelongsToManyRelations::class); + + $followers = $model->getRelation(name: 'followers')->setParent(name: ''); + $following = $model->getRelation(name: 'following')->setParent(name: ''); + + $followersJoin = $followers->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + $followingJoin = $following->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + + $this->assertNotEquals($followersJoin, $followingJoin); + $this->assertStringContainsString('AS followers', $followersJoin); + $this->assertStringContainsString('AS following', $followingJoin); + } + + #[Test] + public function child_with_two_has_one_to_same_subchild_table(): void + { + $model = inspect(model: PersonWithTwoAddresses::class); + + $home = $model->getRelation(name: 'homeAddress')->setParent(name: 'person'); + $work = $model->getRelation(name: 'workAddress')->setParent(name: 'person'); + + $homeJoin = $home->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + $workJoin = $work->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + + $this->assertNotEquals($homeJoin, $workJoin); + $this->assertStringContainsString('AS person_homeAddress', $homeJoin); + $this->assertStringContainsString('AS person_workAddress', $workJoin); + } + + #[Test] + public function child_with_two_has_many_to_same_subchild_table(): void + { + $model = inspect(model: UserWithTwoMessageRelations::class); + + $sent = $model->getRelation(name: 'sentMessages')->setParent(name: 'user'); + $received = $model->getRelation(name: 'receivedMessages')->setParent(name: 'user'); + + $sentJoin = $sent->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + $receivedJoin = $received->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); + + $this->assertNotEquals($sentJoin, $receivedJoin); + $this->assertStringContainsString('AS user_sentMessages', $sentJoin); + $this->assertStringContainsString('AS user_receivedMessages', $receivedJoin); + } +} + +#[Table('same_test_persons')] +final class PersonWithTwoAddresses +{ + public PrimaryKey $id; + + #[HasOne(ownerJoin: 'home_person_id')] + public ?SameTestAddress $homeAddress = null; + + #[HasOne(ownerJoin: 'work_person_id')] + public ?SameTestAddress $workAddress = null; + + public string $name; +} + +#[Table('same_test_addresses')] +final class SameTestAddress +{ + public PrimaryKey $id; + + public ?int $home_person_id = null; + + public ?int $work_person_id = null; + + public string $street; +} + +#[Table('same_test_users')] +final class UserWithTwoMessageRelations +{ + public PrimaryKey $id; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestMessage[] */ + #[HasMany(ownerJoin: 'sender_id')] + public array $sentMessages = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestMessage[] */ + #[HasMany(ownerJoin: 'receiver_id')] + public array $receivedMessages = []; + + public string $name; +} + +#[Table('same_test_messages')] +final class SameTestMessage +{ + public PrimaryKey $id; + + public ?int $sender_id = null; + + public ?int $receiver_id = null; + + public string $body; +} + +#[Table('same_test_btm_users')] +final class UserWithTwoBelongsToManyRelations +{ + public PrimaryKey $id; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestBtmUser[] */ + #[BelongsToMany(pivot: 'followers_pivot')] + public array $followers = []; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestBtmUser[] */ + #[BelongsToMany(pivot: 'following_pivot')] + public array $following = []; + + public string $name; +} + +#[Table('same_test_btm_targets')] +final class SameTestBtmUser +{ + public PrimaryKey $id; + + public string $name; +} From cde1de26dee9149f37aad4eecb253f0bd19b9f91 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 11:49:11 +0000 Subject: [PATCH 08/14] refactor(database): replace rewriteTablePrefix with replaceTableReference Rename for clarity and apply consistently across all relation types. Strip table prefix from explicit table.column join params and re-prefix with the correct alias, preserving cross-table references when the specified table differs from the expected one. --- packages/database/src/BelongsTo.php | 14 ++++++-------- packages/database/src/BelongsToMany.php | 8 ++------ packages/database/src/HasMany.php | 16 ++++------------ packages/database/src/HasManyThrough.php | 8 ++------ packages/database/src/HasOne.php | 16 ++++------------ packages/database/src/HasOneThrough.php | 8 ++------ packages/database/src/HasTableAlias.php | 6 +++--- 7 files changed, 23 insertions(+), 53 deletions(-) diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 39d73071b..e5f4ebc29 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -82,7 +82,7 @@ public function getJoinStatement(): JoinStatement { $relationModel = inspect($this->property->getType()->asClass()); $ownerModel = inspect($this->property->getClass()); - $tableAlias = $this->getTableAlias(tableName: $relationModel->getTableName()); + $tableAlias = $this->getTableAlias($relationModel->getTableName()); $relationJoin = $this->getRelationJoin( relationModel: $relationModel, @@ -117,7 +117,6 @@ public function getJoinStatement(): JoinStatement private function getRelationJoin(ModelInspector $relationModel, string $tableAlias): string { $relationJoin = $this->relationJoin; - $tableName = $relationModel->getTableName(); $tableReference = $this->isSelfReferencing() ? $this->property->getName() : $tableAlias; @@ -127,9 +126,9 @@ private function getRelationJoin(ModelInspector $relationModel, string $tableAli } if ($relationJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $relationJoin, - originalTable: $tableName, + originalTable: $relationModel->getTableName(), aliasedTable: $tableAlias, ); } @@ -172,17 +171,16 @@ private function isSelfReferencing(): bool private function getOwnerJoin(ModelInspector $ownerModel): string { $ownerJoin = $this->ownerJoin; - $ownerTableName = $ownerModel->getTableName(); - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerTableName); + $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); if ($ownerJoin && ! strpos($ownerJoin, '.')) { $ownerJoin = sprintf('%s.%s', $ownerTable, $ownerJoin); } if ($ownerJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $ownerJoin, - originalTable: $ownerTableName, + originalTable: $ownerModel->getTableName(), aliasedTable: $ownerTable, ); } diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index ea86e74b2..23eb7333d 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -256,15 +256,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string needle: '.', ) ) { - return sprintf( - '%s.%s', - $ownerTable, - $relationJoin, - ); + return "{$ownerTable}.{$relationJoin}"; } if ($relationJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $relationJoin, originalTable: $ownerModel->getTableName(), aliasedTable: $ownerTable, diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 83976f54c..915a13aa5 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -130,17 +130,13 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati : $tableAlias; if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = sprintf( - '%s.%s', - $tableReference, - $ownerJoin, - ); + $ownerJoin = "{$tableReference}.{$ownerJoin}"; } if ($ownerJoin) { $ownerModel = inspect($this->property->getIterableType()->asClass()); - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $ownerJoin, originalTable: $ownerModel->getTableName(), aliasedTable: $tableReference, @@ -192,15 +188,11 @@ private function getRelationJoin(ModelInspector $relationModel): string $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); if ($relationJoin && ! strpos($relationJoin, '.')) { - $relationJoin = sprintf( - '%s.%s', - $ownerTable, - $relationJoin, - ); + $relationJoin = "{$ownerTable}.{$relationJoin}"; } if ($relationJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $relationJoin, originalTable: $relationModel->getTableName(), aliasedTable: $ownerTable, diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index c086481aa..d039d7876 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -225,15 +225,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string needle: '.', ) ) { - return sprintf( - '%s.%s', - $ownerTable, - $relationJoin, - ); + return "{$ownerTable}.{$relationJoin}"; } if ($relationJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $relationJoin, originalTable: $ownerModel->getTableName(), aliasedTable: $ownerTable, diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 495d7fc2c..d7aa6f2b2 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -102,15 +102,11 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati : $tableAlias; if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = sprintf( - '%s.%s', - $tableReference, - $ownerJoin, - ); + $ownerJoin = "{$tableReference}.{$ownerJoin}"; } if ($ownerJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $ownerJoin, originalTable: inspect($this->property->getType()->asClass())->getTableName(), aliasedTable: $tableReference, @@ -162,15 +158,11 @@ private function getRelationJoin(ModelInspector $relationModel): string $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); if ($relationJoin && ! strpos($relationJoin, '.')) { - $relationJoin = sprintf( - '%s.%s', - $ownerTable, - $relationJoin, - ); + $relationJoin = "{$ownerTable}.{$relationJoin}"; } if ($relationJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $relationJoin, originalTable: $relationModel->getTableName(), aliasedTable: $ownerTable, diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 399db033c..9d34623bd 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -182,15 +182,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string needle: '.', ) ) { - return sprintf( - '%s.%s', - $ownerTable, - $relationJoin, - ); + return "{$ownerTable}.{$relationJoin}"; } if ($relationJoin) { - return $this->rewriteTablePrefix( + return $this->replaceTableReference( qualifiedColumn: $relationJoin, originalTable: $ownerModel->getTableName(), aliasedTable: $ownerTable, diff --git a/packages/database/src/HasTableAlias.php b/packages/database/src/HasTableAlias.php index 96022408f..0fcc0fd5b 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -50,7 +50,7 @@ private function getOwnerTableAlias(string $ownerTableName): string ->toString(); } - private function rewriteTablePrefix(string $qualifiedColumn, string $originalTable, string $aliasedTable): string + private function replaceTableReference(string $qualifiedColumn, string $originalTable, string $aliasedTable): string { if ($aliasedTable === $originalTable) { return $qualifiedColumn; @@ -58,8 +58,8 @@ private function rewriteTablePrefix(string $qualifiedColumn, string $originalTab return str(string: $qualifiedColumn) ->replaceFirst( - search: $originalTable . '.', - replace: $aliasedTable . '.', + search: "{$originalTable}.", + replace: "{$aliasedTable}.", ) ->toString(); } From 2b6714fdc36a9228a75f909cde5a1d536bfc394f Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 11:55:28 +0000 Subject: [PATCH 09/14] style(database): revert sprintf to original style to minimize diff noise --- packages/database/src/BelongsToMany.php | 6 +++++- packages/database/src/HasMany.php | 12 ++++++++++-- packages/database/src/HasManyThrough.php | 6 +++++- packages/database/src/HasOne.php | 12 ++++++++++-- packages/database/src/HasOneThrough.php | 6 +++++- 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index 23eb7333d..24096089d 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -256,7 +256,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string needle: '.', ) ) { - return "{$ownerTable}.{$relationJoin}"; + return sprintf( + '%s.%s', + $ownerTable, + $relationJoin, + ); } if ($relationJoin) { diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 915a13aa5..794e0308a 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -130,7 +130,11 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati : $tableAlias; if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = "{$tableReference}.{$ownerJoin}"; + $ownerJoin = sprintf( + '%s.%s', + $tableReference, + $ownerJoin, + ); } if ($ownerJoin) { @@ -188,7 +192,11 @@ private function getRelationJoin(ModelInspector $relationModel): string $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); if ($relationJoin && ! strpos($relationJoin, '.')) { - $relationJoin = "{$ownerTable}.{$relationJoin}"; + $relationJoin = sprintf( + '%s.%s', + $ownerTable, + $relationJoin, + ); } if ($relationJoin) { diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index d039d7876..9837bd1bb 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -225,7 +225,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string needle: '.', ) ) { - return "{$ownerTable}.{$relationJoin}"; + return sprintf( + '%s.%s', + $ownerTable, + $relationJoin, + ); } if ($relationJoin) { diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index d7aa6f2b2..2bb3e6f0e 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -102,7 +102,11 @@ private function getOwnerJoin(ModelInspector $ownerModel, ModelInspector $relati : $tableAlias; if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = "{$tableReference}.{$ownerJoin}"; + $ownerJoin = sprintf( + '%s.%s', + $tableReference, + $ownerJoin, + ); } if ($ownerJoin) { @@ -158,7 +162,11 @@ private function getRelationJoin(ModelInspector $relationModel): string $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); if ($relationJoin && ! strpos($relationJoin, '.')) { - $relationJoin = "{$ownerTable}.{$relationJoin}"; + $relationJoin = sprintf( + '%s.%s', + $ownerTable, + $relationJoin, + ); } if ($relationJoin) { diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 9d34623bd..219451aaf 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -182,7 +182,11 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string needle: '.', ) ) { - return "{$ownerTable}.{$relationJoin}"; + return sprintf( + '%s.%s', + $ownerTable, + $relationJoin, + ); } if ($relationJoin) { From 6abbbd92740992e74094450f5e2b0b7c9981c9b7 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 12:02:47 +0000 Subject: [PATCH 10/14] test(database): consolidate same-table relation tests with real data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace separate BelongsTo-only and SQL-string tests with a single MultipleSameTableRelationsTest covering all relation types with actual data: BelongsTo, HasMany, and nested parent → child with two same-table → subchild chains. --- .../ModelInspector/SameTableRelationsTest.php | 180 ------ .../MultipleSameTableBelongsToTest.php | 250 -------- .../MultipleSameTableRelationsTest.php | 543 ++++++++++++++++++ 3 files changed, 543 insertions(+), 430 deletions(-) delete mode 100644 tests/Integration/Database/ModelInspector/SameTableRelationsTest.php delete mode 100644 tests/Integration/Database/MultipleSameTableBelongsToTest.php create mode 100644 tests/Integration/Database/MultipleSameTableRelationsTest.php diff --git a/tests/Integration/Database/ModelInspector/SameTableRelationsTest.php b/tests/Integration/Database/ModelInspector/SameTableRelationsTest.php deleted file mode 100644 index a1383e9bf..000000000 --- a/tests/Integration/Database/ModelInspector/SameTableRelationsTest.php +++ /dev/null @@ -1,180 +0,0 @@ -getRelation(name: 'homeAddress')->setParent(name: ''); - $work = $model->getRelation(name: 'workAddress')->setParent(name: ''); - - $homeJoin = $home->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - $workJoin = $work->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - - $this->assertNotEquals($homeJoin, $workJoin); - $this->assertStringContainsString('addresses AS homeAddress', $homeJoin); - $this->assertStringContainsString('addresses AS workAddress', $workJoin); - } - - #[Test] - public function has_many_two_properties_to_same_table(): void - { - $model = inspect(model: UserWithTwoMessageRelations::class); - - $sent = $model->getRelation(name: 'sentMessages')->setParent(name: ''); - $received = $model->getRelation(name: 'receivedMessages')->setParent(name: ''); - - $sentJoin = $sent->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - $receivedJoin = $received->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - - $this->assertNotEquals($sentJoin, $receivedJoin); - $this->assertStringContainsString('messages AS sentMessages', $sentJoin); - $this->assertStringContainsString('messages AS receivedMessages', $receivedJoin); - } - - #[Test] - public function belongs_to_many_two_properties_to_same_table(): void - { - $model = inspect(model: UserWithTwoBelongsToManyRelations::class); - - $followers = $model->getRelation(name: 'followers')->setParent(name: ''); - $following = $model->getRelation(name: 'following')->setParent(name: ''); - - $followersJoin = $followers->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - $followingJoin = $following->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - - $this->assertNotEquals($followersJoin, $followingJoin); - $this->assertStringContainsString('AS followers', $followersJoin); - $this->assertStringContainsString('AS following', $followingJoin); - } - - #[Test] - public function child_with_two_has_one_to_same_subchild_table(): void - { - $model = inspect(model: PersonWithTwoAddresses::class); - - $home = $model->getRelation(name: 'homeAddress')->setParent(name: 'person'); - $work = $model->getRelation(name: 'workAddress')->setParent(name: 'person'); - - $homeJoin = $home->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - $workJoin = $work->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - - $this->assertNotEquals($homeJoin, $workJoin); - $this->assertStringContainsString('AS person_homeAddress', $homeJoin); - $this->assertStringContainsString('AS person_workAddress', $workJoin); - } - - #[Test] - public function child_with_two_has_many_to_same_subchild_table(): void - { - $model = inspect(model: UserWithTwoMessageRelations::class); - - $sent = $model->getRelation(name: 'sentMessages')->setParent(name: 'user'); - $received = $model->getRelation(name: 'receivedMessages')->setParent(name: 'user'); - - $sentJoin = $sent->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - $receivedJoin = $received->getJoinStatement()->compile(dialect: DatabaseDialect::SQLITE); - - $this->assertNotEquals($sentJoin, $receivedJoin); - $this->assertStringContainsString('AS user_sentMessages', $sentJoin); - $this->assertStringContainsString('AS user_receivedMessages', $receivedJoin); - } -} - -#[Table('same_test_persons')] -final class PersonWithTwoAddresses -{ - public PrimaryKey $id; - - #[HasOne(ownerJoin: 'home_person_id')] - public ?SameTestAddress $homeAddress = null; - - #[HasOne(ownerJoin: 'work_person_id')] - public ?SameTestAddress $workAddress = null; - - public string $name; -} - -#[Table('same_test_addresses')] -final class SameTestAddress -{ - public PrimaryKey $id; - - public ?int $home_person_id = null; - - public ?int $work_person_id = null; - - public string $street; -} - -#[Table('same_test_users')] -final class UserWithTwoMessageRelations -{ - public PrimaryKey $id; - - /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestMessage[] */ - #[HasMany(ownerJoin: 'sender_id')] - public array $sentMessages = []; - - /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestMessage[] */ - #[HasMany(ownerJoin: 'receiver_id')] - public array $receivedMessages = []; - - public string $name; -} - -#[Table('same_test_messages')] -final class SameTestMessage -{ - public PrimaryKey $id; - - public ?int $sender_id = null; - - public ?int $receiver_id = null; - - public string $body; -} - -#[Table('same_test_btm_users')] -final class UserWithTwoBelongsToManyRelations -{ - public PrimaryKey $id; - - /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestBtmUser[] */ - #[BelongsToMany(pivot: 'followers_pivot')] - public array $followers = []; - - /** @var \Tests\Tempest\Integration\Database\ModelInspector\SameTestBtmUser[] */ - #[BelongsToMany(pivot: 'following_pivot')] - public array $following = []; - - public string $name; -} - -#[Table('same_test_btm_targets')] -final class SameTestBtmUser -{ - public PrimaryKey $id; - - public string $name; -} diff --git a/tests/Integration/Database/MultipleSameTableBelongsToTest.php b/tests/Integration/Database/MultipleSameTableBelongsToTest.php deleted file mode 100644 index e083d4995..000000000 --- a/tests/Integration/Database/MultipleSameTableBelongsToTest.php +++ /dev/null @@ -1,250 +0,0 @@ -database->migrate( - CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestRoleMigration::class, - ); - - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - - query(model: SameTableTestRole::class) - ->create( - code: 'admin', - created_by: $alice->id->value, - updated_by: $bob->id->value, - ); - - $role = query(model: SameTableTestRole::class) - ->select() - ->with('createdBy', 'updatedBy') - ->first(); - - $this->assertSame(expected: 'admin', actual: $role->code); - $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->createdBy); - $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->updatedBy); - $this->assertSame(expected: 'Alice', actual: $role->createdBy->name); - $this->assertSame(expected: 'Bob', actual: $role->updatedBy->name); - } - - #[Test] - public function two_eager_belongs_to_same_table(): void - { - $this->database->migrate( - CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestEagerRoleMigration::class, - ); - - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - - query(model: SameTableTestEagerRole::class) - ->create( - code: 'player', - created_by: $alice->id->value, - updated_by: $bob->id->value, - ); - - $role = query(model: SameTableTestEagerRole::class) - ->select() - ->first(); - - $this->assertSame(expected: 'player', actual: $role->code); - $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->createdBy); - $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $role->updatedBy); - $this->assertSame(expected: 'Alice', actual: $role->createdBy->name); - $this->assertSame(expected: 'Bob', actual: $role->updatedBy->name); - } - - #[Test] - public function parent_to_child_with_two_eager_to_same_table_and_subchild(): void - { - $this->database->migrate( - CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestEagerRoleMigration::class, - CreateSameTableTestTaskMigration::class, - ); - - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - - $role = query(model: SameTableTestEagerRole::class) - ->create( - code: 'admin', - created_by: $alice->id->value, - updated_by: $bob->id->value, - ); - - query(model: SameTableTestTask::class) - ->create( - title: 'Task 1', - role_id: $role->id->value, - ); - query(model: SameTableTestTask::class) - ->create( - title: 'Task 2', - role_id: $role->id->value, - ); - - $task = query(model: SameTableTestTask::class) - ->select() - ->with('role', 'role.createdBy', 'role.updatedBy') - ->first(); - - $this->assertSame(expected: 'Task 1', actual: $task->title); - $this->assertInstanceOf(expected: SameTableTestEagerRole::class, actual: $task->role); - $this->assertSame(expected: 'admin', actual: $task->role->code); - $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $task->role->createdBy); - $this->assertInstanceOf(expected: SameTableTestUser::class, actual: $task->role->updatedBy); - $this->assertSame(expected: 'Alice', actual: $task->role->createdBy->name); - $this->assertSame(expected: 'Bob', actual: $task->role->updatedBy->name); - } -} - -#[Table('same_table_test_users')] -final class SameTableTestUser -{ - use IsDatabaseModel; - - public function __construct( - public string $name, - ) {} -} - -#[Table('same_table_test_roles')] -final class SameTableTestRole -{ - use IsDatabaseModel; - - #[BelongsTo(ownerJoin: 'created_by')] - public ?SameTableTestUser $createdBy = null; - - #[BelongsTo(ownerJoin: 'updated_by')] - public ?SameTableTestUser $updatedBy = null; - - public function __construct( - public string $code, - public ?int $created_by = null, - public ?int $updated_by = null, - ) {} -} - -#[Table('same_table_test_eager_roles')] -final class SameTableTestEagerRole -{ - use IsDatabaseModel; - - #[Eager] - #[BelongsTo(ownerJoin: 'created_by')] - public ?SameTableTestUser $createdBy = null; - - #[Eager] - #[BelongsTo(ownerJoin: 'updated_by')] - public ?SameTableTestUser $updatedBy = null; - - /** @var \Tests\Tempest\Integration\Database\SameTableTestTask[] */ - #[HasMany(ownerJoin: 'role_id')] - public array $tasks = []; - - public function __construct( - public string $code, - public ?int $created_by = null, - public ?int $updated_by = null, - ) {} -} - -#[Table('same_table_test_tasks')] -final class SameTableTestTask -{ - use IsDatabaseModel; - - #[BelongsTo(ownerJoin: 'role_id')] - public ?SameTableTestEagerRole $role = null; - - public function __construct( - public string $title, - public ?int $role_id = null, - ) {} -} - -final class CreateSameTableTestUserMigration implements MigratesUp -{ - public string $name = '001_create_same_table_test_users'; - - public function up(): QueryStatement - { - return CreateTableStatement::forModel(modelClass: SameTableTestUser::class) - ->primary() - ->text(name: 'name'); - } -} - -final class CreateSameTableTestRoleMigration implements MigratesUp -{ - public string $name = '002_create_same_table_test_roles'; - - public function up(): QueryStatement - { - return CreateTableStatement::forModel(modelClass: SameTableTestRole::class) - ->primary() - ->text(name: 'code') - ->belongsTo('same_table_test_roles.created_by', 'same_table_test_users.id') - ->belongsTo('same_table_test_roles.updated_by', 'same_table_test_users.id'); - } -} - -final class CreateSameTableTestEagerRoleMigration implements MigratesUp -{ - public string $name = '002_create_same_table_test_eager_roles'; - - public function up(): QueryStatement - { - return CreateTableStatement::forModel(modelClass: SameTableTestEagerRole::class) - ->primary() - ->text(name: 'code') - ->belongsTo('same_table_test_eager_roles.created_by', 'same_table_test_users.id') - ->belongsTo('same_table_test_eager_roles.updated_by', 'same_table_test_users.id'); - } -} - -final class CreateSameTableTestTaskMigration implements MigratesUp -{ - public string $name = '003_create_same_table_test_tasks'; - - public function up(): QueryStatement - { - return CreateTableStatement::forModel(modelClass: SameTableTestTask::class) - ->primary() - ->text(name: 'title') - ->belongsTo('same_table_test_tasks.role_id', 'same_table_test_eager_roles.id'); - } -} diff --git a/tests/Integration/Database/MultipleSameTableRelationsTest.php b/tests/Integration/Database/MultipleSameTableRelationsTest.php new file mode 100644 index 000000000..e9a785fbe --- /dev/null +++ b/tests/Integration/Database/MultipleSameTableRelationsTest.php @@ -0,0 +1,543 @@ +database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestRoleMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + query(model: SameTableTestRole::class)->create(code: 'admin', createdBy: $alice, updatedBy: $bob); + + $role = query(model: SameTableTestRole::class) + ->select() + ->with('createdBy', 'updatedBy') + ->first(); + + $this->assertSame('admin', $role->code); + $this->assertInstanceOf(SameTableTestUser::class, $role->createdBy); + $this->assertInstanceOf(SameTableTestUser::class, $role->updatedBy); + $this->assertSame('Alice', $role->createdBy->name); + $this->assertSame('Bob', $role->updatedBy->name); + } + + #[Test] + public function two_belongs_to_same_table_with_full_table_column_syntax(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestFullSpecRoleMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + query(model: SameTableTestFullSpecRole::class)->create(code: 'moderator', createdByUser: $alice, updatedByUser: $bob); + + $role = query(model: SameTableTestFullSpecRole::class) + ->select() + ->with('createdByUser', 'updatedByUser') + ->first(); + + $this->assertSame('moderator', $role->code); + $this->assertInstanceOf(SameTableTestUser::class, $role->createdByUser); + $this->assertInstanceOf(SameTableTestUser::class, $role->updatedByUser); + $this->assertSame('Alice', $role->createdByUser->name); + $this->assertSame('Bob', $role->updatedByUser->name); + } + + #[Test] + public function two_eager_belongs_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestEagerRoleMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + query(model: SameTableTestEagerRole::class)->create(code: 'player', createdBy: $alice, updatedBy: $bob); + + $role = query(model: SameTableTestEagerRole::class) + ->select() + ->first(); + + $this->assertSame('player', $role->code); + $this->assertInstanceOf(SameTableTestUser::class, $role->createdBy); + $this->assertInstanceOf(SameTableTestUser::class, $role->updatedBy); + $this->assertSame('Alice', $role->createdBy->name); + $this->assertSame('Bob', $role->updatedBy->name); + } + + #[Test] + public function parent_to_child_with_two_eager_belongs_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestEagerRoleMigration::class, + CreateSameTableTestTaskMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + $role = query(model: SameTableTestEagerRole::class)->create(code: 'admin', createdBy: $alice, updatedBy: $bob); + query(model: SameTableTestTask::class)->create(title: 'Task 1', role: $role); + + $task = query(model: SameTableTestTask::class) + ->select() + ->with('role', 'role.createdBy', 'role.updatedBy') + ->first(); + + $this->assertSame('Task 1', $task->title); + $this->assertInstanceOf(SameTableTestEagerRole::class, $task->role); + $this->assertSame('admin', $task->role->code); + $this->assertInstanceOf(SameTableTestUser::class, $task->role->createdBy); + $this->assertInstanceOf(SameTableTestUser::class, $task->role->updatedBy); + $this->assertSame('Alice', $task->role->createdBy->name); + $this->assertSame('Bob', $task->role->updatedBy->name); + } + + // HasMany + + #[Test] + public function two_has_many_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestUserMigration::class, + CreateSameTableTestMessageMigration::class, + ); + + $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); + $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); + query(model: SameTableTestMessage::class)->create(body: 'Hello Bob', sender: $alice, receiver: $bob); + query(model: SameTableTestMessage::class)->create(body: 'Hi Alice', sender: $bob, receiver: $alice); + + $alice = query(model: SameTableTestUser::class) + ->select() + ->with('sentMessages', 'receivedMessages') + ->where('name', 'Alice') + ->first(); + $this->assertCount(1, $alice->sentMessages); + $this->assertCount(1, $alice->receivedMessages); + $this->assertSame('Hello Bob', $alice->sentMessages[0]->body); + $this->assertSame('Hi Alice', $alice->receivedMessages[0]->body); + } + + // BelongsTo (HasOne-like pattern via BelongsTo on the owning side) + + #[Test] + public function two_belongs_to_same_table_as_addresses(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestAddressMigration::class, + CreateSameTableTestPersonMigration::class, + ); + + $home = query(model: SameTableTestAddress::class)->create(street: '123 Home St'); + $work = query(model: SameTableTestAddress::class)->create(street: '456 Work Ave'); + query(model: SameTableTestPerson::class)->create(name: 'Alice', homeAddress: $home, workAddress: $work); + + $person = query(model: SameTableTestPerson::class) + ->select() + ->with('homeAddress', 'workAddress') + ->first(); + + $this->assertSame('Alice', $person->name); + $this->assertInstanceOf(SameTableTestAddress::class, $person->homeAddress); + $this->assertInstanceOf(SameTableTestAddress::class, $person->workAddress); + $this->assertSame('123 Home St', $person->homeAddress->street); + $this->assertSame('456 Work Ave', $person->workAddress->street); + } + + #[Test] + public function parent_to_child_with_two_belongs_to_same_subchild(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestAddressMigration::class, + CreateSameTableTestPersonMigration::class, + CreateSameTableTestCompanyMigration::class, + ); + + $home = query(model: SameTableTestAddress::class)->create(street: '10 Home Rd'); + $work = query(model: SameTableTestAddress::class)->create(street: '20 Office Blvd'); + $person = query(model: SameTableTestPerson::class)->create(name: 'Bob', homeAddress: $home, workAddress: $work); + query(model: SameTableTestCompany::class)->create(name: 'Acme', ceo: $person); + + $company = query(model: SameTableTestCompany::class) + ->select() + ->with('ceo', 'ceo.homeAddress', 'ceo.workAddress') + ->first(); + + $this->assertSame('Acme', $company->name); + $this->assertInstanceOf(SameTableTestPerson::class, $company->ceo); + $this->assertSame('Bob', $company->ceo->name); + $this->assertInstanceOf(SameTableTestAddress::class, $company->ceo->homeAddress); + $this->assertInstanceOf(SameTableTestAddress::class, $company->ceo->workAddress); + $this->assertSame('10 Home Rd', $company->ceo->homeAddress->street); + $this->assertSame('20 Office Blvd', $company->ceo->workAddress->street); + } + + // HasOne + + #[Test] + public function two_has_one_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateSameTableTestEmployeeMigration::class, + CreateSameTableTestContactMigration::class, + ); + + $alice = query(model: SameTableTestEmployee::class)->create(name: 'Alice'); + query(model: SameTableTestContact::class)->create(value: 'alice@work.com', workEmployee: $alice); + query(model: SameTableTestContact::class)->create(value: '555-1234', personalEmployee: $alice); + + $employee = query(model: SameTableTestEmployee::class) + ->select() + ->with('workContact', 'personalContact') + ->first(); + + $this->assertSame('Alice', $employee->name); + $this->assertInstanceOf(SameTableTestContact::class, $employee->workContact); + $this->assertInstanceOf(SameTableTestContact::class, $employee->personalContact); + $this->assertSame('alice@work.com', $employee->workContact->value); + $this->assertSame('555-1234', $employee->personalContact->value); + } +} + +// Models + +#[Table('same_table_test_users')] +final class SameTableTestUser +{ + use IsDatabaseModel; + + /** @var \Tests\Tempest\Integration\Database\SameTableTestMessage[] */ + #[HasMany(ownerJoin: 'sender_id')] + public array $sentMessages = []; + + /** @var \Tests\Tempest\Integration\Database\SameTableTestMessage[] */ + #[HasMany(ownerJoin: 'receiver_id')] + public array $receivedMessages = []; + + public string $name; +} + +#[Table('same_table_test_messages')] +final class SameTableTestMessage +{ + use IsDatabaseModel; + + public string $body; + + #[BelongsTo(ownerJoin: 'sender_id')] + public ?SameTableTestUser $sender = null; + + #[BelongsTo(ownerJoin: 'receiver_id')] + public ?SameTableTestUser $receiver = null; +} + +#[Table('same_table_test_addresses')] +final class SameTableTestAddress +{ + use IsDatabaseModel; + + public string $street; +} + +#[Table('same_table_test_persons')] +final class SameTableTestPerson +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'home_address_id')] + public ?SameTableTestAddress $homeAddress = null; + + #[BelongsTo(ownerJoin: 'work_address_id')] + public ?SameTableTestAddress $workAddress = null; + + public string $name; +} + +#[Table('same_table_test_companies')] +final class SameTableTestCompany +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'ceo_id')] + public ?SameTableTestPerson $ceo = null; + + public string $name; +} + +#[Table('same_table_test_full_spec_roles')] +final class SameTableTestFullSpecRole +{ + use IsDatabaseModel; + + #[Eager] + #[BelongsTo(relationJoin: 'same_table_test_users.id', ownerJoin: 'same_table_test_full_spec_roles.created_by')] + public ?SameTableTestUser $createdByUser = null; + + #[Eager] + #[BelongsTo(relationJoin: 'same_table_test_users.id', ownerJoin: 'same_table_test_full_spec_roles.updated_by')] + public ?SameTableTestUser $updatedByUser = null; + + public string $code; +} + +#[Table('same_table_test_roles')] +final class SameTableTestRole +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'created_by')] + public ?SameTableTestUser $createdBy = null; + + #[BelongsTo(ownerJoin: 'updated_by')] + public ?SameTableTestUser $updatedBy = null; + + public string $code; +} + +#[Table('same_table_test_eager_roles')] +final class SameTableTestEagerRole +{ + use IsDatabaseModel; + + #[Eager] + #[BelongsTo(ownerJoin: 'created_by')] + public ?SameTableTestUser $createdBy = null; + + #[Eager] + #[BelongsTo(ownerJoin: 'updated_by')] + public ?SameTableTestUser $updatedBy = null; + + /** @var \Tests\Tempest\Integration\Database\SameTableTestTask[] */ + #[HasMany(ownerJoin: 'role_id')] + public array $tasks = []; + + public string $code; +} + +#[Table('same_table_test_tasks')] +final class SameTableTestTask +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'role_id')] + public ?SameTableTestEagerRole $role = null; + + public string $title; +} + +#[Table('same_table_test_employees')] +final class SameTableTestEmployee +{ + use IsDatabaseModel; + + #[HasOne(ownerJoin: 'employee_work_id')] + public ?SameTableTestContact $workContact = null; + + #[HasOne(ownerJoin: 'employee_personal_id')] + public ?SameTableTestContact $personalContact = null; + + public string $name; +} + +#[Table('same_table_test_contacts')] +final class SameTableTestContact +{ + use IsDatabaseModel; + + public string $value; + + #[BelongsTo(ownerJoin: 'employee_work_id')] + public ?SameTableTestEmployee $workEmployee = null; + + #[BelongsTo(ownerJoin: 'employee_personal_id')] + public ?SameTableTestEmployee $personalEmployee = null; +} + +// Migrations + +final class CreateSameTableTestUserMigration implements MigratesUp +{ + public string $name = '001_create_same_table_test_users'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestUser::class) + ->primary() + ->text(name: 'name'); + } +} + +final class CreateSameTableTestMessageMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_messages'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestMessage::class) + ->primary() + ->text(name: 'body') + ->belongsTo(local: 'same_table_test_messages.sender_id', foreign: 'same_table_test_users.id') + ->belongsTo(local: 'same_table_test_messages.receiver_id', foreign: 'same_table_test_users.id'); + } +} + +final class CreateSameTableTestAddressMigration implements MigratesUp +{ + public string $name = '001_create_same_table_test_addresses'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestAddress::class) + ->primary() + ->text(name: 'street'); + } +} + +final class CreateSameTableTestPersonMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_persons'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestPerson::class) + ->primary() + ->text(name: 'name') + ->belongsTo(local: 'same_table_test_persons.home_address_id', foreign: 'same_table_test_addresses.id') + ->belongsTo(local: 'same_table_test_persons.work_address_id', foreign: 'same_table_test_addresses.id'); + } +} + +final class CreateSameTableTestCompanyMigration implements MigratesUp +{ + public string $name = '003_create_same_table_test_companies'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestCompany::class) + ->primary() + ->text(name: 'name') + ->belongsTo(local: 'same_table_test_companies.ceo_id', foreign: 'same_table_test_persons.id'); + } +} + +final class CreateSameTableTestRoleMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo(local: 'same_table_test_roles.created_by', foreign: 'same_table_test_users.id') + ->belongsTo(local: 'same_table_test_roles.updated_by', foreign: 'same_table_test_users.id'); + } +} + +final class CreateSameTableTestEagerRoleMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_eager_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestEagerRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo(local: 'same_table_test_eager_roles.created_by', foreign: 'same_table_test_users.id') + ->belongsTo(local: 'same_table_test_eager_roles.updated_by', foreign: 'same_table_test_users.id'); + } +} + +final class CreateSameTableTestTaskMigration implements MigratesUp +{ + public string $name = '003_create_same_table_test_tasks'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestTask::class) + ->primary() + ->text(name: 'title') + ->belongsTo(local: 'same_table_test_tasks.role_id', foreign: 'same_table_test_eager_roles.id'); + } +} + +final class CreateSameTableTestContactMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_contacts'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestContact::class) + ->primary() + ->text(name: 'value') + ->belongsTo(local: 'same_table_test_contacts.employee_work_id', foreign: 'same_table_test_employees.id', nullable: true) + ->belongsTo(local: 'same_table_test_contacts.employee_personal_id', foreign: 'same_table_test_employees.id', nullable: true); + } +} + +final class CreateSameTableTestEmployeeMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_employees'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestEmployee::class) + ->primary() + ->text(name: 'name'); + } +} + +final class CreateSameTableTestFullSpecRoleMigration implements MigratesUp +{ + public string $name = '002_create_same_table_test_full_spec_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: SameTableTestFullSpecRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo(local: 'same_table_test_full_spec_roles.created_by', foreign: 'same_table_test_users.id') + ->belongsTo(local: 'same_table_test_full_spec_roles.updated_by', foreign: 'same_table_test_users.id'); + } +} From d555e2b26b2adc01445e1cff2ad97d8a7bfa9508 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 12:56:35 +0000 Subject: [PATCH 11/14] fix(database): use dialect-correct quoting for PostgreSQL identifiers FieldStatement now uses double quotes for PostgreSQL field identifiers instead of backticks. JoinStatement converts backticks to double quotes for PostgreSQL and strips them for SQLite. This ensures table aliases that are reserved keywords (like 'user') work across all dialects. Shorten test table names to avoid MySQL FK constraint name length limit. --- .../src/QueryStatements/FieldStatement.php | 10 +- .../MultipleSameTableRelationsTest.php | 318 +++++++++--------- 2 files changed, 164 insertions(+), 164 deletions(-) diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index bad8067f2..ce4f07315 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -45,11 +45,11 @@ function (string $part) use ($dialect) { return $part; } - if ($dialect === DatabaseDialect::SQLITE) { - return $part; - } - - return $dialect->quoteIdentifier($part); + return match ($dialect) { + DatabaseDialect::SQLITE => $part, + DatabaseDialect::POSTGRESQL => sprintf('"%s"', $part), + default => sprintf('`%s`', $part), + }; }, ) ->implode('.'); diff --git a/tests/Integration/Database/MultipleSameTableRelationsTest.php b/tests/Integration/Database/MultipleSameTableRelationsTest.php index e9a785fbe..8297af8ba 100644 --- a/tests/Integration/Database/MultipleSameTableRelationsTest.php +++ b/tests/Integration/Database/MultipleSameTableRelationsTest.php @@ -31,22 +31,22 @@ public function two_belongs_to_same_table_with_explicit_with(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestRoleMigration::class, + CreateStUserMigration::class, + CreateStRoleMigration::class, ); - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - query(model: SameTableTestRole::class)->create(code: 'admin', createdBy: $alice, updatedBy: $bob); + $alice = query(model: StUser::class)->create(name: 'Alice'); + $bob = query(model: StUser::class)->create(name: 'Bob'); + query(model: StRole::class)->create(code: 'admin', createdBy: $alice, updatedBy: $bob); - $role = query(model: SameTableTestRole::class) + $role = query(model: StRole::class) ->select() ->with('createdBy', 'updatedBy') ->first(); $this->assertSame('admin', $role->code); - $this->assertInstanceOf(SameTableTestUser::class, $role->createdBy); - $this->assertInstanceOf(SameTableTestUser::class, $role->updatedBy); + $this->assertInstanceOf(StUser::class, $role->createdBy); + $this->assertInstanceOf(StUser::class, $role->updatedBy); $this->assertSame('Alice', $role->createdBy->name); $this->assertSame('Bob', $role->updatedBy->name); } @@ -56,22 +56,22 @@ public function two_belongs_to_same_table_with_full_table_column_syntax(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestFullSpecRoleMigration::class, + CreateStUserMigration::class, + CreateStFullSpecRoleMigration::class, ); - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - query(model: SameTableTestFullSpecRole::class)->create(code: 'moderator', createdByUser: $alice, updatedByUser: $bob); + $alice = query(model: StUser::class)->create(name: 'Alice'); + $bob = query(model: StUser::class)->create(name: 'Bob'); + query(model: StFullSpecRole::class)->create(code: 'moderator', createdByUser: $alice, updatedByUser: $bob); - $role = query(model: SameTableTestFullSpecRole::class) + $role = query(model: StFullSpecRole::class) ->select() ->with('createdByUser', 'updatedByUser') ->first(); $this->assertSame('moderator', $role->code); - $this->assertInstanceOf(SameTableTestUser::class, $role->createdByUser); - $this->assertInstanceOf(SameTableTestUser::class, $role->updatedByUser); + $this->assertInstanceOf(StUser::class, $role->createdByUser); + $this->assertInstanceOf(StUser::class, $role->updatedByUser); $this->assertSame('Alice', $role->createdByUser->name); $this->assertSame('Bob', $role->updatedByUser->name); } @@ -81,21 +81,21 @@ public function two_eager_belongs_to_same_table(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestEagerRoleMigration::class, + CreateStUserMigration::class, + CreateStEagerRoleMigration::class, ); - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - query(model: SameTableTestEagerRole::class)->create(code: 'player', createdBy: $alice, updatedBy: $bob); + $alice = query(model: StUser::class)->create(name: 'Alice'); + $bob = query(model: StUser::class)->create(name: 'Bob'); + query(model: StEagerRole::class)->create(code: 'player', createdBy: $alice, updatedBy: $bob); - $role = query(model: SameTableTestEagerRole::class) + $role = query(model: StEagerRole::class) ->select() ->first(); $this->assertSame('player', $role->code); - $this->assertInstanceOf(SameTableTestUser::class, $role->createdBy); - $this->assertInstanceOf(SameTableTestUser::class, $role->updatedBy); + $this->assertInstanceOf(StUser::class, $role->createdBy); + $this->assertInstanceOf(StUser::class, $role->updatedBy); $this->assertSame('Alice', $role->createdBy->name); $this->assertSame('Bob', $role->updatedBy->name); } @@ -105,26 +105,26 @@ public function parent_to_child_with_two_eager_belongs_to_same_table(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestEagerRoleMigration::class, - CreateSameTableTestTaskMigration::class, + CreateStUserMigration::class, + CreateStEagerRoleMigration::class, + CreateStTaskMigration::class, ); - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - $role = query(model: SameTableTestEagerRole::class)->create(code: 'admin', createdBy: $alice, updatedBy: $bob); - query(model: SameTableTestTask::class)->create(title: 'Task 1', role: $role); + $alice = query(model: StUser::class)->create(name: 'Alice'); + $bob = query(model: StUser::class)->create(name: 'Bob'); + $role = query(model: StEagerRole::class)->create(code: 'admin', createdBy: $alice, updatedBy: $bob); + query(model: StTask::class)->create(title: 'Task 1', role: $role); - $task = query(model: SameTableTestTask::class) + $task = query(model: StTask::class) ->select() ->with('role', 'role.createdBy', 'role.updatedBy') ->first(); $this->assertSame('Task 1', $task->title); - $this->assertInstanceOf(SameTableTestEagerRole::class, $task->role); + $this->assertInstanceOf(StEagerRole::class, $task->role); $this->assertSame('admin', $task->role->code); - $this->assertInstanceOf(SameTableTestUser::class, $task->role->createdBy); - $this->assertInstanceOf(SameTableTestUser::class, $task->role->updatedBy); + $this->assertInstanceOf(StUser::class, $task->role->createdBy); + $this->assertInstanceOf(StUser::class, $task->role->updatedBy); $this->assertSame('Alice', $task->role->createdBy->name); $this->assertSame('Bob', $task->role->updatedBy->name); } @@ -136,16 +136,16 @@ public function two_has_many_to_same_table(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestUserMigration::class, - CreateSameTableTestMessageMigration::class, + CreateStUserMigration::class, + CreateStMessageMigration::class, ); - $alice = query(model: SameTableTestUser::class)->create(name: 'Alice'); - $bob = query(model: SameTableTestUser::class)->create(name: 'Bob'); - query(model: SameTableTestMessage::class)->create(body: 'Hello Bob', sender: $alice, receiver: $bob); - query(model: SameTableTestMessage::class)->create(body: 'Hi Alice', sender: $bob, receiver: $alice); + $alice = query(model: StUser::class)->create(name: 'Alice'); + $bob = query(model: StUser::class)->create(name: 'Bob'); + query(model: StMessage::class)->create(body: 'Hello Bob', sender: $alice, receiver: $bob); + query(model: StMessage::class)->create(body: 'Hi Alice', sender: $bob, receiver: $alice); - $alice = query(model: SameTableTestUser::class) + $alice = query(model: StUser::class) ->select() ->with('sentMessages', 'receivedMessages') ->where('name', 'Alice') @@ -163,22 +163,22 @@ public function two_belongs_to_same_table_as_addresses(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestAddressMigration::class, - CreateSameTableTestPersonMigration::class, + CreateStAddressMigration::class, + CreateStPersonMigration::class, ); - $home = query(model: SameTableTestAddress::class)->create(street: '123 Home St'); - $work = query(model: SameTableTestAddress::class)->create(street: '456 Work Ave'); - query(model: SameTableTestPerson::class)->create(name: 'Alice', homeAddress: $home, workAddress: $work); + $home = query(model: StAddress::class)->create(street: '123 Home St'); + $work = query(model: StAddress::class)->create(street: '456 Work Ave'); + query(model: StPerson::class)->create(name: 'Alice', homeAddress: $home, workAddress: $work); - $person = query(model: SameTableTestPerson::class) + $person = query(model: StPerson::class) ->select() ->with('homeAddress', 'workAddress') ->first(); $this->assertSame('Alice', $person->name); - $this->assertInstanceOf(SameTableTestAddress::class, $person->homeAddress); - $this->assertInstanceOf(SameTableTestAddress::class, $person->workAddress); + $this->assertInstanceOf(StAddress::class, $person->homeAddress); + $this->assertInstanceOf(StAddress::class, $person->workAddress); $this->assertSame('123 Home St', $person->homeAddress->street); $this->assertSame('456 Work Ave', $person->workAddress->street); } @@ -188,26 +188,26 @@ public function parent_to_child_with_two_belongs_to_same_subchild(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestAddressMigration::class, - CreateSameTableTestPersonMigration::class, - CreateSameTableTestCompanyMigration::class, + CreateStAddressMigration::class, + CreateStPersonMigration::class, + CreateStCompanyMigration::class, ); - $home = query(model: SameTableTestAddress::class)->create(street: '10 Home Rd'); - $work = query(model: SameTableTestAddress::class)->create(street: '20 Office Blvd'); - $person = query(model: SameTableTestPerson::class)->create(name: 'Bob', homeAddress: $home, workAddress: $work); - query(model: SameTableTestCompany::class)->create(name: 'Acme', ceo: $person); + $home = query(model: StAddress::class)->create(street: '10 Home Rd'); + $work = query(model: StAddress::class)->create(street: '20 Office Blvd'); + $person = query(model: StPerson::class)->create(name: 'Bob', homeAddress: $home, workAddress: $work); + query(model: StCompany::class)->create(name: 'Acme', ceo: $person); - $company = query(model: SameTableTestCompany::class) + $company = query(model: StCompany::class) ->select() ->with('ceo', 'ceo.homeAddress', 'ceo.workAddress') ->first(); $this->assertSame('Acme', $company->name); - $this->assertInstanceOf(SameTableTestPerson::class, $company->ceo); + $this->assertInstanceOf(StPerson::class, $company->ceo); $this->assertSame('Bob', $company->ceo->name); - $this->assertInstanceOf(SameTableTestAddress::class, $company->ceo->homeAddress); - $this->assertInstanceOf(SameTableTestAddress::class, $company->ceo->workAddress); + $this->assertInstanceOf(StAddress::class, $company->ceo->homeAddress); + $this->assertInstanceOf(StAddress::class, $company->ceo->workAddress); $this->assertSame('10 Home Rd', $company->ceo->homeAddress->street); $this->assertSame('20 Office Blvd', $company->ceo->workAddress->street); } @@ -219,22 +219,22 @@ public function two_has_one_to_same_table(): void { $this->database->migrate( CreateMigrationsTable::class, - CreateSameTableTestEmployeeMigration::class, - CreateSameTableTestContactMigration::class, + CreateStEmployeeMigration::class, + CreateStContactMigration::class, ); - $alice = query(model: SameTableTestEmployee::class)->create(name: 'Alice'); - query(model: SameTableTestContact::class)->create(value: 'alice@work.com', workEmployee: $alice); - query(model: SameTableTestContact::class)->create(value: '555-1234', personalEmployee: $alice); + $alice = query(model: StEmployee::class)->create(name: 'Alice'); + query(model: StContact::class)->create(value: 'alice@work.com', workEmployee: $alice); + query(model: StContact::class)->create(value: '555-1234', personalEmployee: $alice); - $employee = query(model: SameTableTestEmployee::class) + $employee = query(model: StEmployee::class) ->select() ->with('workContact', 'personalContact') ->first(); $this->assertSame('Alice', $employee->name); - $this->assertInstanceOf(SameTableTestContact::class, $employee->workContact); - $this->assertInstanceOf(SameTableTestContact::class, $employee->personalContact); + $this->assertInstanceOf(StContact::class, $employee->workContact); + $this->assertInstanceOf(StContact::class, $employee->personalContact); $this->assertSame('alice@work.com', $employee->workContact->value); $this->assertSame('555-1234', $employee->personalContact->value); } @@ -242,302 +242,302 @@ public function two_has_one_to_same_table(): void // Models -#[Table('same_table_test_users')] -final class SameTableTestUser +#[Table('st_users')] +final class StUser { use IsDatabaseModel; - /** @var \Tests\Tempest\Integration\Database\SameTableTestMessage[] */ + /** @var \Tests\Tempest\Integration\Database\StMessage[] */ #[HasMany(ownerJoin: 'sender_id')] public array $sentMessages = []; - /** @var \Tests\Tempest\Integration\Database\SameTableTestMessage[] */ + /** @var \Tests\Tempest\Integration\Database\StMessage[] */ #[HasMany(ownerJoin: 'receiver_id')] public array $receivedMessages = []; public string $name; } -#[Table('same_table_test_messages')] -final class SameTableTestMessage +#[Table('st_messages')] +final class StMessage { use IsDatabaseModel; public string $body; #[BelongsTo(ownerJoin: 'sender_id')] - public ?SameTableTestUser $sender = null; + public ?StUser $sender = null; #[BelongsTo(ownerJoin: 'receiver_id')] - public ?SameTableTestUser $receiver = null; + public ?StUser $receiver = null; } -#[Table('same_table_test_addresses')] -final class SameTableTestAddress +#[Table('st_addresses')] +final class StAddress { use IsDatabaseModel; public string $street; } -#[Table('same_table_test_persons')] -final class SameTableTestPerson +#[Table('st_persons')] +final class StPerson { use IsDatabaseModel; #[BelongsTo(ownerJoin: 'home_address_id')] - public ?SameTableTestAddress $homeAddress = null; + public ?StAddress $homeAddress = null; #[BelongsTo(ownerJoin: 'work_address_id')] - public ?SameTableTestAddress $workAddress = null; + public ?StAddress $workAddress = null; public string $name; } -#[Table('same_table_test_companies')] -final class SameTableTestCompany +#[Table('st_companies')] +final class StCompany { use IsDatabaseModel; #[BelongsTo(ownerJoin: 'ceo_id')] - public ?SameTableTestPerson $ceo = null; + public ?StPerson $ceo = null; public string $name; } -#[Table('same_table_test_full_spec_roles')] -final class SameTableTestFullSpecRole +#[Table('st_full_spec_roles')] +final class StFullSpecRole { use IsDatabaseModel; #[Eager] - #[BelongsTo(relationJoin: 'same_table_test_users.id', ownerJoin: 'same_table_test_full_spec_roles.created_by')] - public ?SameTableTestUser $createdByUser = null; + #[BelongsTo(relationJoin: 'st_users.id', ownerJoin: 'st_full_spec_roles.created_by')] + public ?StUser $createdByUser = null; #[Eager] - #[BelongsTo(relationJoin: 'same_table_test_users.id', ownerJoin: 'same_table_test_full_spec_roles.updated_by')] - public ?SameTableTestUser $updatedByUser = null; + #[BelongsTo(relationJoin: 'st_users.id', ownerJoin: 'st_full_spec_roles.updated_by')] + public ?StUser $updatedByUser = null; public string $code; } -#[Table('same_table_test_roles')] -final class SameTableTestRole +#[Table('st_roles')] +final class StRole { use IsDatabaseModel; #[BelongsTo(ownerJoin: 'created_by')] - public ?SameTableTestUser $createdBy = null; + public ?StUser $createdBy = null; #[BelongsTo(ownerJoin: 'updated_by')] - public ?SameTableTestUser $updatedBy = null; + public ?StUser $updatedBy = null; public string $code; } -#[Table('same_table_test_eager_roles')] -final class SameTableTestEagerRole +#[Table('st_eager_roles')] +final class StEagerRole { use IsDatabaseModel; #[Eager] #[BelongsTo(ownerJoin: 'created_by')] - public ?SameTableTestUser $createdBy = null; + public ?StUser $createdBy = null; #[Eager] #[BelongsTo(ownerJoin: 'updated_by')] - public ?SameTableTestUser $updatedBy = null; + public ?StUser $updatedBy = null; - /** @var \Tests\Tempest\Integration\Database\SameTableTestTask[] */ + /** @var \Tests\Tempest\Integration\Database\StTask[] */ #[HasMany(ownerJoin: 'role_id')] public array $tasks = []; public string $code; } -#[Table('same_table_test_tasks')] -final class SameTableTestTask +#[Table('st_tasks')] +final class StTask { use IsDatabaseModel; #[BelongsTo(ownerJoin: 'role_id')] - public ?SameTableTestEagerRole $role = null; + public ?StEagerRole $role = null; public string $title; } -#[Table('same_table_test_employees')] -final class SameTableTestEmployee +#[Table('st_employees')] +final class StEmployee { use IsDatabaseModel; #[HasOne(ownerJoin: 'employee_work_id')] - public ?SameTableTestContact $workContact = null; + public ?StContact $workContact = null; #[HasOne(ownerJoin: 'employee_personal_id')] - public ?SameTableTestContact $personalContact = null; + public ?StContact $personalContact = null; public string $name; } -#[Table('same_table_test_contacts')] -final class SameTableTestContact +#[Table('st_contacts')] +final class StContact { use IsDatabaseModel; public string $value; #[BelongsTo(ownerJoin: 'employee_work_id')] - public ?SameTableTestEmployee $workEmployee = null; + public ?StEmployee $workEmployee = null; #[BelongsTo(ownerJoin: 'employee_personal_id')] - public ?SameTableTestEmployee $personalEmployee = null; + public ?StEmployee $personalEmployee = null; } // Migrations -final class CreateSameTableTestUserMigration implements MigratesUp +final class CreateStUserMigration implements MigratesUp { - public string $name = '001_create_same_table_test_users'; + public string $name = '001_create_st_users'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestUser::class) + return CreateTableStatement::forModel(modelClass: StUser::class) ->primary() ->text(name: 'name'); } } -final class CreateSameTableTestMessageMigration implements MigratesUp +final class CreateStMessageMigration implements MigratesUp { - public string $name = '002_create_same_table_test_messages'; + public string $name = '002_create_st_messages'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestMessage::class) + return CreateTableStatement::forModel(modelClass: StMessage::class) ->primary() ->text(name: 'body') - ->belongsTo(local: 'same_table_test_messages.sender_id', foreign: 'same_table_test_users.id') - ->belongsTo(local: 'same_table_test_messages.receiver_id', foreign: 'same_table_test_users.id'); + ->belongsTo(local: 'st_messages.sender_id', foreign: 'st_users.id') + ->belongsTo(local: 'st_messages.receiver_id', foreign: 'st_users.id'); } } -final class CreateSameTableTestAddressMigration implements MigratesUp +final class CreateStAddressMigration implements MigratesUp { - public string $name = '001_create_same_table_test_addresses'; + public string $name = '001_create_st_addresses'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestAddress::class) + return CreateTableStatement::forModel(modelClass: StAddress::class) ->primary() ->text(name: 'street'); } } -final class CreateSameTableTestPersonMigration implements MigratesUp +final class CreateStPersonMigration implements MigratesUp { - public string $name = '002_create_same_table_test_persons'; + public string $name = '002_create_st_persons'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestPerson::class) + return CreateTableStatement::forModel(modelClass: StPerson::class) ->primary() ->text(name: 'name') - ->belongsTo(local: 'same_table_test_persons.home_address_id', foreign: 'same_table_test_addresses.id') - ->belongsTo(local: 'same_table_test_persons.work_address_id', foreign: 'same_table_test_addresses.id'); + ->belongsTo(local: 'st_persons.home_address_id', foreign: 'st_addresses.id') + ->belongsTo(local: 'st_persons.work_address_id', foreign: 'st_addresses.id'); } } -final class CreateSameTableTestCompanyMigration implements MigratesUp +final class CreateStCompanyMigration implements MigratesUp { - public string $name = '003_create_same_table_test_companies'; + public string $name = '003_create_st_companies'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestCompany::class) + return CreateTableStatement::forModel(modelClass: StCompany::class) ->primary() ->text(name: 'name') - ->belongsTo(local: 'same_table_test_companies.ceo_id', foreign: 'same_table_test_persons.id'); + ->belongsTo(local: 'st_companies.ceo_id', foreign: 'st_persons.id'); } } -final class CreateSameTableTestRoleMigration implements MigratesUp +final class CreateStRoleMigration implements MigratesUp { - public string $name = '002_create_same_table_test_roles'; + public string $name = '002_create_st_roles'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestRole::class) + return CreateTableStatement::forModel(modelClass: StRole::class) ->primary() ->text(name: 'code') - ->belongsTo(local: 'same_table_test_roles.created_by', foreign: 'same_table_test_users.id') - ->belongsTo(local: 'same_table_test_roles.updated_by', foreign: 'same_table_test_users.id'); + ->belongsTo(local: 'st_roles.created_by', foreign: 'st_users.id') + ->belongsTo(local: 'st_roles.updated_by', foreign: 'st_users.id'); } } -final class CreateSameTableTestEagerRoleMigration implements MigratesUp +final class CreateStEagerRoleMigration implements MigratesUp { - public string $name = '002_create_same_table_test_eager_roles'; + public string $name = '002_create_st_eager_roles'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestEagerRole::class) + return CreateTableStatement::forModel(modelClass: StEagerRole::class) ->primary() ->text(name: 'code') - ->belongsTo(local: 'same_table_test_eager_roles.created_by', foreign: 'same_table_test_users.id') - ->belongsTo(local: 'same_table_test_eager_roles.updated_by', foreign: 'same_table_test_users.id'); + ->belongsTo(local: 'st_eager_roles.created_by', foreign: 'st_users.id') + ->belongsTo(local: 'st_eager_roles.updated_by', foreign: 'st_users.id'); } } -final class CreateSameTableTestTaskMigration implements MigratesUp +final class CreateStTaskMigration implements MigratesUp { - public string $name = '003_create_same_table_test_tasks'; + public string $name = '003_create_st_tasks'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestTask::class) + return CreateTableStatement::forModel(modelClass: StTask::class) ->primary() ->text(name: 'title') - ->belongsTo(local: 'same_table_test_tasks.role_id', foreign: 'same_table_test_eager_roles.id'); + ->belongsTo(local: 'st_tasks.role_id', foreign: 'st_eager_roles.id'); } } -final class CreateSameTableTestContactMigration implements MigratesUp +final class CreateStContactMigration implements MigratesUp { - public string $name = '002_create_same_table_test_contacts'; + public string $name = '002_create_st_contacts'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestContact::class) + return CreateTableStatement::forModel(modelClass: StContact::class) ->primary() ->text(name: 'value') - ->belongsTo(local: 'same_table_test_contacts.employee_work_id', foreign: 'same_table_test_employees.id', nullable: true) - ->belongsTo(local: 'same_table_test_contacts.employee_personal_id', foreign: 'same_table_test_employees.id', nullable: true); + ->belongsTo(local: 'st_contacts.employee_work_id', foreign: 'st_employees.id', nullable: true) + ->belongsTo(local: 'st_contacts.employee_personal_id', foreign: 'st_employees.id', nullable: true); } } -final class CreateSameTableTestEmployeeMigration implements MigratesUp +final class CreateStEmployeeMigration implements MigratesUp { - public string $name = '002_create_same_table_test_employees'; + public string $name = '002_create_st_employees'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestEmployee::class) + return CreateTableStatement::forModel(modelClass: StEmployee::class) ->primary() ->text(name: 'name'); } } -final class CreateSameTableTestFullSpecRoleMigration implements MigratesUp +final class CreateStFullSpecRoleMigration implements MigratesUp { - public string $name = '002_create_same_table_test_full_spec_roles'; + public string $name = '002_create_st_full_spec_roles'; public function up(): QueryStatement { - return CreateTableStatement::forModel(modelClass: SameTableTestFullSpecRole::class) + return CreateTableStatement::forModel(modelClass: StFullSpecRole::class) ->primary() ->text(name: 'code') - ->belongsTo(local: 'same_table_test_full_spec_roles.created_by', foreign: 'same_table_test_users.id') - ->belongsTo(local: 'same_table_test_full_spec_roles.updated_by', foreign: 'same_table_test_users.id'); + ->belongsTo(local: 'st_full_spec_roles.created_by', foreign: 'st_users.id') + ->belongsTo(local: 'st_full_spec_roles.updated_by', foreign: 'st_users.id'); } } From 26dab63f7d3d75ff2494e455713199f9764d8609 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 13:36:29 +0000 Subject: [PATCH 12/14] refactor(database): only alias when duplicate target tables detected Instead of aliasing all root-level relations (which broke whereRaw with bare table names), detect duplicate target tables in the query builder and only alias those via withPropertyNameAlias(). Single-relation cases keep bare table names, preserving backward compatibility. Remove getOwnerTableAlias (now a no-op), revert FieldStatement/ JoinStatement/doc changes that were only needed for universal aliasing. --- docs/1-essentials/03-database.md | 2 +- packages/database/src/BelongsTo.php | 4 +-- packages/database/src/BelongsToMany.php | 2 +- .../QueryBuilders/SelectQueryBuilder.php | 21 ++++++++++++ packages/database/src/HasMany.php | 2 +- packages/database/src/HasManyThrough.php | 2 +- packages/database/src/HasOne.php | 2 +- packages/database/src/HasOneThrough.php | 2 +- packages/database/src/HasTableAlias.php | 33 +++++++------------ .../src/QueryStatements/FieldStatement.php | 10 +++--- .../src/QueryStatements/JoinStatement.php | 8 ++--- packages/database/src/Relation.php | 4 +++ .../QueryStatements/FieldStatementTest.php | 4 +-- .../Builder/SelectQueryBuilderTest.php | 8 ++--- .../ModelInspector/BelongsToManyTest.php | 2 +- .../Database/ModelInspector/BelongsToTest.php | 12 +++---- 16 files changed, 65 insertions(+), 53 deletions(-) diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index e03625fbd..64eb3f168 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -272,7 +272,7 @@ The `through` parameter specifies the intermediate model class. The target model ```sql LEFT JOIN profiles ON profiles.author_id = authors.id -LEFT JOIN addresses AS address ON address.profile_id = profiles.id +LEFT JOIN addresses ON addresses.profile_id = profiles.id ``` When conventions don't match, optional parameters can override the join fields: diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index e5f4ebc29..a6b1b4716 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -105,7 +105,7 @@ public function getJoinStatement(): JoinStatement ? sprintf('%s AS %s', $tableName, $tableAlias) : $tableName; - // LEFT JOIN authors AS author ON author.id = books.author_id + // LEFT JOIN authors ON authors.id = books.author_id return new JoinStatement(sprintf( 'LEFT JOIN %s ON %s = %s', $tableRef, @@ -171,7 +171,7 @@ private function isSelfReferencing(): bool private function getOwnerJoin(ModelInspector $ownerModel): string { $ownerJoin = $this->ownerJoin; - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); + $ownerTable = $ownerModel->getTableName(); if ($ownerJoin && ! strpos($ownerJoin, '.')) { $ownerJoin = sprintf('%s.%s', $ownerTable, $ownerJoin); diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index 24096089d..e453f44dd 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -247,7 +247,7 @@ private function resolveOwnerJoin( private function resolveRelationJoin(ModelInspector $ownerModel): string { $relationJoin = $this->relationJoin; - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); + $ownerTable = $ownerModel->getTableName(); if ( $relationJoin diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 831d435da..3088682e6 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -500,6 +500,27 @@ public function getResolvedRelations(): array $relations = [...$relations, ...$resolvedRelations]; } + return $this->shouldUsePropertyNameAlias($relations); + } + + /** + * @param Relation[] $relations + * + * @return Relation[] + */ + private function shouldUsePropertyNameAlias(array $relations): array + { + $rootTables = arr(input: $relations) + ->filter(filter: fn (Relation $_, string $path) => ! str_contains(haystack: $path, needle: '.')) + ->map(map: fn (Relation $relation) => inspect(model: $relation)->getTableName()) + ->toArray(); + + $counts = array_count_values(array: $rootTables); + + arr(input: $rootTables) + ->filter(filter: fn (string $table) => $counts[$table] > 1) + ->each(each: fn (string $_, string $path) => $relations[$path]->withPropertyNameAlias()); + return $relations; } diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 794e0308a..956473f90 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -189,7 +189,7 @@ private function isSelfReferencing(): bool private function getRelationJoin(ModelInspector $relationModel): string { $relationJoin = $this->relationJoin; - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); + $ownerTable = $relationModel->getTableName(); if ($relationJoin && ! strpos($relationJoin, '.')) { $relationJoin = sprintf( diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index 9837bd1bb..ed5bb7efd 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -216,7 +216,7 @@ private function resolveOwnerJoin( private function resolveRelationJoin(ModelInspector $ownerModel): string { $relationJoin = $this->relationJoin; - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); + $ownerTable = $ownerModel->getTableName(); if ( $relationJoin diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index 2bb3e6f0e..e09670741 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -159,7 +159,7 @@ private function isSelfReferencing(): bool private function getRelationJoin(ModelInspector $relationModel): string { $relationJoin = $this->relationJoin; - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $relationModel->getTableName()); + $ownerTable = $relationModel->getTableName(); if ($relationJoin && ! strpos($relationJoin, '.')) { $relationJoin = sprintf( diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 219451aaf..ed43d44af 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -173,7 +173,7 @@ private function resolveOwnerJoin( private function resolveRelationJoin(ModelInspector $ownerModel): string { $relationJoin = $this->relationJoin; - $ownerTable = $this->getOwnerTableAlias(ownerTableName: $ownerModel->getTableName()); + $ownerTable = $ownerModel->getTableName(); if ( $relationJoin diff --git a/packages/database/src/HasTableAlias.php b/packages/database/src/HasTableAlias.php index 0fcc0fd5b..8bf826988 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -8,6 +8,15 @@ trait HasTableAlias { + public bool $withPropertyNameAlias = false; + + public function withPropertyNameAlias(): self + { + $this->withPropertyNameAlias = true; + + return $this; + } + private function getTableAlias(string $tableName): string { if ($this->parent === null) { @@ -15,11 +24,9 @@ private function getTableAlias(string $tableName): string } if ($this->parent === '') { - $alias = $this->property->getName(); - - return $alias === $tableName - ? $tableName - : str(string: $alias)->wrap('`')->toString(); + return $this->withPropertyNameAlias + ? $this->property->getName() + : $tableName; } return str(string: $this->parent) @@ -31,22 +38,6 @@ private function getTableAlias(string $tableName): string '_', $this->property->getName(), ) - ->wrap('`') - ->toString(); - } - - private function getOwnerTableAlias(string $ownerTableName): string - { - if ($this->parent === null || $this->parent === '') { - return $ownerTableName; - } - - return str(string: $this->parent) - ->replace( - search: '.', - replace: '_', - ) - ->wrap('`') ->toString(); } diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index ce4f07315..b874aabb4 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -45,11 +45,11 @@ function (string $part) use ($dialect) { return $part; } - return match ($dialect) { - DatabaseDialect::SQLITE => $part, - DatabaseDialect::POSTGRESQL => sprintf('"%s"', $part), - default => sprintf('`%s`', $part), - }; + if ($dialect === DatabaseDialect::SQLITE) { + return $part; + } + + return sprintf('`%s`', $part); }, ) ->implode('.'); diff --git a/packages/database/src/QueryStatements/JoinStatement.php b/packages/database/src/QueryStatements/JoinStatement.php index aec2bdf07..a05d9f7f9 100644 --- a/packages/database/src/QueryStatements/JoinStatement.php +++ b/packages/database/src/QueryStatements/JoinStatement.php @@ -18,13 +18,9 @@ public function compile(DatabaseDialect $dialect): string $statement = $this->statement; if (! str($statement)->lower()->startsWith(['join', 'inner join', 'left join', 'right join', 'full join', 'full outer join', 'self join'])) { - $statement = sprintf('INNER JOIN %s', $statement); + return sprintf('INNER JOIN %s', $statement); } - return match ($dialect) { - DatabaseDialect::POSTGRESQL => str_replace('`', '"', $statement), - DatabaseDialect::SQLITE => str_replace('`', '', $statement), - default => $statement, - }; + return $statement; } } diff --git a/packages/database/src/Relation.php b/packages/database/src/Relation.php index 3c33c01c4..0a27f7756 100644 --- a/packages/database/src/Relation.php +++ b/packages/database/src/Relation.php @@ -11,8 +11,12 @@ interface Relation extends PropertyAttribute { public string $name { get; } + public bool $withPropertyNameAlias { get; } + public function setParent(string $name): self; + public function withPropertyNameAlias(): self; + public function getSelectFields(): ImmutableArray; public function getJoinStatement(): JoinStatement; diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 6610f2026..6524d25ce 100644 --- a/packages/database/tests/QueryStatements/FieldStatementTest.php +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -47,12 +47,12 @@ public function test_mysql(): void public function test_postgres(): void { $this->assertSame( - '"table"."field"', + '`table`.`field`', new FieldStatement('`table`.`field`')->compile(DatabaseDialect::POSTGRESQL), ); $this->assertSame( - '"table"."field"', + '`table`.`field`', new FieldStatement('table.field')->compile(DatabaseDialect::POSTGRESQL), ); } diff --git a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php index 0653bf2a5..acf94a7a8 100644 --- a/tests/Integration/Database/Builder/SelectQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/SelectQueryBuilderTest.php @@ -458,7 +458,7 @@ public function test_with_belongs_to_relation(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id`, author.id AS `author.id`, author.name AS `author.name`, author.type AS `author.type`, author.publisher_id AS `author.publisher_id`, chapters.id AS `chapters.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, isbn.id AS `isbn.id`, isbn.value AS `isbn.value`, isbn.book_id AS `isbn.book_id` FROM `books` LEFT JOIN authors AS author ON author.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns AS isbn ON isbn.book_id = books.id', + 'SELECT books.id AS `books.id`, books.title AS `books.title`, books.author_id AS `books.author_id`, authors.id AS `author.id`, authors.name AS `author.name`, authors.type AS `author.type`, authors.publisher_id AS `author.publisher_id`, chapters.id AS `chapters.id`, chapters.title AS `chapters.title`, chapters.contents AS `chapters.contents`, chapters.book_id AS `chapters.book_id`, isbns.id AS `isbn.id`, isbns.value AS `isbn.value`, isbns.book_id AS `isbn.book_id` FROM `books` LEFT JOIN authors ON authors.id = books.author_id LEFT JOIN chapters ON chapters.book_id = books.id LEFT JOIN isbns ON isbns.book_id = books.id', $query->compile(), ); } @@ -610,7 +610,7 @@ public function test_paginate_preserves_relations(): void $page1 = query(Chapter::class) ->select() ->with('book') - ->whereRaw('book.title = ?', 'LOTR 1') + ->whereRaw('books.title = ?', 'LOTR 1') ->paginate(itemsPerPage: 5, currentPage: 1); $this->assertSame(3, $page1->totalItems); @@ -734,7 +734,7 @@ public function test_select_with_has_one_through_relation(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT tags.id AS `tags.id`, tags.label AS `tags.label`, topReviewer.id AS `topReviewer.id`, topReviewer.name AS `topReviewer.name`, topReviewer.book_review_id AS `topReviewer.book_review_id` FROM `tags` LEFT JOIN book_reviews ON book_reviews.tag_id = tags.id LEFT JOIN reviewers AS topReviewer ON topReviewer.book_review_id = book_reviews.id', + 'SELECT tags.id AS `tags.id`, tags.label AS `tags.label`, reviewers.id AS `topReviewer.id`, reviewers.name AS `topReviewer.name`, reviewers.book_review_id AS `topReviewer.book_review_id` FROM `tags` LEFT JOIN book_reviews ON book_reviews.tag_id = tags.id LEFT JOIN reviewers ON reviewers.book_review_id = book_reviews.id', $query->compile(), ); } @@ -747,7 +747,7 @@ public function test_select_with_duplicate_belongs_to_many_target_table(): void ->build(); $this->assertSameWithoutBackticks( - 'SELECT users.id AS `users.id`, users.name AS `users.name`, users.role_id AS `users.role_id`, role.id AS `role.id`, role.name AS `role.name`, role_permissions.id AS `role.permissions.id`, role_permissions.label AS `role.permissions.label`, permissions.id AS `permissions.id`, permissions.label AS `permissions.label` FROM `users` LEFT JOIN roles AS role ON role.id = users.role_id LEFT JOIN permissions_roles ON permissions_roles.role_id = role.id LEFT JOIN permissions AS role_permissions ON role_permissions.id = permissions_roles.permission_id LEFT JOIN permissions_users ON permissions_users.user_id = users.id LEFT JOIN permissions ON permissions.id = permissions_users.permission_id', + 'SELECT users.id AS `users.id`, users.name AS `users.name`, users.role_id AS `users.role_id`, roles.id AS `role.id`, roles.name AS `role.name`, role_permissions.id AS `role.permissions.id`, role_permissions.label AS `role.permissions.label`, permissions.id AS `permissions.id`, permissions.label AS `permissions.label` FROM `users` LEFT JOIN roles ON roles.id = users.role_id LEFT JOIN permissions_roles ON permissions_roles.role_id = roles.id LEFT JOIN permissions AS role_permissions ON role_permissions.id = permissions_roles.permission_id LEFT JOIN permissions_users ON permissions_users.user_id = users.id LEFT JOIN permissions ON permissions.id = permissions_users.permission_id', $query->compile(), ); } diff --git a/tests/Integration/Database/ModelInspector/BelongsToManyTest.php b/tests/Integration/Database/ModelInspector/BelongsToManyTest.php index 7f5e5a2ee..ac2d06d4f 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToManyTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToManyTest.php @@ -67,7 +67,7 @@ public function test_belongs_to_many_with_parent_join_uses_alias(): void ->setParent(name: 'parent'); $this->assertSame( - expected: 'LEFT JOIN owner_target ON owner_target.owner_id = parent.id LEFT JOIN target AS parent_targets ON parent_targets.id = owner_target.target_id', + expected: 'LEFT JOIN owner_target ON owner_target.owner_id = owner.id LEFT JOIN target AS parent_targets ON parent_targets.id = owner_target.target_id', actual: $relation ->getJoinStatement() ->compile(dialect: DatabaseDialect::SQLITE), diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index 86cdb445e..d1e453378 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -107,8 +107,8 @@ public function test_multiple_belongs_to_same_table_generates_distinct_joins(): { $model = inspect(BelongsToTestRoleWithMultipleSameTableRelationsModel::class); - $createdByRelation = $model->getRelation('createdBy')->setParent(''); - $updatedByRelation = $model->getRelation('updatedBy')->setParent(''); + $createdByRelation = $model->getRelation('createdBy')->setParent('')->withPropertyNameAlias(); + $updatedByRelation = $model->getRelation('updatedBy')->setParent('')->withPropertyNameAlias(); $this->assertEquals( 'LEFT JOIN users AS createdBy ON createdBy.id = roles.created_by', @@ -125,8 +125,8 @@ public function test_multiple_belongs_to_same_table_with_full_table_column_synta { $model = inspect(BelongsToTestRoleWithFullSpecRelationsModel::class); - $createdByRelation = $model->getRelation('created_by')->setParent(''); - $updatedByRelation = $model->getRelation('updated_by')->setParent(''); + $createdByRelation = $model->getRelation('created_by')->setParent('')->withPropertyNameAlias(); + $updatedByRelation = $model->getRelation('updated_by')->setParent('')->withPropertyNameAlias(); $this->assertEquals( 'LEFT JOIN users AS created_by ON created_by.id = roles.created_by', @@ -143,8 +143,8 @@ public function test_multiple_belongs_to_same_table_select_fields(): void { $model = inspect(BelongsToTestRoleWithMultipleSameTableRelationsModel::class); - $createdByFields = $model->getRelation('createdBy')->setParent('')->getSelectFields(); - $updatedByFields = $model->getRelation('updatedBy')->setParent('')->getSelectFields(); + $createdByFields = $model->getRelation('createdBy')->setParent('')->withPropertyNameAlias()->getSelectFields(); + $updatedByFields = $model->getRelation('updatedBy')->setParent('')->withPropertyNameAlias()->getSelectFields(); $this->assertSame( 'createdBy.id AS `createdBy.id`', From d6ac9018e56a68db2945db5408bc09cb7ca02444 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 13:47:42 +0000 Subject: [PATCH 13/14] test(database): add unit tests for shouldUsePropertyNameAlias detection Verify the query builder correctly flags duplicate target tables: - single relation: not aliased - duplicate target tables: both aliased - mixed relations: only duplicates aliased - nested relations under non-duplicate: not aliased --- .../src/QueryStatements/FieldStatement.php | 2 +- .../QueryStatements/FieldStatementTest.php | 4 +- .../ShouldUsePropertyNameAliasTest.php | 139 ++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Database/Builder/ShouldUsePropertyNameAliasTest.php diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index b874aabb4..bad8067f2 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -49,7 +49,7 @@ function (string $part) use ($dialect) { return $part; } - return sprintf('`%s`', $part); + return $dialect->quoteIdentifier($part); }, ) ->implode('.'); diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 6524d25ce..6610f2026 100644 --- a/packages/database/tests/QueryStatements/FieldStatementTest.php +++ b/packages/database/tests/QueryStatements/FieldStatementTest.php @@ -47,12 +47,12 @@ public function test_mysql(): void public function test_postgres(): void { $this->assertSame( - '`table`.`field`', + '"table"."field"', new FieldStatement('`table`.`field`')->compile(DatabaseDialect::POSTGRESQL), ); $this->assertSame( - '`table`.`field`', + '"table"."field"', new FieldStatement('table.field')->compile(DatabaseDialect::POSTGRESQL), ); } diff --git a/tests/Integration/Database/Builder/ShouldUsePropertyNameAliasTest.php b/tests/Integration/Database/Builder/ShouldUsePropertyNameAliasTest.php new file mode 100644 index 000000000..41a48c851 --- /dev/null +++ b/tests/Integration/Database/Builder/ShouldUsePropertyNameAliasTest.php @@ -0,0 +1,139 @@ +select() + ->with('author') + ->getResolvedRelations(); + + $this->assertFalse($relations['author']->withPropertyNameAlias); + } + + #[Test] + public function duplicate_target_tables_are_aliased(): void + { + $relations = query(model: DuplicateRelationModel::class) + ->select() + ->with('createdBy', 'updatedBy') + ->getResolvedRelations(); + + $this->assertTrue($relations['createdBy']->withPropertyNameAlias); + $this->assertTrue($relations['updatedBy']->withPropertyNameAlias); + } + + #[Test] + public function mixed_relations_only_alias_duplicates(): void + { + $relations = query(model: MixedRelationModel::class) + ->select() + ->with('title', 'createdBy', 'updatedBy') + ->getResolvedRelations(); + + $this->assertFalse($relations['title']->withPropertyNameAlias); + $this->assertTrue($relations['createdBy']->withPropertyNameAlias); + $this->assertTrue($relations['updatedBy']->withPropertyNameAlias); + } + + #[Test] + public function nested_relations_under_non_duplicate_are_not_aliased(): void + { + $relations = query(model: SingleRelationModel::class) + ->select() + ->with('author', 'author.posts') + ->getResolvedRelations(); + + $this->assertFalse($relations['author']->withPropertyNameAlias); + $this->assertFalse($relations['author.posts']->withPropertyNameAlias); + } +} + +#[Table('alias_test_books')] +final class SingleRelationModel +{ + use IsDatabaseModel; + + #[BelongsTo] + public ?AliasTestAuthor $author = null; + + public string $title; +} + +#[Table('alias_test_authors')] +final class AliasTestAuthor +{ + use IsDatabaseModel; + + public string $name; + + /** @var \Tests\Tempest\Integration\Database\Builder\SingleRelationModel[] */ + #[HasMany] + public array $posts = []; +} + +#[Table('alias_test_items')] +final class DuplicateRelationModel +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'created_by')] + public ?AliasTestUser $createdBy = null; + + #[BelongsTo(ownerJoin: 'updated_by')] + public ?AliasTestUser $updatedBy = null; + + public string $name; +} + +#[Table('alias_test_mixed')] +final class MixedRelationModel +{ + use IsDatabaseModel; + + #[HasOne] + public ?AliasTestTitle $title = null; + + #[BelongsTo(ownerJoin: 'created_by')] + public ?AliasTestUser $createdBy = null; + + #[BelongsTo(ownerJoin: 'updated_by')] + public ?AliasTestUser $updatedBy = null; + + public string $name; +} + +#[Table('alias_test_users')] +final class AliasTestUser +{ + use IsDatabaseModel; + + public string $name; +} + +#[Table('alias_test_titles')] +final class AliasTestTitle +{ + use IsDatabaseModel; + + public string $value; +} From ff5fde729b5984f24f10877f4159413751312b74 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Tue, 31 Mar 2026 12:54:04 +0100 Subject: [PATCH 14/14] fix(database): quote aliased identifiers for PostgreSQL compatibility Wrap aliases in backticks from getTableAlias when withPropertyNameAlias is set. JoinStatement::compile() converts backticks to double quotes for PostgreSQL and strips them for SQLite. FieldStatement already handles this via DatabaseDialect::quoteIdentifier(). This ensures aliased table names are consistently quoted across SELECT fields and JOIN clauses, preventing PostgreSQL case-sensitivity mismatches. --- packages/database/src/HasTableAlias.php | 3 ++- packages/database/src/QueryStatements/JoinStatement.php | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/database/src/HasTableAlias.php b/packages/database/src/HasTableAlias.php index 8bf826988..0fd8a5161 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -25,7 +25,7 @@ private function getTableAlias(string $tableName): string if ($this->parent === '') { return $this->withPropertyNameAlias - ? $this->property->getName() + ? str(string: $this->property->getName())->wrap('`')->toString() : $tableName; } @@ -38,6 +38,7 @@ private function getTableAlias(string $tableName): string '_', $this->property->getName(), ) + ->wrap('`') ->toString(); } diff --git a/packages/database/src/QueryStatements/JoinStatement.php b/packages/database/src/QueryStatements/JoinStatement.php index a05d9f7f9..aec2bdf07 100644 --- a/packages/database/src/QueryStatements/JoinStatement.php +++ b/packages/database/src/QueryStatements/JoinStatement.php @@ -18,9 +18,13 @@ public function compile(DatabaseDialect $dialect): string $statement = $this->statement; if (! str($statement)->lower()->startsWith(['join', 'inner join', 'left join', 'right join', 'full join', 'full outer join', 'self join'])) { - return sprintf('INNER JOIN %s', $statement); + $statement = sprintf('INNER JOIN %s', $statement); } - return $statement; + return match ($dialect) { + DatabaseDialect::POSTGRESQL => str_replace('`', '"', $statement), + DatabaseDialect::SQLITE => str_replace('`', '', $statement), + default => $statement, + }; } }