diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 437d9b2ef..a6b1b4716 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -126,7 +126,11 @@ private function getRelationJoin(ModelInspector $relationModel, string $tableAli } if ($relationJoin) { - return $relationJoin; + return $this->replaceTableReference( + qualifiedColumn: $relationJoin, + originalTable: $relationModel->getTableName(), + aliasedTable: $tableAlias, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -167,18 +171,23 @@ private function isSelfReferencing(): bool private function getOwnerJoin(ModelInspector $ownerModel): string { $ownerJoin = $this->ownerJoin; + $ownerTable = $ownerModel->getTableName(); if ($ownerJoin && ! strpos($ownerJoin, '.')) { - $ownerJoin = sprintf('%s.%s', $ownerModel->getTableName(), $ownerJoin); + $ownerJoin = sprintf('%s.%s', $ownerTable, $ownerJoin); } if ($ownerJoin) { - return $ownerJoin; + return $this->replaceTableReference( + qualifiedColumn: $ownerJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $this->getOwnerFieldName(), ); } diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index cd2206396..e453f44dd 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 = $ownerModel->getTableName(); if ( $relationJoin @@ -257,13 +258,17 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string ) { return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $relationJoin, ); } if ($relationJoin) { - return $relationJoin; + return $this->replaceTableReference( + qualifiedColumn: $relationJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $ownerModel->getPrimaryKey(); @@ -277,7 +282,7 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $primaryKey, ); } 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 fef15dca2..956473f90 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->replaceTableReference( + qualifiedColumn: $ownerJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $tableReference, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -183,17 +189,22 @@ private function isSelfReferencing(): bool private function getRelationJoin(ModelInspector $relationModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $relationModel->getTableName(); if ($relationJoin && ! strpos($relationJoin, '.')) { $relationJoin = sprintf( '%s.%s', - $relationModel->getTableName(), + $ownerTable, $relationJoin, ); } if ($relationJoin) { - return $relationJoin; + return $this->replaceTableReference( + qualifiedColumn: $relationJoin, + originalTable: $relationModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -204,7 +215,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..ed5bb7efd 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 = $ownerModel->getTableName(); if ( $relationJoin @@ -226,13 +227,17 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string ) { return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $relationJoin, ); } if ($relationJoin) { - return $relationJoin; + return $this->replaceTableReference( + qualifiedColumn: $relationJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $ownerModel->getPrimaryKey(); @@ -246,7 +251,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..e09670741 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->replaceTableReference( + qualifiedColumn: $ownerJoin, + originalTable: inspect($this->property->getType()->asClass())->getTableName(), + aliasedTable: $tableReference, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -155,17 +159,22 @@ private function isSelfReferencing(): bool private function getRelationJoin(ModelInspector $relationModel): string { $relationJoin = $this->relationJoin; + $ownerTable = $relationModel->getTableName(); if ($relationJoin && ! strpos($relationJoin, '.')) { $relationJoin = sprintf( '%s.%s', - $relationModel->getTableName(), + $ownerTable, $relationJoin, ); } if ($relationJoin) { - return $relationJoin; + return $this->replaceTableReference( + qualifiedColumn: $relationJoin, + originalTable: $relationModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $relationModel->getPrimaryKey(); @@ -176,7 +185,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..ed43d44af 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 = $ownerModel->getTableName(); if ( $relationJoin @@ -183,13 +184,17 @@ private function resolveRelationJoin(ModelInspector $ownerModel): string ) { return sprintf( '%s.%s', - $ownerModel->getTableName(), + $ownerTable, $relationJoin, ); } if ($relationJoin) { - return $relationJoin; + return $this->replaceTableReference( + qualifiedColumn: $relationJoin, + originalTable: $ownerModel->getTableName(), + aliasedTable: $ownerTable, + ); } $primaryKey = $ownerModel->getPrimaryKey(); @@ -203,7 +208,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..0fd8a5161 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -8,12 +8,27 @@ 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 || $this->parent === '') { + if ($this->parent === null) { return $tableName; } + if ($this->parent === '') { + return $this->withPropertyNameAlias + ? str(string: $this->property->getName())->wrap('`')->toString() + : $tableName; + } + return str(string: $this->parent) ->replace( search: '.', @@ -23,6 +38,21 @@ private function getTableAlias(string $tableName): string '_', $this->property->getName(), ) + ->wrap('`') + ->toString(); + } + + private function replaceTableReference(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/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, + }; } } 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/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; +} diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index 7c2747510..d1e453378 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('')->withPropertyNameAlias(); + $updatedByRelation = $model->getRelation('updatedBy')->setParent('')->withPropertyNameAlias(); + + $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('')->withPropertyNameAlias(); + $updatedByRelation = $model->getRelation('updated_by')->setParent('')->withPropertyNameAlias(); + + $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('')->withPropertyNameAlias()->getSelectFields(); + $updatedByFields = $model->getRelation('updatedBy')->setParent('')->withPropertyNameAlias()->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 { diff --git a/tests/Integration/Database/MultipleSameTableRelationsTest.php b/tests/Integration/Database/MultipleSameTableRelationsTest.php new file mode 100644 index 000000000..8297af8ba --- /dev/null +++ b/tests/Integration/Database/MultipleSameTableRelationsTest.php @@ -0,0 +1,543 @@ +database->migrate( + CreateMigrationsTable::class, + CreateStUserMigration::class, + CreateStRoleMigration::class, + ); + + $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: StRole::class) + ->select() + ->with('createdBy', 'updatedBy') + ->first(); + + $this->assertSame('admin', $role->code); + $this->assertInstanceOf(StUser::class, $role->createdBy); + $this->assertInstanceOf(StUser::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, + CreateStUserMigration::class, + CreateStFullSpecRoleMigration::class, + ); + + $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: StFullSpecRole::class) + ->select() + ->with('createdByUser', 'updatedByUser') + ->first(); + + $this->assertSame('moderator', $role->code); + $this->assertInstanceOf(StUser::class, $role->createdByUser); + $this->assertInstanceOf(StUser::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, + CreateStUserMigration::class, + CreateStEagerRoleMigration::class, + ); + + $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: StEagerRole::class) + ->select() + ->first(); + + $this->assertSame('player', $role->code); + $this->assertInstanceOf(StUser::class, $role->createdBy); + $this->assertInstanceOf(StUser::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, + CreateStUserMigration::class, + CreateStEagerRoleMigration::class, + CreateStTaskMigration::class, + ); + + $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: StTask::class) + ->select() + ->with('role', 'role.createdBy', 'role.updatedBy') + ->first(); + + $this->assertSame('Task 1', $task->title); + $this->assertInstanceOf(StEagerRole::class, $task->role); + $this->assertSame('admin', $task->role->code); + $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); + } + + // HasMany + + #[Test] + public function two_has_many_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateStUserMigration::class, + CreateStMessageMigration::class, + ); + + $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: StUser::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, + CreateStAddressMigration::class, + CreateStPersonMigration::class, + ); + + $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: StPerson::class) + ->select() + ->with('homeAddress', 'workAddress') + ->first(); + + $this->assertSame('Alice', $person->name); + $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); + } + + #[Test] + public function parent_to_child_with_two_belongs_to_same_subchild(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateStAddressMigration::class, + CreateStPersonMigration::class, + CreateStCompanyMigration::class, + ); + + $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: StCompany::class) + ->select() + ->with('ceo', 'ceo.homeAddress', 'ceo.workAddress') + ->first(); + + $this->assertSame('Acme', $company->name); + $this->assertInstanceOf(StPerson::class, $company->ceo); + $this->assertSame('Bob', $company->ceo->name); + $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); + } + + // HasOne + + #[Test] + public function two_has_one_to_same_table(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreateStEmployeeMigration::class, + CreateStContactMigration::class, + ); + + $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: StEmployee::class) + ->select() + ->with('workContact', 'personalContact') + ->first(); + + $this->assertSame('Alice', $employee->name); + $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); + } +} + +// Models + +#[Table('st_users')] +final class StUser +{ + use IsDatabaseModel; + + /** @var \Tests\Tempest\Integration\Database\StMessage[] */ + #[HasMany(ownerJoin: 'sender_id')] + public array $sentMessages = []; + + /** @var \Tests\Tempest\Integration\Database\StMessage[] */ + #[HasMany(ownerJoin: 'receiver_id')] + public array $receivedMessages = []; + + public string $name; +} + +#[Table('st_messages')] +final class StMessage +{ + use IsDatabaseModel; + + public string $body; + + #[BelongsTo(ownerJoin: 'sender_id')] + public ?StUser $sender = null; + + #[BelongsTo(ownerJoin: 'receiver_id')] + public ?StUser $receiver = null; +} + +#[Table('st_addresses')] +final class StAddress +{ + use IsDatabaseModel; + + public string $street; +} + +#[Table('st_persons')] +final class StPerson +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'home_address_id')] + public ?StAddress $homeAddress = null; + + #[BelongsTo(ownerJoin: 'work_address_id')] + public ?StAddress $workAddress = null; + + public string $name; +} + +#[Table('st_companies')] +final class StCompany +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'ceo_id')] + public ?StPerson $ceo = null; + + public string $name; +} + +#[Table('st_full_spec_roles')] +final class StFullSpecRole +{ + use IsDatabaseModel; + + #[Eager] + #[BelongsTo(relationJoin: 'st_users.id', ownerJoin: 'st_full_spec_roles.created_by')] + public ?StUser $createdByUser = null; + + #[Eager] + #[BelongsTo(relationJoin: 'st_users.id', ownerJoin: 'st_full_spec_roles.updated_by')] + public ?StUser $updatedByUser = null; + + public string $code; +} + +#[Table('st_roles')] +final class StRole +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'created_by')] + public ?StUser $createdBy = null; + + #[BelongsTo(ownerJoin: 'updated_by')] + public ?StUser $updatedBy = null; + + public string $code; +} + +#[Table('st_eager_roles')] +final class StEagerRole +{ + use IsDatabaseModel; + + #[Eager] + #[BelongsTo(ownerJoin: 'created_by')] + public ?StUser $createdBy = null; + + #[Eager] + #[BelongsTo(ownerJoin: 'updated_by')] + public ?StUser $updatedBy = null; + + /** @var \Tests\Tempest\Integration\Database\StTask[] */ + #[HasMany(ownerJoin: 'role_id')] + public array $tasks = []; + + public string $code; +} + +#[Table('st_tasks')] +final class StTask +{ + use IsDatabaseModel; + + #[BelongsTo(ownerJoin: 'role_id')] + public ?StEagerRole $role = null; + + public string $title; +} + +#[Table('st_employees')] +final class StEmployee +{ + use IsDatabaseModel; + + #[HasOne(ownerJoin: 'employee_work_id')] + public ?StContact $workContact = null; + + #[HasOne(ownerJoin: 'employee_personal_id')] + public ?StContact $personalContact = null; + + public string $name; +} + +#[Table('st_contacts')] +final class StContact +{ + use IsDatabaseModel; + + public string $value; + + #[BelongsTo(ownerJoin: 'employee_work_id')] + public ?StEmployee $workEmployee = null; + + #[BelongsTo(ownerJoin: 'employee_personal_id')] + public ?StEmployee $personalEmployee = null; +} + +// Migrations + +final class CreateStUserMigration implements MigratesUp +{ + public string $name = '001_create_st_users'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StUser::class) + ->primary() + ->text(name: 'name'); + } +} + +final class CreateStMessageMigration implements MigratesUp +{ + public string $name = '002_create_st_messages'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StMessage::class) + ->primary() + ->text(name: 'body') + ->belongsTo(local: 'st_messages.sender_id', foreign: 'st_users.id') + ->belongsTo(local: 'st_messages.receiver_id', foreign: 'st_users.id'); + } +} + +final class CreateStAddressMigration implements MigratesUp +{ + public string $name = '001_create_st_addresses'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StAddress::class) + ->primary() + ->text(name: 'street'); + } +} + +final class CreateStPersonMigration implements MigratesUp +{ + public string $name = '002_create_st_persons'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StPerson::class) + ->primary() + ->text(name: 'name') + ->belongsTo(local: 'st_persons.home_address_id', foreign: 'st_addresses.id') + ->belongsTo(local: 'st_persons.work_address_id', foreign: 'st_addresses.id'); + } +} + +final class CreateStCompanyMigration implements MigratesUp +{ + public string $name = '003_create_st_companies'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StCompany::class) + ->primary() + ->text(name: 'name') + ->belongsTo(local: 'st_companies.ceo_id', foreign: 'st_persons.id'); + } +} + +final class CreateStRoleMigration implements MigratesUp +{ + public string $name = '002_create_st_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo(local: 'st_roles.created_by', foreign: 'st_users.id') + ->belongsTo(local: 'st_roles.updated_by', foreign: 'st_users.id'); + } +} + +final class CreateStEagerRoleMigration implements MigratesUp +{ + public string $name = '002_create_st_eager_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StEagerRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo(local: 'st_eager_roles.created_by', foreign: 'st_users.id') + ->belongsTo(local: 'st_eager_roles.updated_by', foreign: 'st_users.id'); + } +} + +final class CreateStTaskMigration implements MigratesUp +{ + public string $name = '003_create_st_tasks'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StTask::class) + ->primary() + ->text(name: 'title') + ->belongsTo(local: 'st_tasks.role_id', foreign: 'st_eager_roles.id'); + } +} + +final class CreateStContactMigration implements MigratesUp +{ + public string $name = '002_create_st_contacts'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StContact::class) + ->primary() + ->text(name: 'value') + ->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 CreateStEmployeeMigration implements MigratesUp +{ + public string $name = '002_create_st_employees'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StEmployee::class) + ->primary() + ->text(name: 'name'); + } +} + +final class CreateStFullSpecRoleMigration implements MigratesUp +{ + public string $name = '002_create_st_full_spec_roles'; + + public function up(): QueryStatement + { + return CreateTableStatement::forModel(modelClass: StFullSpecRole::class) + ->primary() + ->text(name: 'code') + ->belongsTo(local: 'st_full_spec_roles.created_by', foreign: 'st_users.id') + ->belongsTo(local: 'st_full_spec_roles.updated_by', foreign: 'st_users.id'); + } +}