diff --git a/packages/database/src/BelongsTo.php b/packages/database/src/BelongsTo.php index 5c0aa4685..88b0dd019 100644 --- a/packages/database/src/BelongsTo.php +++ b/packages/database/src/BelongsTo.php @@ -96,24 +96,18 @@ public function getJoinStatement(): JoinStatement if ($this->isSelfReferencing()) { return new JoinStatement(sprintf( 'LEFT JOIN %s AS %s ON %s = %s', - $relationModel->getTableName(), - $this->property->getName(), - $relationJoin, - $ownerJoin, + $this->quoteIdentifier($relationModel->getTableName()), + $this->quoteIdentifier($this->property->getName()), + $this->quoteIdentifier($relationJoin), + $this->quoteIdentifier($ownerJoin), )); } - $tableName = $relationModel->getTableName(); - $tableRef = $tableAlias !== $tableName - ? sprintf('%s AS %s', $tableName, $tableAlias) - : $tableName; - - // LEFT JOIN authors ON authors.id = books.author_id return new JoinStatement(sprintf( 'LEFT JOIN %s ON %s = %s', - $tableRef, - $relationJoin, - $ownerJoin, + $this->quoteTableReference($relationModel->getTableName(), $tableAlias), + $this->quoteIdentifier($relationJoin), + $this->quoteIdentifier($ownerJoin), )); } @@ -153,13 +147,16 @@ public function getExistsStatement(): WhereExistsStatement $relatedTable = $relatedModel->getTableName(); $parentTable = $parentModel->getTableName(); $relatedPK = $relatedModel->getPrimaryKey(); - $fk = $this->getOwnerFieldName(); return new WhereExistsStatement( - relatedTable: $relatedTable, + relatedTable: $this->quoteIdentifier($relatedTable), relatedModelName: $relatedModel->getName(), - condition: "{$relatedTable}.{$relatedPK} = {$parentTable}.{$fk}", + condition: sprintf( + '%s = %s', + $this->qualifyIdentifier((string) $relatedPK, $relatedTable), + $this->qualifyIdentifier($fk, $parentTable), + ), ); } @@ -210,13 +207,11 @@ public function query(PrimaryKey $primaryKey, string|UnitEnum|null $onDatabase = ->onDatabase(databaseTag: $onDatabase) ->scope(scope: new WhereRawScope( statement: sprintf( - '%s.%s = (SELECT %s FROM %s WHERE %s.%s = ?)', - $relatedTable, - $relatedPK, - $fk, - $ownerTable, - $ownerTable, - $ownerPK, + '%s = (SELECT %s FROM %s WHERE %s = ?)', + $this->qualifyIdentifier((string) $relatedPK, $relatedTable), + $this->quoteIdentifier($fk), + $this->quoteIdentifier($ownerTable), + $this->qualifyIdentifier((string) $ownerPK, $ownerTable), ), binding: $primaryKey, )); diff --git a/packages/database/src/BelongsToMany.php b/packages/database/src/BelongsToMany.php index c666a43be..4ac841264 100644 --- a/packages/database/src/BelongsToMany.php +++ b/packages/database/src/BelongsToMany.php @@ -165,12 +165,12 @@ private function buildFirstJoin( ): string { return sprintf( 'LEFT JOIN %s ON %s = %s', - $pivotTable, - $this->resolveOwnerJoin( + $this->quoteIdentifier($pivotTable), + $this->quoteIdentifier($this->resolveOwnerJoin( ownerModel: $ownerModel, pivotTable: $pivotTable, - ), - $this->resolveRelationJoin(ownerModel: $ownerModel), + )), + $this->quoteIdentifier($this->resolveRelationJoin(ownerModel: $ownerModel)), ); } @@ -182,22 +182,17 @@ private function buildSecondJoin( string $pivotTable, string $tableAlias, ): string { - $tableName = $targetModel->getTableName(); - $tableRef = $tableAlias !== $tableName - ? sprintf('%s AS %s', $tableName, $tableAlias) - : $tableName; - return sprintf( 'LEFT JOIN %s ON %s = %s', - $tableRef, - $this->resolveRelatedRelationJoin( + $this->quoteTableReference($targetModel->getTableName(), $tableAlias), + $this->quoteIdentifier($this->resolveRelatedRelationJoin( targetModel: $targetModel, tableAlias: $tableAlias, - ), - $this->resolveRelatedOwnerJoin( + )), + $this->quoteIdentifier($this->resolveRelatedOwnerJoin( targetModel: $targetModel, pivotTable: $pivotTable, - ), + )), ); } @@ -389,11 +384,20 @@ public function getExistsStatement(): WhereExistsStatement $targetFK = $this->relatedOwnerJoin ?? str(string: $targetTable)->singularizeLastWord()->append(suffix: "_{$targetPK}"); return new WhereExistsStatement( - relatedTable: $pivotTable, + relatedTable: $this->quoteIdentifier($pivotTable), relatedModelName: $targetModel->getName(), - condition: "{$pivotTable}.{$fk} = {$ownerTable}.{$ownerPK}", + condition: sprintf( + '%s = %s', + $this->qualifyIdentifier((string) $fk, $pivotTable), + $this->qualifyIdentifier((string) $ownerPK, $ownerTable), + ), joinStatement: new JoinStatement( - statement: "INNER JOIN {$targetTable} ON {$targetTable}.{$targetPK} = {$pivotTable}.{$targetFK}", + statement: sprintf( + 'INNER JOIN %s ON %s = %s', + $this->quoteIdentifier($targetTable), + $this->qualifyIdentifier((string) $targetPK, $targetTable), + $this->qualifyIdentifier((string) $targetFK, $pivotTable), + ), ), ); } @@ -416,12 +420,11 @@ public function query(PrimaryKey $primaryKey, string|UnitEnum|null $onDatabase = ->onDatabase(databaseTag: $onDatabase) ->scope(scope: new WhereRawScope( statement: sprintf( - '%s.%s IN (SELECT %s FROM %s WHERE %s = ?)', - $targetTable, - $targetPK, - $targetFK, - $pivotTable, - $ownerFK, + '%s IN (SELECT %s FROM %s WHERE %s = ?)', + $this->qualifyIdentifier((string) $targetPK, $targetTable), + $this->quoteIdentifier((string) $targetFK), + $this->quoteIdentifier($pivotTable), + $this->quoteIdentifier((string) $ownerFK), ), binding: $primaryKey, )); diff --git a/packages/database/src/HasMany.php b/packages/database/src/HasMany.php index 75685ad3a..c541e5c5f 100644 --- a/packages/database/src/HasMany.php +++ b/packages/database/src/HasMany.php @@ -105,23 +105,18 @@ public function getJoinStatement(): JoinStatement if ($this->isSelfReferencing()) { return new JoinStatement(sprintf( 'LEFT JOIN %s AS %s ON %s = %s', - $ownerModel->getTableName(), - $this->property->getName(), - $ownerJoin, - $relationJoin, + $this->quoteIdentifier($ownerModel->getTableName()), + $this->quoteIdentifier($this->property->getName()), + $this->quoteIdentifier($ownerJoin), + $this->quoteIdentifier($relationJoin), )); } - $tableName = $ownerModel->getTableName(); - $tableRef = $tableAlias !== $tableName - ? sprintf('%s AS %s', $tableName, $tableAlias) - : $tableName; - return new JoinStatement(sprintf( 'LEFT JOIN %s ON %s = %s', - $tableRef, - $ownerJoin, - $relationJoin, + $this->quoteTableReference($ownerModel->getTableName(), $tableAlias), + $this->quoteIdentifier($ownerJoin), + $this->quoteIdentifier($relationJoin), )); } @@ -171,13 +166,16 @@ public function getExistsStatement(): WhereExistsStatement $relatedTable = $relatedModel->getTableName(); $parentTable = $parentModel->getTableName(); $parentPK = $parentModel->getPrimaryKey(); - - $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}"); + $fk = (string) ($this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}")); return new WhereExistsStatement( - relatedTable: $relatedTable, + relatedTable: $this->quoteIdentifier($relatedTable), relatedModelName: $relatedModel->getName(), - condition: "{$relatedTable}.{$fk} = {$parentTable}.{$parentPK}", + condition: sprintf( + '%s = %s', + $this->qualifyIdentifier($fk, $relatedTable), + $this->qualifyIdentifier((string) $parentPK, $parentTable), + ), ); } diff --git a/packages/database/src/HasManyThrough.php b/packages/database/src/HasManyThrough.php index c06b1b912..2a8339b18 100644 --- a/packages/database/src/HasManyThrough.php +++ b/packages/database/src/HasManyThrough.php @@ -145,12 +145,12 @@ private function buildFirstJoin( ): string { return sprintf( 'LEFT JOIN %s ON %s = %s', - $intermediateModel->getTableName(), - $this->resolveOwnerJoin( + $this->quoteIdentifier($intermediateModel->getTableName()), + $this->quoteIdentifier($this->resolveOwnerJoin( intermediateModel: $intermediateModel, ownerModel: $ownerModel, - ), - $this->resolveRelationJoin(ownerModel: $ownerModel), + )), + $this->quoteIdentifier($this->resolveRelationJoin(ownerModel: $ownerModel)), ); } @@ -159,20 +159,16 @@ private function buildSecondJoin( ModelInspector $targetModel, ): string { $tableAlias = $this->getTableAlias(tableName: $targetModel->getTableName()); - $tableName = $targetModel->getTableName(); - $tableRef = $tableAlias !== $tableName - ? sprintf('%s AS %s', $tableName, $tableAlias) - : $tableName; return sprintf( 'LEFT JOIN %s ON %s = %s', - $tableRef, - $this->resolveThroughOwnerJoin( + $this->quoteTableReference($targetModel->getTableName(), $tableAlias), + $this->quoteIdentifier($this->resolveThroughOwnerJoin( targetModel: $targetModel, intermediateModel: $intermediateModel, tableAlias: $tableAlias, - ), - $this->resolveThroughRelationJoin(intermediateModel: $intermediateModel), + )), + $this->quoteIdentifier($this->resolveThroughRelationJoin(intermediateModel: $intermediateModel)), ); } @@ -355,11 +351,20 @@ public function getExistsStatement(): WhereExistsStatement $targetFK = $this->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord()->append(suffix: "_{$intermediatePK}"); return new WhereExistsStatement( - relatedTable: $intermediateTable, + relatedTable: $this->quoteIdentifier($intermediateTable), relatedModelName: $targetModel->getName(), - condition: "{$intermediateTable}.{$fk} = {$ownerTable}.{$ownerPK}", + condition: sprintf( + '%s = %s', + $this->qualifyIdentifier((string) $fk, $intermediateTable), + $this->qualifyIdentifier((string) $ownerPK, $ownerTable), + ), joinStatement: new JoinStatement( - statement: "INNER JOIN {$targetTable} ON {$targetTable}.{$targetFK} = {$intermediateTable}.{$intermediatePK}", + statement: sprintf( + 'INNER JOIN %s ON %s = %s', + $this->quoteIdentifier($targetTable), + $this->qualifyIdentifier((string) $targetFK, $targetTable), + $this->qualifyIdentifier((string) $intermediatePK, $intermediateTable), + ), ), ); } @@ -383,10 +388,10 @@ public function query(PrimaryKey $primaryKey, string|UnitEnum|null $onDatabase = ->scope(scope: new WhereRawScope( statement: sprintf( '%s IN (SELECT %s FROM %s WHERE %s = ?)', - $relatedTable . '.' . $targetFK, - $intermediatePK, - $intermediateTable, - $ownerFK, + $this->qualifyIdentifier((string) $targetFK, $relatedTable), + $this->quoteIdentifier((string) $intermediatePK), + $this->quoteIdentifier($intermediateTable), + $this->quoteIdentifier((string) $ownerFK), ), binding: $primaryKey, )); diff --git a/packages/database/src/HasOne.php b/packages/database/src/HasOne.php index a5b4308e9..9343d97f4 100644 --- a/packages/database/src/HasOne.php +++ b/packages/database/src/HasOne.php @@ -77,23 +77,18 @@ public function getJoinStatement(): JoinStatement if ($this->isSelfReferencing()) { return new JoinStatement(sprintf( 'LEFT JOIN %s AS %s ON %s = %s', - $ownerModel->getTableName(), - $this->property->getName(), - $ownerJoin, - $relationJoin, + $this->quoteIdentifier($ownerModel->getTableName()), + $this->quoteIdentifier($this->property->getName()), + $this->quoteIdentifier($ownerJoin), + $this->quoteIdentifier($relationJoin), )); } - $tableName = $ownerModel->getTableName(); - $tableRef = $tableAlias !== $tableName - ? sprintf('%s AS %s', $tableName, $tableAlias) - : $tableName; - return new JoinStatement(sprintf( 'LEFT JOIN %s ON %s = %s', - $tableRef, - $ownerJoin, - $relationJoin, + $this->quoteTableReference($ownerModel->getTableName(), $tableAlias), + $this->quoteIdentifier($ownerJoin), + $this->quoteIdentifier($relationJoin), )); } @@ -141,13 +136,16 @@ public function getExistsStatement(): WhereExistsStatement $relatedTable = $relatedModel->getTableName(); $parentTable = $parentModel->getTableName(); $parentPK = $parentModel->getPrimaryKey(); - - $fk = $this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}"); + $fk = (string) ($this->ownerJoin ?? str(string: $parentTable)->singularizeLastWord()->append(suffix: "_{$parentPK}")); return new WhereExistsStatement( - relatedTable: $relatedTable, + relatedTable: $this->quoteIdentifier($relatedTable), relatedModelName: $relatedModel->getName(), - condition: "{$relatedTable}.{$fk} = {$parentTable}.{$parentPK}", + condition: sprintf( + '%s = %s', + $this->qualifyIdentifier($fk, $relatedTable), + $this->qualifyIdentifier((string) $parentPK, $parentTable), + ), ); } diff --git a/packages/database/src/HasOneThrough.php b/packages/database/src/HasOneThrough.php index 8f23cabaf..f0a3fdc32 100644 --- a/packages/database/src/HasOneThrough.php +++ b/packages/database/src/HasOneThrough.php @@ -105,9 +105,9 @@ private function buildFirstJoin( ): string { return sprintf( 'LEFT JOIN %s ON %s = %s', - $intermediateModel->getTableName(), - $this->resolveOwnerJoin(intermediateModel: $intermediateModel, ownerModel: $ownerModel), - $this->resolveRelationJoin(ownerModel: $ownerModel), + $this->quoteIdentifier($intermediateModel->getTableName()), + $this->quoteIdentifier($this->resolveOwnerJoin(intermediateModel: $intermediateModel, ownerModel: $ownerModel)), + $this->quoteIdentifier($this->resolveRelationJoin(ownerModel: $ownerModel)), ); } @@ -116,20 +116,16 @@ private function buildSecondJoin( ModelInspector $targetModel, ): string { $tableAlias = $this->getTableAlias(tableName: $targetModel->getTableName()); - $tableName = $targetModel->getTableName(); - $tableRef = $tableAlias !== $tableName - ? sprintf('%s AS %s', $tableName, $tableAlias) - : $tableName; return sprintf( 'LEFT JOIN %s ON %s = %s', - $tableRef, - $this->resolveThroughOwnerJoin( + $this->quoteTableReference($targetModel->getTableName(), $tableAlias), + $this->quoteIdentifier($this->resolveThroughOwnerJoin( targetModel: $targetModel, intermediateModel: $intermediateModel, tableAlias: $tableAlias, - ), - $this->resolveThroughRelationJoin(intermediateModel: $intermediateModel), + )), + $this->quoteIdentifier($this->resolveThroughRelationJoin(intermediateModel: $intermediateModel)), ); } @@ -312,11 +308,20 @@ public function getExistsStatement(): WhereExistsStatement $targetFK = $this->throughOwnerJoin ?? str(string: $intermediateTable)->singularizeLastWord()->append(suffix: "_{$intermediatePK}"); return new WhereExistsStatement( - relatedTable: $intermediateTable, + relatedTable: $this->quoteIdentifier($intermediateTable), relatedModelName: $targetModel->getName(), - condition: "{$intermediateTable}.{$fk} = {$ownerTable}.{$ownerPK}", + condition: sprintf( + '%s = %s', + $this->qualifyIdentifier((string) $fk, $intermediateTable), + $this->qualifyIdentifier((string) $ownerPK, $ownerTable), + ), joinStatement: new JoinStatement( - statement: "INNER JOIN {$targetTable} ON {$targetTable}.{$targetFK} = {$intermediateTable}.{$intermediatePK}", + statement: sprintf( + 'INNER JOIN %s ON %s = %s', + $this->quoteIdentifier($targetTable), + $this->qualifyIdentifier((string) $targetFK, $targetTable), + $this->qualifyIdentifier((string) $intermediatePK, $intermediateTable), + ), ), ); } @@ -339,12 +344,11 @@ public function query(PrimaryKey $primaryKey, string|UnitEnum|null $onDatabase = ->onDatabase(databaseTag: $onDatabase) ->scope(scope: new WhereRawScope( statement: sprintf( - '%s.%s IN (SELECT %s FROM %s WHERE %s = ?)', - $relatedTable, - $targetFK, - $intermediatePK, - $intermediateTable, - $ownerFK, + '%s IN (SELECT %s FROM %s WHERE %s = ?)', + $this->qualifyIdentifier((string) $targetFK, $relatedTable), + $this->quoteIdentifier((string) $intermediatePK), + $this->quoteIdentifier($intermediateTable), + $this->quoteIdentifier((string) $ownerFK), ), binding: $primaryKey, )); diff --git a/packages/database/src/HasTableAlias.php b/packages/database/src/HasTableAlias.php index 0fd8a5161..7272957f1 100644 --- a/packages/database/src/HasTableAlias.php +++ b/packages/database/src/HasTableAlias.php @@ -42,6 +42,37 @@ private function getTableAlias(string $tableName): string ->toString(); } + private function quoteIdentifier(string $identifier): string + { + $parts = explode('.', $identifier); + + foreach ($parts as &$part) { + $part = sprintf('`%s`', trim($part, '`" ')); + } + + return implode('.', $parts); + } + + private function qualifyIdentifier(string $identifier, string $table): string + { + if (! str_contains($identifier, '.')) { + $identifier = "{$table}.{$identifier}"; + } + + return $this->quoteIdentifier($identifier); + } + + private function quoteTableReference(string $table, ?string $alias = null): string + { + $quotedTable = $this->quoteIdentifier($table); + + if ($alias === null || trim($alias, '`" ') === $table) { + return $quotedTable; + } + + return sprintf('%s AS %s', $quotedTable, $this->quoteIdentifier($alias)); + } + private function replaceTableReference(string $qualifiedColumn, string $originalTable, string $aliasedTable): string { if ($aliasedTable === $originalTable) { diff --git a/packages/database/src/QueryStatements/WhereExistsStatement.php b/packages/database/src/QueryStatements/WhereExistsStatement.php index 1ee4d303d..f28d4224e 100644 --- a/packages/database/src/QueryStatements/WhereExistsStatement.php +++ b/packages/database/src/QueryStatements/WhereExistsStatement.php @@ -52,13 +52,18 @@ public function compile(DatabaseDialect $dialect): string } if ($this->useCount) { - return "(SELECT COUNT(*) FROM {$fromClause} WHERE {$whereClause}) {$this->operator->value} {$this->count}"; + $statement = "(SELECT COUNT(*) FROM {$fromClause} WHERE {$whereClause}) {$this->operator->value} {$this->count}"; + } else { + $keyword = $this->negate + ? 'NOT EXISTS' + : 'EXISTS'; + $statement = "{$keyword} (SELECT 1 FROM {$fromClause} WHERE {$whereClause})"; } - $keyword = $this->negate - ? 'NOT EXISTS' - : 'EXISTS'; - - return "{$keyword} (SELECT 1 FROM {$fromClause} WHERE {$whereClause})"; + return match ($dialect) { + DatabaseDialect::POSTGRESQL => str_replace('`', '"', $statement), + DatabaseDialect::SQLITE => str_replace('`', '', $statement), + default => $statement, + }; } } diff --git a/tests/Integration/Database/ModelInspector/BelongsToTest.php b/tests/Integration/Database/ModelInspector/BelongsToTest.php index 6894f4579..c16313655 100644 --- a/tests/Integration/Database/ModelInspector/BelongsToTest.php +++ b/tests/Integration/Database/ModelInspector/BelongsToTest.php @@ -2,18 +2,65 @@ namespace Tests\Tempest\Integration\Database\ModelInspector; +use PHPUnit\Framework\Attributes\Test; use Tempest\Database\BelongsTo; use Tempest\Database\Config\DatabaseDialect; +use Tempest\Database\Config\PostgresConfig; +use Tempest\Database\Connection\PDOConnection; +use Tempest\Database\Database; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; +use Tempest\Database\GenericDatabase; use Tempest\Database\HasMany; use Tempest\Database\PrimaryKey; use Tempest\Database\Table; +use Tempest\Database\Transactions\GenericTransactionManager; +use Tempest\EventBus\EventBus; +use Tempest\Mapper\SerializerFactory; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use function Tempest\Database\inspect; final class BelongsToTest extends FrameworkIntegrationTestCase { + #[Test] + public function relation_queries_quote_camel_case_identifiers_for_postgresql(): void + { + $connection = new PDOConnection(new PostgresConfig()); + $this->container->singleton( + Database::class, + new GenericDatabase( + connection: $connection, + transactionManager: new GenericTransactionManager($connection), + serializerFactory: $this->container->get(SerializerFactory::class), + eventBus: $this->container->get(EventBus::class), + ), + ); + + $items = inspect(PostgresRelationStash::class)->getRelation('items'); + $stash = inspect(PostgresRelationStashItem::class)->getRelation('stash'); + + $this->assertSame( + 'LEFT JOIN "stash_items" ON "stash_items"."stashId" = "stashes"."id"', + $items->getJoinStatement()->compile(DatabaseDialect::POSTGRESQL), + ); + $this->assertSame( + 'EXISTS (SELECT 1 FROM "stash_items" WHERE "stash_items"."stashId" = "stashes"."id")', + $items->getExistsStatement()->compile(DatabaseDialect::POSTGRESQL), + ); + $this->assertSame( + 'LEFT JOIN "stashes" ON "stashes"."id" = "stash_items"."stashId"', + $stash->getJoinStatement()->compile(DatabaseDialect::POSTGRESQL), + ); + $this->assertSame( + 'EXISTS (SELECT 1 FROM "stashes" WHERE "stashes"."id" = "stash_items"."stashId")', + $stash->getExistsStatement()->compile(DatabaseDialect::POSTGRESQL), + ); + $this->assertSame( + 'SELECT "stashes"."id" AS "stashes.id" FROM "stashes" WHERE "stashes"."id" = (SELECT "stashId" FROM "stash_items" WHERE "stash_items"."id" = ?)', + $stash->query(new PrimaryKey(1))->select()->compile()->toString(), + ); + } + public function test_belongs_to(): void { $model = inspect(BelongsToTestOwnerModel::class); @@ -236,6 +283,27 @@ public function test_self_referencing_has_many_select_fields(): void } } +#[Table('stashes')] +final class PostgresRelationStash +{ + public PrimaryKey $id; + + /** @var \Tests\Tempest\Integration\Database\ModelInspector\PostgresRelationStashItem[] */ + #[HasMany(ownerJoin: 'stashId')] + public array $items = []; +} + +#[Table('stash_items')] +final class PostgresRelationStashItem +{ + public PrimaryKey $id; + + #[BelongsTo(ownerJoin: 'stashId')] + public PostgresRelationStash $stash; + + public string $stashId; +} + #[Table('relation')] final class BelongsToTestRelationModel {