From 0fe5025b35ad043de1fa0009543aa111d5adb02f Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Mon, 30 Mar 2026 23:34:53 +0100 Subject: [PATCH 1/2] fix(database): convert backtick identifiers to double quotes for PostgreSQL Query::compile() stripped backtick quoting entirely for PostgreSQL, leaving identifiers unquoted. This breaks for reserved words like order, user, group, and type. Now converts backticks to double quotes, which is the correct PostgreSQL identifier quoting. --- packages/database/src/Query.php | 2 +- packages/database/tests/QueryCompileTest.php | 177 +++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 packages/database/tests/QueryCompileTest.php diff --git a/packages/database/src/Query.php b/packages/database/src/Query.php index 958ee743de..6cf14d7dfc 100644 --- a/packages/database/src/Query.php +++ b/packages/database/src/Query.php @@ -76,7 +76,7 @@ public function compile(): ImmutableString } if ($dialect === DatabaseDialect::POSTGRESQL) { - $sql = str_replace('`', '', $sql); + $sql = str_replace('`', '"', $sql); } return new ImmutableString($sql); diff --git a/packages/database/tests/QueryCompileTest.php b/packages/database/tests/QueryCompileTest.php new file mode 100644 index 0000000000..9d17377548 --- /dev/null +++ b/packages/database/tests/QueryCompileTest.php @@ -0,0 +1,177 @@ +singleton( + className: Database::class, + definition: $database + ); + GenericContainer::setInstance(instance: $container); + } + + private function createDatabaseWithPostgresDialect(): void + { + $config = new PostgresConfig(); + $connection = new PDOConnection(config: $config); + + $database = new GenericDatabase( + connection: $connection, + transactionManager: new GenericTransactionManager(connection: $connection), + serializerFactory: new SerializerFactory(container: new GenericContainer()), + eventBus: new GenericEventBus( + container: new GenericContainer(), + eventBusConfig: new EventBusConfig(), + ), + ); + + $container = new GenericContainer(); + $container->singleton( + className: Database::class, + definition: $database + ); + GenericContainer::setInstance(instance: $container); + } + + private function createDatabaseWithSqliteDialect(): void + { + $config = new SQLiteConfig(path: ':memory:'); + $connection = new PDOConnection(config: $config); + + $database = new GenericDatabase( + connection: $connection, + transactionManager: new GenericTransactionManager(connection: $connection), + serializerFactory: new SerializerFactory(container: new GenericContainer()), + eventBus: new GenericEventBus( + container: new GenericContainer(), + eventBusConfig: new EventBusConfig(), + ), + ); + + $container = new GenericContainer(); + $container->singleton( + className: Database::class, + definition: $database + ); + GenericContainer::setInstance(instance: $container); + } + + #[Test] + public function postgresql_converts_backticks_to_double_quotes(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT `name`, `email` FROM `users` WHERE `id` = ?'); + + $this->assertSame( + 'SELECT "name", "email" FROM "users" WHERE "id" = ?', + $query->compile()->toString(), + ); + } + + #[Test] + public function postgresql_handles_qualified_identifiers(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT `users`.`name` FROM `users`'); + + $this->assertSame( + 'SELECT "users"."name" FROM "users"', + $query->compile()->toString(), + ); + } + + #[Test] + public function postgresql_handles_reserved_words(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT `order`, `user`, `type` FROM `group`'); + + $this->assertSame( + 'SELECT "order", "user", "type" FROM "group"', + $query->compile()->toString(), + ); + } + + #[Test] + public function postgresql_passes_through_sql_without_backticks(): void + { + $this->createDatabaseWithPostgresDialect(); + + $query = new Query(sql: 'SELECT 1'); + + $this->assertSame( + 'SELECT 1', + $query->compile()->toString() + ); + } + + #[Test] + public function mysql_retains_backticks(): void + { + $this->createDatabaseWithMysqlDialect(); + + $query = new Query(sql: 'SELECT `name` FROM `users` WHERE `id` = ?'); + + $this->assertSame( + 'SELECT `name` FROM `users` WHERE `id` = ?', + $query->compile()->toString(), + ); + } + + #[Test] + public function sqlite_retains_backticks(): void + { + $this->createDatabaseWithSqliteDialect(); + + $query = new Query(sql: 'SELECT `name` FROM `users` WHERE `id` = ?'); + + $this->assertSame( + 'SELECT `name` FROM `users` WHERE `id` = ?', + $query->compile()->toString(), + ); + } +} From cc40648acb1a99f77caaf9539f343866c9b17be7 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Mon, 30 Mar 2026 23:48:40 +0100 Subject: [PATCH 2/2] refactor(database): use dialect-aware quoteIdentifier across all statements Add DatabaseDialect::quoteIdentifier() that returns backticks for MySQL/SQLite and double quotes for PostgreSQL. Update all QueryStatement compile() methods to use it instead of hardcoded backticks. Query::compile() safety net remains for __toString() paths that lack dialect context. include UpdateStatement in quoteIdentifier refactor --- .../database/src/Config/DatabaseDialect.php | 8 +++++ .../QueryStatements/AlterTableStatement.php | 3 +- .../src/QueryStatements/BooleanStatement.php | 4 +-- .../src/QueryStatements/CharStatement.php | 4 +-- .../src/QueryStatements/CountStatement.php | 2 +- .../QueryStatements/CreateTableStatement.php | 3 +- .../src/QueryStatements/DateStatement.php | 4 +-- .../src/QueryStatements/DatetimeStatement.php | 10 +++--- .../src/QueryStatements/DeleteStatement.php | 2 +- .../DropConstraintStatement.php | 4 +-- .../QueryStatements/DropTableStatement.php | 4 +-- .../src/QueryStatements/EnumStatement.php | 14 ++++---- .../src/QueryStatements/FieldStatement.php | 24 ++++--------- .../src/QueryStatements/FloatStatement.php | 4 +-- .../src/QueryStatements/IdentityStatement.php | 2 +- .../src/QueryStatements/IndexStatement.php | 11 +++--- .../src/QueryStatements/InsertStatement.php | 2 +- .../src/QueryStatements/IntegerStatement.php | 10 +++--- .../src/QueryStatements/JsonStatement.php | 14 ++++---- .../QueryStatements/PrimaryKeyStatement.php | 8 +++-- .../src/QueryStatements/SetStatement.php | 4 +-- .../src/QueryStatements/TextStatement.php | 10 +++--- .../src/QueryStatements/UniqueStatement.php | 11 +++--- .../src/QueryStatements/UpdateStatement.php | 4 +-- .../UuidPrimaryKeyStatement.php | 8 +++-- .../src/QueryStatements/VarcharStatement.php | 4 +-- packages/database/tests/QueryCompileTest.php | 10 +++--- .../AlterTableStatementTest.php | 29 ++++++++++----- .../CreateTableStatementTest.php | 36 +++++++++---------- .../QueryStatements/DeleteStatementTest.php | 4 ++- .../QueryStatements/FieldStatementTest.php | 4 +-- .../QueryStatements/InsertStatementTest.php | 2 +- .../QueryStatements/UpdateStatementTest.php | 4 ++- .../UuidPrimaryKeyStatementTest.php | 2 +- .../Builder/InsertQueryBuilderTest.php | 2 +- .../Builder/UpdateQueryBuilderTest.php | 2 +- .../FrameworkIntegrationTestCase.php | 2 +- 37 files changed, 152 insertions(+), 123 deletions(-) diff --git a/packages/database/src/Config/DatabaseDialect.php b/packages/database/src/Config/DatabaseDialect.php index 4c468dfe58..743047c03d 100644 --- a/packages/database/src/Config/DatabaseDialect.php +++ b/packages/database/src/Config/DatabaseDialect.php @@ -21,6 +21,14 @@ public function tableNotFoundCode(): string }; } + public function quoteIdentifier(string $identifier): string + { + return match ($this) { + self::MYSQL, self::SQLITE => sprintf('`%s`', $identifier), + self::POSTGRESQL => sprintf('"%s"', $identifier), + }; + } + public function isTableNotFoundError(QueryWasInvalid $queryWasInvalid): bool { $pdoException = $queryWasInvalid->pdoException; diff --git a/packages/database/src/QueryStatements/AlterTableStatement.php b/packages/database/src/QueryStatements/AlterTableStatement.php index d1d7530c0f..7250ee7542 100644 --- a/packages/database/src/QueryStatements/AlterTableStatement.php +++ b/packages/database/src/QueryStatements/AlterTableStatement.php @@ -4,7 +4,6 @@ namespace Tempest\Database\QueryStatements; -use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\HasTrailingStatements; use Tempest\Database\QueryStatement; @@ -92,7 +91,7 @@ public function compile(DatabaseDialect $dialect): string if ($this->statements !== []) { return sprintf( 'ALTER TABLE %s %s;', - new TableDefinition($this->tableName), + $dialect->quoteIdentifier($this->tableName), arr($this->statements) ->map(fn (QueryStatement $queryStatement) => str($queryStatement->compile($dialect))->trim()->replace(' ', ' ')) ->filter(fn (ImmutableString $line) => $line->isNotEmpty()) diff --git a/packages/database/src/QueryStatements/BooleanStatement.php b/packages/database/src/QueryStatements/BooleanStatement.php index 52c6240886..6bb9d7984c 100644 --- a/packages/database/src/QueryStatements/BooleanStatement.php +++ b/packages/database/src/QueryStatements/BooleanStatement.php @@ -27,8 +27,8 @@ public function compile(DatabaseDialect $dialect): string } return sprintf( - '`%s` BOOLEAN %s %s', - $this->name, + '%s BOOLEAN %s %s', + $dialect->quoteIdentifier($this->name), $default !== null ? "DEFAULT {$default}" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/CharStatement.php b/packages/database/src/QueryStatements/CharStatement.php index 8e1fe53dbf..56a5d75af9 100644 --- a/packages/database/src/QueryStatements/CharStatement.php +++ b/packages/database/src/QueryStatements/CharStatement.php @@ -18,8 +18,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` CHAR %s %s', - $this->name, + '%s CHAR %s %s', + $dialect->quoteIdentifier($this->name), $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/CountStatement.php b/packages/database/src/QueryStatements/CountStatement.php index 353d1f3540..b417561915 100644 --- a/packages/database/src/QueryStatements/CountStatement.php +++ b/packages/database/src/QueryStatements/CountStatement.php @@ -33,7 +33,7 @@ public function compile(DatabaseDialect $dialect): string $query = arr([ sprintf('SELECT %s', $countField->compile($dialect)), - sprintf('FROM `%s`', $this->table->name), + sprintf('FROM %s', $dialect->quoteIdentifier($this->table->name)), ]); if ($this->joins->isNotEmpty()) { diff --git a/packages/database/src/QueryStatements/CreateTableStatement.php b/packages/database/src/QueryStatements/CreateTableStatement.php index 5b9a1fb346..81e66153cd 100644 --- a/packages/database/src/QueryStatements/CreateTableStatement.php +++ b/packages/database/src/QueryStatements/CreateTableStatement.php @@ -5,7 +5,6 @@ namespace Tempest\Database\QueryStatements; use BackedEnum; -use Tempest\Database\Builder\TableDefinition; use Tempest\Database\Config\DatabaseDialect; use Tempest\Database\Enums\DatabaseTextLength; use Tempest\Database\HasTrailingStatements; @@ -400,7 +399,7 @@ public function compile(DatabaseDialect $dialect): string { return sprintf( 'CREATE TABLE %s (%s);', - new TableDefinition($this->tableName), + $dialect->quoteIdentifier($this->tableName), arr($this->statements) // Remove BelongsTo for sqlLite as it does not support those queries ->filter(fn (QueryStatement $queryStatement) => ! ($dialect === DatabaseDialect::SQLITE && $queryStatement instanceof BelongsToStatement)) diff --git a/packages/database/src/QueryStatements/DateStatement.php b/packages/database/src/QueryStatements/DateStatement.php index d3f6bdf78d..37cc379668 100644 --- a/packages/database/src/QueryStatements/DateStatement.php +++ b/packages/database/src/QueryStatements/DateStatement.php @@ -18,8 +18,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` DATE %s %s', - $this->name, + '%s DATE %s %s', + $dialect->quoteIdentifier($this->name), $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/DatetimeStatement.php b/packages/database/src/QueryStatements/DatetimeStatement.php index c4aa33997e..6acc42345c 100644 --- a/packages/database/src/QueryStatements/DatetimeStatement.php +++ b/packages/database/src/QueryStatements/DatetimeStatement.php @@ -29,16 +29,18 @@ public function compile(DatabaseDialect $dialect): string default => null, }; + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::POSTGRESQL => sprintf( - '`%s` TIMESTAMP %s %s', - $this->name, + '%s TIMESTAMP %s %s', + $name, $default !== null ? "DEFAULT {$default}" : '', $this->nullable ? '' : 'NOT NULL', ), default => sprintf( - '`%s` DATETIME %s %s', - $this->name, + '%s DATETIME %s %s', + $name, $default !== null ? "DEFAULT {$default}" : '', $this->nullable ? '' : 'NOT NULL', ), diff --git a/packages/database/src/QueryStatements/DeleteStatement.php b/packages/database/src/QueryStatements/DeleteStatement.php index a8a880a5df..f7e88c2f30 100644 --- a/packages/database/src/QueryStatements/DeleteStatement.php +++ b/packages/database/src/QueryStatements/DeleteStatement.php @@ -25,7 +25,7 @@ public function compile(DatabaseDialect $dialect): string } $query = arr([ - sprintf('DELETE FROM `%s`', $this->table->name), + sprintf('DELETE FROM %s', $dialect->quoteIdentifier($this->table->name)), ]); if ($this->where->isNotEmpty()) { diff --git a/packages/database/src/QueryStatements/DropConstraintStatement.php b/packages/database/src/QueryStatements/DropConstraintStatement.php index ffe7da51a1..85d3e5431f 100644 --- a/packages/database/src/QueryStatements/DropConstraintStatement.php +++ b/packages/database/src/QueryStatements/DropConstraintStatement.php @@ -26,8 +26,8 @@ public function compile(DatabaseDialect $dialect): string return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - 'ALTER TABLE `%s` DROP CONSTRAINT %s', - $foreignTable, + 'ALTER TABLE %s DROP CONSTRAINT %s', + $dialect->quoteIdentifier($foreignTable), $constraintName, ), default => '', diff --git a/packages/database/src/QueryStatements/DropTableStatement.php b/packages/database/src/QueryStatements/DropTableStatement.php index a836b17441..259b374cd2 100644 --- a/packages/database/src/QueryStatements/DropTableStatement.php +++ b/packages/database/src/QueryStatements/DropTableStatement.php @@ -36,8 +36,8 @@ public static function forModel(string $modelClass): self public function compile(DatabaseDialect $dialect): string { return match ($dialect) { - DatabaseDialect::POSTGRESQL => sprintf('DROP TABLE IF EXISTS `%s` CASCADE', $this->tableName), - default => sprintf('DROP TABLE IF EXISTS `%s`', $this->tableName), + DatabaseDialect::POSTGRESQL => sprintf('DROP TABLE IF EXISTS %s CASCADE', $dialect->quoteIdentifier($this->tableName)), + default => sprintf('DROP TABLE IF EXISTS %s', $dialect->quoteIdentifier($this->tableName)), }; } } diff --git a/packages/database/src/QueryStatements/EnumStatement.php b/packages/database/src/QueryStatements/EnumStatement.php index 26ba7f5eb9..afcbd27c1c 100644 --- a/packages/database/src/QueryStatements/EnumStatement.php +++ b/packages/database/src/QueryStatements/EnumStatement.php @@ -35,23 +35,25 @@ public function compile(DatabaseDialect $dialect): string $defaultValue = null; } + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` ENUM(%s) %s %s', - $this->name, + '%s ENUM(%s) %s %s', + $name, $cases->implode(', '), $defaultValue !== null ? "DEFAULT '{$defaultValue}'" : '', $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::SQLITE => sprintf( - '`%s` TEXT %s %s', - $this->name, + '%s TEXT %s %s', + $name, $defaultValue !== null ? "DEFAULT '{$defaultValue}'" : '', $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::POSTGRESQL => sprintf( - '"%s" "%s" %s %s', - $this->name, + '%s "%s" %s %s', + $name, str($this->enumClass)->replace('\\\\', '_'), $defaultValue !== null ? "DEFAULT ('{$defaultValue}')" : '', $this->nullable ? '' : 'NOT NULL', diff --git a/packages/database/src/QueryStatements/FieldStatement.php b/packages/database/src/QueryStatements/FieldStatement.php index 26dd22cfd2..bad8067f27 100644 --- a/packages/database/src/QueryStatements/FieldStatement.php +++ b/packages/database/src/QueryStatements/FieldStatement.php @@ -29,27 +29,18 @@ public function compile(DatabaseDialect $dialect): string $aliasPrefix = $this->aliasPrefix ? "{$this->aliasPrefix}." : ''; if ($this->alias === true) { - $alias = sprintf( - '`%s%s`', - $aliasPrefix, - str_replace('`', '', $field), - ); + $alias = $aliasPrefix . str_replace('`', '', $field); } elseif ($this->alias) { - $alias = sprintf( - '`%s%s`', - $aliasPrefix, - $this->alias, - ); + $alias = $aliasPrefix . $this->alias; } } else { - $alias = $parts[1]; + $alias = trim($parts[1], '`" '); } $field = arr(explode('.', $field)) - ->map(fn (string $part) => trim($part, '` ')) + ->map(fn (string $part) => trim($part, '`" ')) ->map( function (string $part) use ($dialect) { - // Function calls are never wrapped in backticks. if (str_contains($part, '(')) { return $part; } @@ -58,7 +49,7 @@ function (string $part) use ($dialect) { return $part; } - return sprintf('`%s`', $part); + return $dialect->quoteIdentifier($part); }, ) ->implode('.'); @@ -67,10 +58,7 @@ function (string $part) use ($dialect) { return $field; } - return match ($dialect) { - DatabaseDialect::POSTGRESQL => sprintf('%s AS "%s"', $field, trim($alias, '`')), - default => sprintf('%s AS `%s`', $field, trim($alias, '`')), - }; + return sprintf('%s AS %s', $field, $dialect->quoteIdentifier($alias)); } public function withAliasPrefix(?string $prefix = null): self diff --git a/packages/database/src/QueryStatements/FloatStatement.php b/packages/database/src/QueryStatements/FloatStatement.php index 02ba890dd0..d09f6b537a 100644 --- a/packages/database/src/QueryStatements/FloatStatement.php +++ b/packages/database/src/QueryStatements/FloatStatement.php @@ -18,8 +18,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` FLOAT %s %s', - $this->name, + '%s FLOAT %s %s', + $dialect->quoteIdentifier($this->name), $this->default !== null ? "DEFAULT {$this->default}" : '', $this->nullable ? '' : 'NOT NULL', ); diff --git a/packages/database/src/QueryStatements/IdentityStatement.php b/packages/database/src/QueryStatements/IdentityStatement.php index 5f2d7e38c5..c1cdb44fc0 100644 --- a/packages/database/src/QueryStatements/IdentityStatement.php +++ b/packages/database/src/QueryStatements/IdentityStatement.php @@ -15,6 +15,6 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - return sprintf('`%s`', $this->name); + return $dialect->quoteIdentifier($this->name); } } diff --git a/packages/database/src/QueryStatements/IndexStatement.php b/packages/database/src/QueryStatements/IndexStatement.php index 2d2685e723..c2cdf97f6e 100644 --- a/packages/database/src/QueryStatements/IndexStatement.php +++ b/packages/database/src/QueryStatements/IndexStatement.php @@ -19,12 +19,15 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - $columns = arr($this->columns)->implode('`, `')->wrap('`', '`'); + $columns = arr($this->columns) + ->map($dialect->quoteIdentifier(...)) + ->implode(', '); - $indexName = str($this->tableName . ' ' . $columns->replace(',', '')->snake())->snake()->toString(); + $rawColumns = arr($this->columns)->implode('_'); + $indexName = str($this->tableName . '_' . $rawColumns)->snake()->toString(); - $on = sprintf('`%s` (%s)', $this->tableName, $columns); + $on = sprintf('%s (%s)', $dialect->quoteIdentifier($this->tableName), $columns); - return sprintf('CREATE INDEX `%s` ON %s', $indexName, $on); + return sprintf('CREATE INDEX %s ON %s', $dialect->quoteIdentifier($indexName), $on); } } diff --git a/packages/database/src/QueryStatements/InsertStatement.php b/packages/database/src/QueryStatements/InsertStatement.php index 8b95d0c6f1..899d1be628 100644 --- a/packages/database/src/QueryStatements/InsertStatement.php +++ b/packages/database/src/QueryStatements/InsertStatement.php @@ -55,7 +55,7 @@ public function compile(DatabaseDialect $dialect): string $sql = sprintf( 'INSERT INTO %s (%s) VALUES %s', $this->table, - $columns->map(fn (string $column) => "`{$column}`")->implode(', '), + $columns->map($dialect->quoteIdentifier(...))->implode(', '), $entryPlaceholders, ); } diff --git a/packages/database/src/QueryStatements/IntegerStatement.php b/packages/database/src/QueryStatements/IntegerStatement.php index 873cf92287..9a274ee290 100644 --- a/packages/database/src/QueryStatements/IntegerStatement.php +++ b/packages/database/src/QueryStatements/IntegerStatement.php @@ -19,17 +19,19 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::SQLITE => sprintf( - '`%s` INTEGER %s %s %s', - $this->name, + '%s INTEGER %s %s %s', + $name, $this->unsigned ? 'UNSIGNED' : '', $this->default !== null ? "DEFAULT {$this->default}" : '', $this->nullable ? '' : 'NOT NULL', ), default => sprintf( - '`%s` %s %s %s %s', - $this->name, + '%s %s %s %s %s', + $name, is_int($this->size) ? DatabaseIntegerSize::fromBytes($this->size)->toString() : $this->size->toString(), $this->unsigned ? 'UNSIGNED' : '', $this->default !== null ? "DEFAULT {$this->default}" : '', diff --git a/packages/database/src/QueryStatements/JsonStatement.php b/packages/database/src/QueryStatements/JsonStatement.php index f1238f22f9..2abfc6a6c9 100644 --- a/packages/database/src/QueryStatements/JsonStatement.php +++ b/packages/database/src/QueryStatements/JsonStatement.php @@ -22,21 +22,23 @@ public function compile(DatabaseDialect $dialect): string throw new DefaultValueWasInvalid($this->name, $this->default); } + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` JSON %s', - $this->name, + '%s JSON %s', + $name, $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::SQLITE => sprintf( - '`%s` TEXT %s %s', - $this->name, + '%s TEXT %s %s', + $name, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ), DatabaseDialect::POSTGRESQL => sprintf( - '`%s` JSONB %s %s', - $this->name, + '%s JSONB %s %s', + $name, $this->default !== null ? "DEFAULT ('{$this->default}')" : '', $this->nullable ? '' : 'NOT NULL', ), diff --git a/packages/database/src/QueryStatements/PrimaryKeyStatement.php b/packages/database/src/QueryStatements/PrimaryKeyStatement.php index 07153a071d..528cdc6fac 100644 --- a/packages/database/src/QueryStatements/PrimaryKeyStatement.php +++ b/packages/database/src/QueryStatements/PrimaryKeyStatement.php @@ -15,10 +15,12 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { - DatabaseDialect::MYSQL => sprintf('`%s` INTEGER PRIMARY KEY AUTO_INCREMENT', $this->name), - DatabaseDialect::POSTGRESQL => sprintf('`%s` SERIAL PRIMARY KEY', $this->name), - DatabaseDialect::SQLITE => sprintf('`%s` INTEGER PRIMARY KEY AUTOINCREMENT', $this->name), + DatabaseDialect::MYSQL => "{$name} INTEGER PRIMARY KEY AUTO_INCREMENT", + DatabaseDialect::POSTGRESQL => "{$name} SERIAL PRIMARY KEY", + DatabaseDialect::SQLITE => "{$name} INTEGER PRIMARY KEY AUTOINCREMENT", }; } } diff --git a/packages/database/src/QueryStatements/SetStatement.php b/packages/database/src/QueryStatements/SetStatement.php index 934f3101b5..d33a8c6999 100644 --- a/packages/database/src/QueryStatements/SetStatement.php +++ b/packages/database/src/QueryStatements/SetStatement.php @@ -27,8 +27,8 @@ public function compile(DatabaseDialect $dialect): string return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` SET (%s) %s %s', - $this->name, + '%s SET (%s) %s %s', + $dialect->quoteIdentifier($this->name), "'" . implode("', '", $this->values) . "'", $this->default ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', diff --git a/packages/database/src/QueryStatements/TextStatement.php b/packages/database/src/QueryStatements/TextStatement.php index 88c5441584..a6c581686c 100644 --- a/packages/database/src/QueryStatements/TextStatement.php +++ b/packages/database/src/QueryStatements/TextStatement.php @@ -19,16 +19,18 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { DatabaseDialect::MYSQL => sprintf( - '`%s` %s %s', - $this->name, + '%s %s %s', + $name, $this->getSQLTypeDeclaration($this->length), $this->nullable ? '' : 'NOT NULL', ), default => sprintf( - '`%s` TEXT %s %s', - $this->name, + '%s TEXT %s %s', + $name, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', ), diff --git a/packages/database/src/QueryStatements/UniqueStatement.php b/packages/database/src/QueryStatements/UniqueStatement.php index 90526fee11..40f5dad617 100644 --- a/packages/database/src/QueryStatements/UniqueStatement.php +++ b/packages/database/src/QueryStatements/UniqueStatement.php @@ -19,12 +19,15 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { - $columns = arr($this->columns)->implode('`, `')->wrap('`', '`'); + $columns = arr($this->columns) + ->map($dialect->quoteIdentifier(...)) + ->implode(', '); - $indexName = str($this->tableName . ' ' . $columns->replace(',', '')->snake())->snake()->toString(); + $rawColumns = arr($this->columns)->implode('_'); + $indexName = str($this->tableName . '_' . $rawColumns)->snake()->toString(); - $on = sprintf('`%s` (%s)', $this->tableName, $columns); + $on = sprintf('%s (%s)', $dialect->quoteIdentifier($this->tableName), $columns); - return sprintf('CREATE UNIQUE INDEX `%s` ON %s', $indexName, $on); + return sprintf('CREATE UNIQUE INDEX %s ON %s', $dialect->quoteIdentifier($indexName), $on); } } diff --git a/packages/database/src/QueryStatements/UpdateStatement.php b/packages/database/src/QueryStatements/UpdateStatement.php index 0a46b886d4..7ed6814d71 100644 --- a/packages/database/src/QueryStatements/UpdateStatement.php +++ b/packages/database/src/QueryStatements/UpdateStatement.php @@ -27,7 +27,7 @@ public function compile(DatabaseDialect $dialect): string } $query = arr([ - sprintf('UPDATE `%s`', $this->table->name), + sprintf('UPDATE %s', $dialect->quoteIdentifier($this->table->name)), ]); if ($this->values->isEmpty()) { @@ -35,7 +35,7 @@ public function compile(DatabaseDialect $dialect): string } $query[] = 'SET ' . $this->values - ->map(fn (mixed $_, mixed $key) => "`{$key}` = ?") + ->map(fn (mixed $_, mixed $key) => $dialect->quoteIdentifier($key) . ' = ?') ->implode(', '); if ($this->where->isNotEmpty()) { diff --git a/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php b/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php index 9ed2e5bab7..425aa9d20b 100644 --- a/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php +++ b/packages/database/src/QueryStatements/UuidPrimaryKeyStatement.php @@ -15,10 +15,12 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { + $name = $dialect->quoteIdentifier($this->name); + return match ($dialect) { - DatabaseDialect::MYSQL => sprintf('`%s` CHAR(36) PRIMARY KEY', $this->name), - DatabaseDialect::POSTGRESQL => sprintf('`%s` UUID PRIMARY KEY', $this->name), - DatabaseDialect::SQLITE => sprintf('`%s` TEXT PRIMARY KEY', $this->name), + DatabaseDialect::MYSQL => "{$name} CHAR(36) PRIMARY KEY", + DatabaseDialect::POSTGRESQL => "{$name} UUID PRIMARY KEY", + DatabaseDialect::SQLITE => "{$name} TEXT PRIMARY KEY", }; } } diff --git a/packages/database/src/QueryStatements/VarcharStatement.php b/packages/database/src/QueryStatements/VarcharStatement.php index 4f89e43c52..5356fee958 100644 --- a/packages/database/src/QueryStatements/VarcharStatement.php +++ b/packages/database/src/QueryStatements/VarcharStatement.php @@ -19,8 +19,8 @@ public function __construct( public function compile(DatabaseDialect $dialect): string { return sprintf( - '`%s` VARCHAR(%s) %s %s', - $this->name, + '%s VARCHAR(%s) %s %s', + $dialect->quoteIdentifier($this->name), $this->size, $this->default !== null ? "DEFAULT '{$this->default}'" : '', $this->nullable ? '' : 'NOT NULL', diff --git a/packages/database/tests/QueryCompileTest.php b/packages/database/tests/QueryCompileTest.php index 9d17377548..3f77d55af4 100644 --- a/packages/database/tests/QueryCompileTest.php +++ b/packages/database/tests/QueryCompileTest.php @@ -1,6 +1,6 @@ singleton( className: Database::class, - definition: $database + definition: $database, ); GenericContainer::setInstance(instance: $container); } @@ -69,7 +69,7 @@ private function createDatabaseWithPostgresDialect(): void $container = new GenericContainer(); $container->singleton( className: Database::class, - definition: $database + definition: $database, ); GenericContainer::setInstance(instance: $container); } @@ -92,7 +92,7 @@ private function createDatabaseWithSqliteDialect(): void $container = new GenericContainer(); $container->singleton( className: Database::class, - definition: $database + definition: $database, ); GenericContainer::setInstance(instance: $container); } @@ -145,7 +145,7 @@ public function postgresql_passes_through_sql_without_backticks(): void $this->assertSame( 'SELECT 1', - $query->compile()->toString() + $query->compile()->toString(), ); } diff --git a/packages/database/tests/QueryStatements/AlterTableStatementTest.php b/packages/database/tests/QueryStatements/AlterTableStatementTest.php index e06f14e6f5..0eea1847ef 100644 --- a/packages/database/tests/QueryStatements/AlterTableStatementTest.php +++ b/packages/database/tests/QueryStatements/AlterTableStatementTest.php @@ -26,8 +26,16 @@ public function test_alter_for_only_indexes(DatabaseDialect $dialect): void ->index('foo') ->unique('bar'); - $this->assertEqualsIgnoringCase('CREATE INDEX `table_foo` ON `table` (`foo`)', $alterStatement->trailingStatements[0]->compile($dialect)); - $this->assertEqualsIgnoringCase('CREATE UNIQUE INDEX `table_bar` ON `table` (`bar`)', $alterStatement->trailingStatements[1]->compile($dialect)); + $q = $dialect->quoteIdentifier(...); + + $this->assertEqualsIgnoringCase( + 'CREATE INDEX ' . $q('table_foo') . ' ON ' . $q('table') . ' (' . $q('foo') . ')', + $alterStatement->trailingStatements[0]->compile($dialect), + ); + $this->assertEqualsIgnoringCase( + 'CREATE UNIQUE INDEX ' . $q('table_bar') . ' ON ' . $q('table') . ' (' . $q('bar') . ')', + $alterStatement->trailingStatements[1]->compile($dialect), + ); } #[TestWith([DatabaseDialect::MYSQL])] @@ -35,7 +43,8 @@ public function test_alter_for_only_indexes(DatabaseDialect $dialect): void #[TestWith([DatabaseDialect::SQLITE])] public function test_alter_add_column(DatabaseDialect $dialect): void { - $expected = "ALTER TABLE `table` ADD `bar` VARCHAR(42) DEFAULT 'xx' ;"; + $q = $dialect->quoteIdentifier(...); + $expected = 'ALTER TABLE ' . $q('table') . ' ADD ' . $q('bar') . " VARCHAR(42) DEFAULT 'xx' ;"; $statement = new AlterTableStatement('table') ->add(new VarcharStatement('bar', 42, true, 'xx')) ->compile($dialect); @@ -61,7 +70,7 @@ public function test_alter_add_belongs_to_mysql(DatabaseDialect $dialect): void #[TestWith([DatabaseDialect::POSTGRESQL])] public function test_alter_add_belongs_to_postgresql(DatabaseDialect $dialect): void { - $expected = 'ALTER TABLE `table` ADD CONSTRAINT `fk_parent_table_foo` FOREIGN KEY(foo) REFERENCES parent(bar) ON DELETE RESTRICT ON UPDATE NO ACTION ;'; + $expected = 'ALTER TABLE "table" ADD CONSTRAINT "fk_parent_table_foo" FOREIGN KEY(foo) REFERENCES parent(bar) ON DELETE RESTRICT ON UPDATE NO ACTION ;'; $statement = new AlterTableStatement('table') ->add(new BelongsToStatement('table.foo', 'parent.bar')) ->compile($dialect); @@ -86,7 +95,8 @@ public function test_alter_add_belongs_to_unsupported(DatabaseDialect $dialect): #[TestWith([DatabaseDialect::SQLITE])] public function test_alter_table_drop_column(DatabaseDialect $dialect): void { - $expected = 'ALTER TABLE `table` DROP COLUMN `foo` ;'; + $q = $dialect->quoteIdentifier(...); + $expected = 'ALTER TABLE ' . $q('table') . ' DROP COLUMN ' . $q('foo') . ' ;'; $statement = new AlterTableStatement('table') ->dropColumn('foo') ->compile($dialect); @@ -97,7 +107,7 @@ public function test_alter_table_drop_column(DatabaseDialect $dialect): void } #[TestWith([DatabaseDialect::MYSQL, 'ALTER TABLE `table` DROP CONSTRAINT `foo` ;'])] - #[TestWith([DatabaseDialect::POSTGRESQL, 'ALTER TABLE `table` DROP CONSTRAINT `foo` ;'])] + #[TestWith([DatabaseDialect::POSTGRESQL, 'ALTER TABLE "table" DROP CONSTRAINT "foo" ;'])] public function test_alter_table_drop_constraint(DatabaseDialect $dialect, string $expected): void { $statement = new AlterTableStatement('table') @@ -121,7 +131,7 @@ public function test_alter_table_drop_constraint_unsupported_dialects(DatabaseDi #[TestWith([DatabaseDialect::MYSQL, "ALTER TABLE `table` ADD `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] #[TestWith([ DatabaseDialect::POSTGRESQL, - "ALTER TABLE `table` ADD `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;", + "ALTER TABLE \"table\" ADD \"foo\" VARCHAR(42) DEFAULT 'bar' NOT NULL ;", ])] #[TestWith([DatabaseDialect::SQLITE, "ALTER TABLE `table` ADD `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] public function test_alter_table_add_column(DatabaseDialect $dialect, string $expected): void @@ -140,7 +150,8 @@ public function test_alter_table_add_column(DatabaseDialect $dialect, string $ex #[TestWith([DatabaseDialect::SQLITE])] public function test_alter_table_rename_column(DatabaseDialect $dialect): void { - $expected = 'ALTER TABLE `table` RENAME COLUMN `foo` TO `bar` ;'; + $q = $dialect->quoteIdentifier(...); + $expected = 'ALTER TABLE ' . $q('table') . ' RENAME COLUMN ' . $q('foo') . ' TO ' . $q('bar') . ' ;'; $statement = new AlterTableStatement('table') ->rename('foo', 'bar') ->compile($dialect); @@ -151,7 +162,7 @@ public function test_alter_table_rename_column(DatabaseDialect $dialect): void } #[TestWith([DatabaseDialect::MYSQL, "ALTER TABLE `table` MODIFY COLUMN `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] - #[TestWith([DatabaseDialect::POSTGRESQL, "ALTER TABLE `table` ALTER COLUMN `foo` VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] + #[TestWith([DatabaseDialect::POSTGRESQL, "ALTER TABLE \"table\" ALTER COLUMN \"foo\" VARCHAR(42) DEFAULT 'bar' NOT NULL ;"])] public function test_alter_table_modify_column(DatabaseDialect $dialect, string $expected): void { $statement = new AlterTableStatement('table') diff --git a/packages/database/tests/QueryStatements/CreateTableStatementTest.php b/packages/database/tests/QueryStatements/CreateTableStatementTest.php index b3989ba11f..56d4e087b7 100644 --- a/packages/database/tests/QueryStatements/CreateTableStatementTest.php +++ b/packages/database/tests/QueryStatements/CreateTableStatementTest.php @@ -46,8 +46,8 @@ public static function provide_create_table_database_dialects(): iterable yield 'postgresql' => [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, << [ DatabaseDialect::POSTGRESQL, <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $expectedPostgres = 'DELETE FROM "foo" WHERE `bar` = "1"'; + + $this->assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } public function test_exception_when_no_condition_is_set(): void diff --git a/packages/database/tests/QueryStatements/FieldStatementTest.php b/packages/database/tests/QueryStatements/FieldStatementTest.php index 6524d25ce4..6610f20269 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/packages/database/tests/QueryStatements/InsertStatementTest.php b/packages/database/tests/QueryStatements/InsertStatementTest.php index 7c87898224..1de702d4f2 100644 --- a/packages/database/tests/QueryStatements/InsertStatementTest.php +++ b/packages/database/tests/QueryStatements/InsertStatementTest.php @@ -26,7 +26,7 @@ public function test_insert_statement(): void $this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $expectedPostgres = 'INSERT INTO `foo` AS `bar` (`foo`, `bar`) VALUES (?, ?), (?, ?) RETURNING *'; + $expectedPostgres = 'INSERT INTO `foo` AS `bar` ("foo", "bar") VALUES (?, ?), (?, ?) RETURNING *'; $this->assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } diff --git a/packages/database/tests/QueryStatements/UpdateStatementTest.php b/packages/database/tests/QueryStatements/UpdateStatementTest.php index 2667725732..afee68d328 100644 --- a/packages/database/tests/QueryStatements/UpdateStatementTest.php +++ b/packages/database/tests/QueryStatements/UpdateStatementTest.php @@ -28,7 +28,9 @@ public function test_update(): void $this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); - $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $expectedPostgres = 'UPDATE "foo" SET "bar" = ?, "baz" = ? WHERE `bar` = ?'; + + $this->assertSame($expectedPostgres, $statement->compile(DatabaseDialect::POSTGRESQL)); } public function test_exception_when_no_values(): void diff --git a/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php b/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php index 70ed1b044c..bcaa919860 100644 --- a/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php +++ b/packages/database/tests/QueryStatements/UuidPrimaryKeyStatementTest.php @@ -29,7 +29,7 @@ public function postgresql_compilation(): void $statement = new UuidPrimaryKeyStatement('uuid'); $compiled = $statement->compile(DatabaseDialect::POSTGRESQL); - $this->assertSame('`uuid` UUID PRIMARY KEY', $compiled); + $this->assertSame('"uuid" UUID PRIMARY KEY', $compiled); } #[Test] diff --git a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php index 1ea96334d4..fcfba7b850 100644 --- a/tests/Integration/Database/Builder/InsertQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/InsertQueryBuilderTest.php @@ -192,7 +192,7 @@ public function test_insert_mapping(): void $expected = match ($dialect) { DatabaseDialect::POSTGRESQL => <<<'SQL' - INSERT INTO authors (name) VALUES (?) RETURNING * + INSERT INTO "authors" ("name") VALUES (?) RETURNING * SQL, default => <<<'SQL' INSERT INTO `authors` (`name`) VALUES (?) diff --git a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php index d34fb0ee1c..94600f57ff 100644 --- a/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/UpdateQueryBuilderTest.php @@ -314,7 +314,7 @@ public function test_update_mapping(): void $expected = match ($dialect) { DatabaseDialect::POSTGRESQL => <<<'SQL' - UPDATE authors SET name = ? WHERE authors.id = ? + UPDATE "authors" SET "name" = ? WHERE "authors"."id" = ? SQL, default => <<<'SQL' UPDATE `authors` SET `name` = ? WHERE `authors`.`id` = ? diff --git a/tests/Integration/FrameworkIntegrationTestCase.php b/tests/Integration/FrameworkIntegrationTestCase.php index 6a1c91959d..66efa7148a 100644 --- a/tests/Integration/FrameworkIntegrationTestCase.php +++ b/tests/Integration/FrameworkIntegrationTestCase.php @@ -62,7 +62,7 @@ protected function assertSameWithoutBackticks(Stringable|string $expected, Strin { $clean = fn (string $string): string => str($string) ->replace('`', '') - ->replaceRegex('/AS \"(?.*?)\"/', fn (array $matches) => "AS {$matches['alias']}") + ->replace('"', '') ->toString(); $this->assertSame(