From 2c910048e1e9f44e95dd45a610ae6477b29838f5 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 13 Oct 2026 12:57:31 +1100 Subject: [PATCH 1/6] Quote Select, join and GROUP BY column names as identifiers Select resolved each column string through quoteIdentifierInFragment(), which splits on every non-word character, so a column such as x-y rendered as "x""-""y" while its alias rendered as "x-y". Column strings in the select list, join column lists and GROUP BY are now quoted as an identifier chain split only on the table-qualifier dot, with a * part left bare. A string holding an inline " AS " alias keeps fragment quoting, as before. --- src/Sql/Select.php | 46 ++++++++ test/unit/Sql/SelectColumnNameTest.php | 152 +++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 test/unit/Sql/SelectColumnNameTest.php diff --git a/src/Sql/Select.php b/src/Sql/Select.php index 183750be9..619347d36 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -5,6 +5,7 @@ namespace PhpDb\Sql; use Closure; +use Override; use PhpDb\Adapter\Driver\DriverInterface; use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\Platform\PlatformInterface; @@ -12,9 +13,11 @@ use function array_key_exists; use function array_key_first; +use function array_map; use function count; use function explode; use function gettype; +use function implode; use function is_array; use function is_int; use function is_numeric; @@ -36,6 +39,8 @@ * @property ?Having $having * @property ?Join $joins * @psalm-import-type Specification from AbstractSql + * @psalm-import-type ColumnReference from AbstractSql + * @psalm-import-type ColumnValue from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet * @psalm-import-type JoinName from Join * @psalm-type TableReference = string|TableIdentifier|array @@ -748,6 +753,27 @@ protected function processWhere( ]; } + /** + * Quote a select, join or group column string as an identifier chain + * + * @param ColumnReference|ColumnValue $column + * @throws Exception\RuntimeException + */ + #[Override] + protected function resolveColumnValue( + Select|array|string|int|float|bool|ExpressionInterface|null $column, + PlatformInterface $platform, + ?DriverInterface $driver = null, + ?ParameterContainer $parameterContainer = null, + ?string $namedParameterPrefix = null, + ): string { + if (is_array($column) && ($column['isIdentifier'] ?? false) && is_string($column['column'])) { + return ($column['fromTable'] ?? '') . $this->quoteColumnIdentifier($column['column'], $platform); + } + + return parent::resolveColumnValue($column, $platform, $driver, $parameterContainer, $namedParameterPrefix); + } + private function getHaving(): Having { return $this->having ??= new Having(); @@ -770,6 +796,26 @@ private function prefixFromTable(?string $fromTable, PlatformInterface $platform : ''; } + /** + * Quote a column string as an identifier chain, splitting only on the table qualifier dot + * + * A `*` part is left bare. A string holding an inline ` AS ` alias keeps fragment quoting. + */ + private function quoteColumnIdentifier(string $column, PlatformInterface $platform): string + { + if (stripos($column, needle: ' as ') !== false) { + return $platform->quoteIdentifierInFragment($column); + } + + return implode( + $platform->getIdentifierSeparator(), + array_map( + static fn(string $part): string => self::SQL_STAR === $part ? $part : $platform->quoteIdentifier($part), + explode('.', $column), + ), + ); + } + /** * @param Select|TableReference|null $table * @return array{0: string|null, 1: string} diff --git a/test/unit/Sql/SelectColumnNameTest.php b/test/unit/Sql/SelectColumnNameTest.php new file mode 100644 index 000000000..2a8b45763 --- /dev/null +++ b/test/unit/Sql/SelectColumnNameTest.php @@ -0,0 +1,152 @@ + + */ + public static function groupColumnProvider(): array + { + return [ + 'plain' => ['bar', '"bar"'], + 'dotted' => ['t.bar', '"t"."bar"'], + 'hyphen' => ['x-y', '"x-y"'], + 'dotted hyphen' => ['t-1.x-y', '"t-1"."x-y"'], + 'space' => ['my col', '"my col"'], + 'quote character' => ['a"b', '"a\'b"'], + 'expression' => [new Expression('YEAR(created)'), 'YEAR(created)'], + ]; + } + + /** + * @return array, string}> + */ + public static function joinColumnProvider(): array + { + return [ + 'plain' => [['bar'], '"zac"."bar" AS "bar"'], + 'plain aliased' => [['b' => 'bar'], '"zac"."bar" AS "b"'], + 'dotted' => [['t.bar'], '"zac"."t"."bar" AS "t.bar"'], + 'star' => [['*'], '"zac".*'], + 'qualified star' => [['t.*'], '"zac"."t".* AS "t.*"'], + 'hyphen' => [['x-y'], '"zac"."x-y" AS "x-y"'], + 'hyphen aliased' => [['a' => 'x-y'], '"zac"."x-y" AS "a"'], + 'space' => [['my col'], '"zac"."my col" AS "my col"'], + 'space aliased' => [['a' => 'my col'], '"zac"."my col" AS "a"'], + 'quote character' => [['a"b'], '"zac"."a\'b" AS "a\'b"'], + 'quote character aliased' => [['a' => 'a"b'], '"zac"."a\'b" AS "a"'], + 'inline alias' => [['bar AS baz'], '"zac"."bar" AS "baz" AS "bar AS baz"'], + 'expression' => [[new Expression('COUNT(*)')], 'COUNT(*) AS Expression1'], + 'expression aliased' => [['c' => new Expression('COUNT(*)')], 'COUNT(*) AS "c"'], + ]; + } + + /** + * @return array, bool, string}> + */ + public static function selectColumnProvider(): array + { + return [ + 'plain' => [['bar'], true, 'SELECT "foo"."bar" AS "bar" FROM "foo"'], + 'plain aliased' => [['b' => 'bar'], true, 'SELECT "foo"."bar" AS "b" FROM "foo"'], + 'dotted' => [['t.bar'], true, 'SELECT "foo"."t"."bar" AS "t.bar" FROM "foo"'], + 'dotted without prefix' => [['t.bar'], false, 'SELECT "t"."bar" AS "t.bar" FROM "foo"'], + 'star' => [['*'], true, 'SELECT "foo".* FROM "foo"'], + 'star without prefix' => [['*'], false, 'SELECT * FROM "foo"'], + 'qualified star' => [['t.*'], true, 'SELECT "foo"."t".* AS "t.*" FROM "foo"'], + 'qualified star without prefix' => [['t.*'], false, 'SELECT "t".* AS "t.*" FROM "foo"'], + 'hyphen' => [['x-y'], true, 'SELECT "foo"."x-y" AS "x-y" FROM "foo"'], + 'hyphen aliased' => [['a' => 'x-y'], true, 'SELECT "foo"."x-y" AS "a" FROM "foo"'], + 'hyphen without prefix' => [['x-y'], false, 'SELECT "x-y" AS "x-y" FROM "foo"'], + 'space' => [['my col'], true, 'SELECT "foo"."my col" AS "my col" FROM "foo"'], + 'space aliased' => [['a' => 'my col'], true, 'SELECT "foo"."my col" AS "a" FROM "foo"'], + 'quote character' => [['a"b'], true, 'SELECT "foo"."a\'b" AS "a\'b" FROM "foo"'], + 'quote character aliased' => [['a' => 'a"b'], true, 'SELECT "foo"."a\'b" AS "a" FROM "foo"'], + 'dotted hyphen' => [['t-1.x-y'], false, 'SELECT "t-1"."x-y" AS "t-1.x-y" FROM "foo"'], + 'dotted space aliased' => [ + ['a' => 't 1.my col'], + false, + 'SELECT "t 1"."my col" AS "a" FROM "foo"', + ], + 'function call' => [['COUNT(id)'], false, 'SELECT "COUNT(id)" AS "COUNT(id)" FROM "foo"'], + 'arithmetic' => [ + ['price * qty'], + false, + 'SELECT "price * qty" AS "price * qty" FROM "foo"', + ], + 'inline alias' => [['bar AS baz'], true, 'SELECT "foo"."bar" AS "baz" FROM "foo"'], + 'inline alias lower case' => [['bar as baz'], true, 'SELECT "foo"."bar" as "baz" FROM "foo"'], + 'inline alias without prefix' => [['t.bar AS baz'], false, 'SELECT "t"."bar" AS "baz" FROM "foo"'], + 'expression' => [ + [new Expression('COUNT(id)')], + true, + 'SELECT COUNT(id) AS Expression1 FROM "foo"', + ], + 'expression aliased' => [ + ['c' => new Expression('COUNT(id)')], + true, + 'SELECT COUNT(id) AS "c" FROM "foo"', + ], + ]; + } + + #[Test] + #[DataProvider('groupColumnProvider')] + public function quotesGroupColumnStringsAsIdentifierChains(string|Expression $column, string $expected): void + { + $select = (new Select('foo'))->group($column); + + self::assertSame( + "SELECT \"foo\".* FROM \"foo\" GROUP BY {$expected}", + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + /** + * @param array $columns + */ + #[Test] + #[DataProvider('joinColumnProvider')] + public function quotesJoinColumnStringsAsIdentifierChains(array $columns, string $expected): void + { + $select = (new Select('foo'))->columns([])->join('zac', 'm = n', $columns); + + self::assertSame( + "SELECT {$expected} FROM \"foo\" INNER JOIN \"zac\" ON \"m\" = \"n\"", + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + /** + * @param array $columns + */ + #[Test] + #[DataProvider('selectColumnProvider')] + public function quotesSelectColumnStringsAsIdentifierChains( + array $columns, + bool $prefixColumnsWithTable, + string $expected, + ): void { + $select = (new Select('foo'))->columns($columns, $prefixColumnsWithTable); + + self::assertSame($expected, $select->getSqlString(new TrustingSql92Platform())); + } +} From 84996870976e6be9b6f7550b1101849c161e58ea Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 13 Oct 2026 17:02:17 +1100 Subject: [PATCH 2/6] Keep fragment quoting for column strings it already renders as valid SQL A column string now switches to identifier-chain quoting only when fragment quoting would render it broken: a piece between the safe delimiters (space, "." and "*") that the platform would split and quote in parts, as with x-y, a"b or COUNT(id), or two names separated by spaces alone, as with "my col". Strings such as "price * qty" or "a*b" render exactly as before. --- src/Sql/Select.php | 40 ++++++++- test/unit/Sql/SelectColumnNameTest.php | 115 +++++++++++++++---------- 2 files changed, 107 insertions(+), 48 deletions(-) diff --git a/src/Sql/Select.php b/src/Sql/Select.php index 619347d36..bd56e0540 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -32,6 +32,9 @@ use function strtoupper; use function trim; +use const PREG_SPLIT_DELIM_CAPTURE; +use const PREG_SPLIT_NO_EMPTY; + /** * @api * @@ -789,6 +792,38 @@ private function getWhere(): Where return $this->where ??= new Where(); } + /** + * Whether fragment quoting renders a column string as valid SQL + * + * It does when each piece between the safe delimiters (space, `.` and `*`) is quoted as one + * whole name and no two pieces are separated by spaces alone. + */ + private function isFragmentQuotable(string $column, PlatformInterface $platform): bool + { + $previousIsName = false; + $pieces = preg_split('/([ .*])/', $column, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + foreach (false === $pieces ? [] : $pieces as $piece) { + if (' ' === $piece) { + continue; + } + + $isName = '.' !== $piece && self::SQL_STAR !== $piece; + if ( + $isName + && ( + $previousIsName + || $platform->quoteIdentifierInFragment($piece) !== $platform->quoteIdentifier($piece) + ) + ) { + return false; + } + + $previousIsName = $isName; + } + + return true; + } + private function prefixFromTable(?string $fromTable, PlatformInterface $platform): string { return $this->prefixColumnsWithTable && null !== $fromTable && '' !== $fromTable @@ -799,11 +834,12 @@ private function prefixFromTable(?string $fromTable, PlatformInterface $platform /** * Quote a column string as an identifier chain, splitting only on the table qualifier dot * - * A `*` part is left bare. A string holding an inline ` AS ` alias keeps fragment quoting. + * A `*` part is left bare. A string holding an inline ` AS ` alias, or one that fragment quoting + * already renders as valid SQL, keeps fragment quoting. */ private function quoteColumnIdentifier(string $column, PlatformInterface $platform): string { - if (stripos($column, needle: ' as ') !== false) { + if (stripos($column, needle: ' as ') !== false || $this->isFragmentQuotable($column, $platform)) { return $platform->quoteIdentifierInFragment($column); } diff --git a/test/unit/Sql/SelectColumnNameTest.php b/test/unit/Sql/SelectColumnNameTest.php index 2a8b45763..2daf4093e 100644 --- a/test/unit/Sql/SelectColumnNameTest.php +++ b/test/unit/Sql/SelectColumnNameTest.php @@ -18,6 +18,7 @@ #[CoversMethod(Select::class, 'processGroup')] #[CoversMethod(Select::class, 'resolveColumnValue')] #[CoversMethod(Select::class, 'quoteColumnIdentifier')] +#[CoversMethod(Select::class, 'isFragmentQuotable')] final class SelectColumnNameTest extends TestCase { /** @@ -26,13 +27,16 @@ final class SelectColumnNameTest extends TestCase public static function groupColumnProvider(): array { return [ - 'plain' => ['bar', '"bar"'], - 'dotted' => ['t.bar', '"t"."bar"'], - 'hyphen' => ['x-y', '"x-y"'], - 'dotted hyphen' => ['t-1.x-y', '"t-1"."x-y"'], - 'space' => ['my col', '"my col"'], - 'quote character' => ['a"b', '"a\'b"'], - 'expression' => [new Expression('YEAR(created)'), 'YEAR(created)'], + 'plain' => ['bar', '"bar"'], + 'dotted' => ['t.bar', '"t"."bar"'], + 'hyphen' => ['x-y', '"x-y"'], + 'dotted hyphen' => ['t-1.x-y', '"t-1"."x-y"'], + 'space' => ['my col', '"my col"'], + 'quote character' => ['a"b', '"a\'b"'], + 'function call' => ['YEAR(created)', '"YEAR(created)"'], + 'arithmetic' => ['a * b', '"a" * "b"'], + 'arithmetic without spaces' => ['a*b', '"a"*"b"'], + 'expression' => [new Expression('YEAR(created)'), 'YEAR(created)'], ]; } @@ -42,20 +46,22 @@ public static function groupColumnProvider(): array public static function joinColumnProvider(): array { return [ - 'plain' => [['bar'], '"zac"."bar" AS "bar"'], - 'plain aliased' => [['b' => 'bar'], '"zac"."bar" AS "b"'], - 'dotted' => [['t.bar'], '"zac"."t"."bar" AS "t.bar"'], - 'star' => [['*'], '"zac".*'], - 'qualified star' => [['t.*'], '"zac"."t".* AS "t.*"'], - 'hyphen' => [['x-y'], '"zac"."x-y" AS "x-y"'], - 'hyphen aliased' => [['a' => 'x-y'], '"zac"."x-y" AS "a"'], - 'space' => [['my col'], '"zac"."my col" AS "my col"'], - 'space aliased' => [['a' => 'my col'], '"zac"."my col" AS "a"'], - 'quote character' => [['a"b'], '"zac"."a\'b" AS "a\'b"'], - 'quote character aliased' => [['a' => 'a"b'], '"zac"."a\'b" AS "a"'], - 'inline alias' => [['bar AS baz'], '"zac"."bar" AS "baz" AS "bar AS baz"'], - 'expression' => [[new Expression('COUNT(*)')], 'COUNT(*) AS Expression1'], - 'expression aliased' => [['c' => new Expression('COUNT(*)')], 'COUNT(*) AS "c"'], + 'plain' => [['bar'], '"zac"."bar" AS "bar"'], + 'plain aliased' => [['b' => 'bar'], '"zac"."bar" AS "b"'], + 'dotted' => [['t.bar'], '"zac"."t"."bar" AS "t.bar"'], + 'star' => [['*'], '"zac".*'], + 'qualified star' => [['t.*'], '"zac"."t".* AS "t.*"'], + 'hyphen' => [['x-y'], '"zac"."x-y" AS "x-y"'], + 'hyphen aliased' => [['a' => 'x-y'], '"zac"."x-y" AS "a"'], + 'space' => [['my col'], '"zac"."my col" AS "my col"'], + 'space aliased' => [['a' => 'my col'], '"zac"."my col" AS "a"'], + 'quote character' => [['a"b'], '"zac"."a\'b" AS "a\'b"'], + 'quote character aliased' => [['a' => 'a"b'], '"zac"."a\'b" AS "a"'], + 'arithmetic' => [['price * qty'], '"zac"."price" * "qty" AS "price * qty"'], + 'arithmetic without spaces' => [['a*b'], '"zac"."a"*"b" AS "a*b"'], + 'inline alias' => [['bar AS baz'], '"zac"."bar" AS "baz" AS "bar AS baz"'], + 'expression' => [[new Expression('COUNT(*)')], 'COUNT(*) AS Expression1'], + 'expression aliased' => [['c' => new Expression('COUNT(*)')], 'COUNT(*) AS "c"'], ]; } @@ -65,42 +71,59 @@ public static function joinColumnProvider(): array public static function selectColumnProvider(): array { return [ - 'plain' => [['bar'], true, 'SELECT "foo"."bar" AS "bar" FROM "foo"'], - 'plain aliased' => [['b' => 'bar'], true, 'SELECT "foo"."bar" AS "b" FROM "foo"'], - 'dotted' => [['t.bar'], true, 'SELECT "foo"."t"."bar" AS "t.bar" FROM "foo"'], - 'dotted without prefix' => [['t.bar'], false, 'SELECT "t"."bar" AS "t.bar" FROM "foo"'], - 'star' => [['*'], true, 'SELECT "foo".* FROM "foo"'], - 'star without prefix' => [['*'], false, 'SELECT * FROM "foo"'], - 'qualified star' => [['t.*'], true, 'SELECT "foo"."t".* AS "t.*" FROM "foo"'], - 'qualified star without prefix' => [['t.*'], false, 'SELECT "t".* AS "t.*" FROM "foo"'], - 'hyphen' => [['x-y'], true, 'SELECT "foo"."x-y" AS "x-y" FROM "foo"'], - 'hyphen aliased' => [['a' => 'x-y'], true, 'SELECT "foo"."x-y" AS "a" FROM "foo"'], - 'hyphen without prefix' => [['x-y'], false, 'SELECT "x-y" AS "x-y" FROM "foo"'], - 'space' => [['my col'], true, 'SELECT "foo"."my col" AS "my col" FROM "foo"'], - 'space aliased' => [['a' => 'my col'], true, 'SELECT "foo"."my col" AS "a" FROM "foo"'], - 'quote character' => [['a"b'], true, 'SELECT "foo"."a\'b" AS "a\'b" FROM "foo"'], - 'quote character aliased' => [['a' => 'a"b'], true, 'SELECT "foo"."a\'b" AS "a" FROM "foo"'], - 'dotted hyphen' => [['t-1.x-y'], false, 'SELECT "t-1"."x-y" AS "t-1.x-y" FROM "foo"'], - 'dotted space aliased' => [ + 'plain' => [['bar'], true, 'SELECT "foo"."bar" AS "bar" FROM "foo"'], + 'plain aliased' => [['b' => 'bar'], true, 'SELECT "foo"."bar" AS "b" FROM "foo"'], + 'dotted' => [['t.bar'], true, 'SELECT "foo"."t"."bar" AS "t.bar" FROM "foo"'], + 'dotted without prefix' => [['t.bar'], false, 'SELECT "t"."bar" AS "t.bar" FROM "foo"'], + 'star' => [['*'], true, 'SELECT "foo".* FROM "foo"'], + 'star without prefix' => [['*'], false, 'SELECT * FROM "foo"'], + 'qualified star' => [['t.*'], true, 'SELECT "foo"."t".* AS "t.*" FROM "foo"'], + 'qualified star without prefix' => [['t.*'], false, 'SELECT "t".* AS "t.*" FROM "foo"'], + 'hyphen' => [['x-y'], true, 'SELECT "foo"."x-y" AS "x-y" FROM "foo"'], + 'hyphen aliased' => [['a' => 'x-y'], true, 'SELECT "foo"."x-y" AS "a" FROM "foo"'], + 'hyphen without prefix' => [['x-y'], false, 'SELECT "x-y" AS "x-y" FROM "foo"'], + 'space' => [['my col'], true, 'SELECT "foo"."my col" AS "my col" FROM "foo"'], + 'space aliased' => [['a' => 'my col'], true, 'SELECT "foo"."my col" AS "a" FROM "foo"'], + 'quote character' => [['a"b'], true, 'SELECT "foo"."a\'b" AS "a\'b" FROM "foo"'], + 'quote character aliased' => [['a' => 'a"b'], true, 'SELECT "foo"."a\'b" AS "a" FROM "foo"'], + 'dotted hyphen' => [['t-1.x-y'], false, 'SELECT "t-1"."x-y" AS "t-1.x-y" FROM "foo"'], + 'dotted space aliased' => [ ['a' => 't 1.my col'], false, 'SELECT "t 1"."my col" AS "a" FROM "foo"', ], - 'function call' => [['COUNT(id)'], false, 'SELECT "COUNT(id)" AS "COUNT(id)" FROM "foo"'], - 'arithmetic' => [ + 'function call' => [['COUNT(id)'], false, 'SELECT "COUNT(id)" AS "COUNT(id)" FROM "foo"'], + 'arithmetic' => [ ['price * qty'], + true, + 'SELECT "foo"."price" * "qty" AS "price * qty" FROM "foo"', + ], + 'arithmetic without prefix' => [ + ['price * qty'], + false, + 'SELECT "price" * "qty" AS "price * qty" FROM "foo"', + ], + 'arithmetic without spaces' => [['a*b'], false, 'SELECT "a"*"b" AS "a*b" FROM "foo"'], + 'arithmetic aliased' => [ + ['total' => 'price * qty'], + false, + 'SELECT "price" * "qty" AS "total" FROM "foo"', + ], + 'dotted arithmetic' => [ + ['t.price * t.qty'], false, - 'SELECT "price * qty" AS "price * qty" FROM "foo"', + 'SELECT "t"."price" * "t"."qty" AS "t.price * t.qty" FROM "foo"', ], - 'inline alias' => [['bar AS baz'], true, 'SELECT "foo"."bar" AS "baz" FROM "foo"'], - 'inline alias lower case' => [['bar as baz'], true, 'SELECT "foo"."bar" as "baz" FROM "foo"'], - 'inline alias without prefix' => [['t.bar AS baz'], false, 'SELECT "t"."bar" AS "baz" FROM "foo"'], - 'expression' => [ + 'arithmetic with hyphenated name' => [['x-y * 2'], false, 'SELECT "x-y * 2" AS "x-y * 2" FROM "foo"'], + 'inline alias' => [['bar AS baz'], true, 'SELECT "foo"."bar" AS "baz" FROM "foo"'], + 'inline alias lower case' => [['bar as baz'], true, 'SELECT "foo"."bar" as "baz" FROM "foo"'], + 'inline alias without prefix' => [['t.bar AS baz'], false, 'SELECT "t"."bar" AS "baz" FROM "foo"'], + 'expression' => [ [new Expression('COUNT(id)')], true, 'SELECT COUNT(id) AS Expression1 FROM "foo"', ], - 'expression aliased' => [ + 'expression aliased' => [ ['c' => new Expression('COUNT(id)')], true, 'SELECT COUNT(id) AS "c" FROM "foo"', From 730e0a34fe6eb05fd7973273bf0a68ac96cf9532 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 13 Oct 2026 17:06:01 +1100 Subject: [PATCH 3/6] Quote predicate names as identifiers where fragment quoting renders broken SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Predicate identifiers render through quoteIdentifierInFragment(), which quotes every piece split out on a non-word character other than a space, dot, * or AS. A name such as x-y, a"b, nâme or my col therefore renders as adjacent quoted pieces or a bare sequence of identifiers. Such names are now quoted whole as an identifier chain, split only on the qualifier dot. Every name whose fragment quoting renders valid SQL keeps its current rendering, judged against the platform's own fragment pattern. --- src/Sql/AbstractSql.php | 71 +++- test/unit/Sql/PredicateIdentifierNameTest.php | 302 ++++++++++++++++++ .../TestAsset/TrustingHyphenWordPlatform.php | 27 ++ 3 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 test/unit/Sql/PredicateIdentifierNameTest.php create mode 100644 test/unit/TestAsset/TrustingHyphenWordPlatform.php diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index 5ea9aaadc..05865d4df 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -18,16 +18,23 @@ use PhpDb\Sql\Platform\PlatformDecoratorInterface; use function array_key_first; +use function array_map; use function count; +use function explode; use function get_object_vars; use function implode; use function is_array; use function is_string; +use function preg_split; use function rtrim; use function str_replace; +use function strtolower; use function strtoupper; use function vsprintf; +use const PREG_SPLIT_DELIM_CAPTURE; +use const PREG_SPLIT_NO_EMPTY; + /** * @api * @@ -247,7 +254,7 @@ protected function processExpression( $parameterContainer, ) : $platform->quoteValue((string) $argument->getValue()), - $argument instanceof Identifier => $platform->quoteIdentifierInFragment($argument->getValue()), + $argument instanceof Identifier => $this->quoteIdentifierArgument($argument->getValue(), $platform), $argument instanceof Literal => $argument->getValue(), $argument instanceof Identifiers => $this->processIdentifiersArgument($argument, $platform), $argument instanceof SelectArgument => $this->processExpressionOrSelect( @@ -330,7 +337,7 @@ protected function processIdentifiersArgument( $processedIdentifiers = []; foreach ($identifiers as $identifier) { - $processedIdentifiers[] = $platform->quoteIdentifierInFragment($identifier); + $processedIdentifiers[] = $this->quoteIdentifierArgument($identifier, $platform); } return implode(', ', $processedIdentifiers); @@ -555,6 +562,66 @@ private function matchSpecification(array $specifications, int $parameterCount): throw Exception\RuntimeException::forUnsupportedParameterCount(); } + /** + * Quote an identifier argument, keeping fragment quoting wherever it renders valid SQL + * + * Fragment quoting leaves spaces, dots, `*` and `AS` bare and quotes every other piece the + * platform splits out, so a name holding any other delimiter renders as adjacent quoted + * pieces, and names separated only by spaces render as a bare sequence of identifiers. Both + * are invalid, so such a name is quoted whole as an identifier chain instead, split only on + * the qualifier dot with a `*` part left bare. + */ + private function quoteIdentifierArgument(string $identifier, PlatformInterface $platform): string + { + $isValidFragment = true; + $previousIsName = false; + $afterSpace = false; + + foreach ((array) preg_split( + '/([ .*])/', + $identifier, + -1, + PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, + ) as $part) { + if (' ' === $part) { + $afterSpace = true; + continue; + } + + $isName = '.' !== $part && '*' !== $part && 'as' !== strtolower((string) $part); + if ( + $isName + && ( + ( + $previousIsName + && $afterSpace + ) + || $platform->quoteIdentifierInFragment((string) $part) !== $platform->quoteIdentifier( + (string) $part, + ) + ) + ) { + $isValidFragment = false; + break; + } + + $previousIsName = $isName; + $afterSpace = false; + } + + if ($isValidFragment) { + return $platform->quoteIdentifierInFragment($identifier); + } + + return implode( + $platform->getIdentifierSeparator(), + array_map( + static fn(string $part): string => '*' === $part ? $part : $platform->quoteIdentifier($part), + explode('.', $identifier), + ), + ); + } + private function quoteJoinTableIdentifier(TableIdentifier $identifier, PlatformInterface $platform): string { [$table, $schema] = $identifier->getTableAndSchema(); diff --git a/test/unit/Sql/PredicateIdentifierNameTest.php b/test/unit/Sql/PredicateIdentifierNameTest.php new file mode 100644 index 000000000..07a908701 --- /dev/null +++ b/test/unit/Sql/PredicateIdentifierNameTest.php @@ -0,0 +1,302 @@ + + */ + public static function brokenNameProvider(): array + { + return [ + 'hyphen' => ['x-y', '"x-y"'], + 'space' => ['my col', '"my col"'], + 'quote character' => ['a"b', '"a\'b"'], + 'non-ascii' => ['nâme', '"nâme"'], + 'tab' => ["a\tb", "\"a\tb\""], + 'dotted hyphen' => ['t-1.x-y', '"t-1"."x-y"'], + 'dotted space' => ['t.my col', '"t"."my col"'], + 'hyphen qualified star' => ['t-1.*', '"t-1".*'], + ]; + } + + /** + * @return iterable + */ + public static function brokenPredicateProvider(): iterable + { + foreach (self::predicateMethodProvider() as $method => [$apply, $template]) { + foreach (self::brokenNameProvider() as $case => [$name, $quoted]) { + yield "{$method} {$case}" => [$apply, $template, $name, $quoted]; + } + } + } + + /** + * @return array + */ + public static function hyphenWordPlatformProvider(): array + { + return [ + 'hyphen' => ['x-y', '`x-y`'], + 'hyphen arithmetic' => ['a-b * c', '`a-b` * `c`'], + 'dotted hyphen' => ['t-1.x-y', '`t-1`.`x-y`'], + 'space' => ['my col', '`my col`'], + 'quote character' => ['a`b', '`a``b`'], + 'plus' => ['a+b', '`a+b`'], + ]; + } + + /** + * @return array + */ + public static function predicateMethodProvider(): array + { + return [ + 'equalTo' => [static fn(Predicate $p, string $n): Predicate => $p->equalTo($n, 1), "%s = '1'"], + 'notEqualTo' => [static fn(Predicate $p, string $n): Predicate => $p->notEqualTo($n, 1), "%s != '1'"], + 'lessThan' => [static fn(Predicate $p, string $n): Predicate => $p->lessThan($n, 1), "%s < '1'"], + 'greaterThan' => [static fn(Predicate $p, string $n): Predicate => $p->greaterThan($n, 1), "%s > '1'"], + 'like' => [static fn(Predicate $p, string $n): Predicate => $p->like($n, 'a%'), "%s LIKE 'a%%'"], + 'in' => [static fn(Predicate $p, string $n): Predicate => $p->in($n, [1, 2]), "%s IN ('1', '2')"], + 'between' => [ + static fn(Predicate $p, string $n): Predicate => $p->between($n, 1, 2), + "%s BETWEEN '1' AND '2'", + ], + 'isNull' => [static fn(Predicate $p, string $n): Predicate => $p->isNull($n), '%s IS NULL'], + ]; + } + + /** + * Names whose fragment quoting renders valid SQL, and that rendering + * + * @return array + */ + public static function unchangedNameProvider(): array + { + return [ + 'plain' => ['col', '"col"'], + 'dotted' => ['t.col', '"t"."col"'], + 'qualified star' => ['t.*', '"t".*'], + 'arithmetic' => ['price*qty', '"price"*"qty"'], + 'spaced arithmetic' => ['a * b', '"a" * "b"'], + 'dotted arithmetic' => ['t.a * t.b', '"t"."a" * "t"."b"'], + 'inline alias' => ['x as y', '"x" as "y"'], + 'inline alias upper' => ['x AS y', '"x" AS "y"'], + 'comma' => ['a,b', '"a,b"'], + 'empty' => ['', ''], + ]; + } + + /** + * @return iterable + */ + public static function unchangedPredicateProvider(): iterable + { + foreach (self::predicateMethodProvider() as $method => [$apply, $template]) { + foreach (self::unchangedNameProvider() as $case => [$name, $quoted]) { + yield "{$method} {$case}" => [$apply, $template, $name, $quoted]; + } + } + } + + #[Test] + #[DataProvider('hyphenWordPlatformProvider')] + public function followsThePlatformFragmentPatternForWordCharacters(string $name, string $quoted): void + { + $select = new Select('foo'); + $select->where->equalTo($name, 1); + + self::assertSame( + "SELECT `foo`.* FROM `foo` WHERE {$quoted} = '1'", + $select->getSqlString(new TrustingHyphenWordPlatform()), + ); + } + + /** + * @param Closure(Predicate, string): Predicate $apply + */ + #[Test] + #[DataProvider('unchangedPredicateProvider')] + public function keepsValidWhereNameRenderingUnchanged( + Closure $apply, + string $template, + string $name, + string $quoted, + ): void { + $select = new Select('foo'); + $apply($select->where, $name); + + self::assertSame( + sprintf("SELECT \"foo\".* FROM \"foo\" WHERE {$template}", $quoted), + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + public function leavesExpressionWhereUnchanged(): void + { + $select = new Select('foo'); + $select->where(new Expression('x-y = ?', [1])); + + self::assertSame( + "SELECT \"foo\".* FROM \"foo\" WHERE x-y = '1'", + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + public function leavesLiteralWhereUnchanged(): void + { + $select = new Select('foo'); + $select->where(new Literal('x-y = 1')); + + self::assertSame( + 'SELECT "foo".* FROM "foo" WHERE x-y = 1', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + public function leavesStringWhereUnchanged(): void + { + $select = new Select('foo'); + $select->where('x-y = 1'); + + self::assertSame( + 'SELECT "foo".* FROM "foo" WHERE x-y = 1', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + #[DataProvider('brokenNameProvider')] + #[DataProvider('unchangedNameProvider')] + public function quotesDeleteWhereName(string $name, string $quoted): void + { + $delete = new Delete('foo'); + $delete->where->equalTo($name, 1); + + self::assertSame( + "DELETE FROM \"foo\" WHERE {$quoted} = '1'", + $delete->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + public function quotesEachNameOfAnIdentifiersArgument(): void + { + $select = new Select('foo'); + $select->where(new Expression('(?) IN (SELECT 1)', [Argument::identifiers(['x-y', 't.col', 'a * b'])])); + + self::assertSame( + 'SELECT "foo".* FROM "foo" WHERE ("x-y", "t"."col", "a" * "b") IN (SELECT 1)', + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + #[DataProvider('brokenNameProvider')] + #[DataProvider('unchangedNameProvider')] + public function quotesHavingName(string $name, string $quoted): void + { + $select = new Select('foo'); + $select->having->greaterThan($name, 1); + + self::assertSame( + "SELECT \"foo\".* FROM \"foo\" HAVING {$quoted} > '1'", + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + /** + * @param Closure(Predicate, string): Predicate $apply + */ + #[Test] + #[DataProvider('brokenPredicateProvider')] + public function quotesPreviouslyBrokenWhereNameAsOneIdentifier( + Closure $apply, + string $template, + string $name, + string $quoted, + ): void { + $select = new Select('foo'); + $apply($select->where, $name); + + self::assertSame( + sprintf("SELECT \"foo\".* FROM \"foo\" WHERE {$template}", $quoted), + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + #[DataProvider('brokenNameProvider')] + #[DataProvider('unchangedNameProvider')] + public function quotesUpdateWhereName(string $name, string $quoted): void + { + $update = new Update('foo'); + $update->set(['bar' => 2]); + $update->where->equalTo($name, 1); + + self::assertSame( + "UPDATE \"foo\" SET \"bar\" = '2' WHERE {$quoted} = '1'", + $update->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + #[DataProvider('brokenNameProvider')] + #[DataProvider('unchangedNameProvider')] + public function quotesWhereArrayKeyAsPredicateName(string $name, string $quoted): void + { + $select = new Select('foo'); + $select->where([$name => 1]); + + self::assertSame( + "SELECT \"foo\".* FROM \"foo\" WHERE {$quoted} = '1'", + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + #[DataProvider('brokenNameProvider')] + #[DataProvider('unchangedNameProvider')] + public function quotesWhereArrayKeyWithNullAsIsNullName(string $name, string $quoted): void + { + $select = new Select('foo'); + $select->where([$name => null]); + + self::assertSame( + "SELECT \"foo\".* FROM \"foo\" WHERE {$quoted} IS NULL", + $select->getSqlString(new TrustingSql92Platform()), + ); + } +} diff --git a/test/unit/TestAsset/TrustingHyphenWordPlatform.php b/test/unit/TestAsset/TrustingHyphenWordPlatform.php new file mode 100644 index 000000000..1e65219ed --- /dev/null +++ b/test/unit/TestAsset/TrustingHyphenWordPlatform.php @@ -0,0 +1,27 @@ +quoteTrustedValue($value); + } +} From 4c19e83502efb745a483f0bc42be3bea49578dea Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Wed, 4 Nov 2026 16:22:49 +1100 Subject: [PATCH 4/6] Quote identifier strings through one shared IdentifierQuoter Replace Select's own column-quoting helpers with IdentifierQuoter, which the ORDER BY and predicate fixes can share: - A plain name of word characters and qualifier dots takes a fast path straight to fragment quoting, as before. - Otherwise IdentifierShape reads the name in pieces. Fragment quoting is kept where it renders as written; a name it would split (x-y, my col, t.as) is quoted whole per qualifier part, with each part trimmed. - Expression syntax that no identifier quoting renders as written, such as COUNT(id), a - b or x-y * 2, throws InvalidArgumentException, as does an empty name or qualifier part. - An inline column alias such as 'x-y as z' renders "x-y" AS "z". TrustingHyphenWordPlatform becomes the shared hyphen-as-word test platform. --- .../Exception/InvalidArgumentException.php | 16 ++ src/Sql/IdentifierPiece.php | 63 ++++++ src/Sql/IdentifierQuoter.php | 94 +++++++++ src/Sql/IdentifierShape.php | 80 +++++++ src/Sql/Select.php | 62 +----- test/unit/Sql/IdentifierQuoterTest.php | 197 ++++++++++++++++++ test/unit/Sql/SelectColumnNameTest.php | 97 ++++++--- .../TestAsset/TrustingHyphenWordPlatform.php | 27 +++ 8 files changed, 544 insertions(+), 92 deletions(-) create mode 100644 src/Sql/IdentifierPiece.php create mode 100644 src/Sql/IdentifierQuoter.php create mode 100644 src/Sql/IdentifierShape.php create mode 100644 test/unit/Sql/IdentifierQuoterTest.php create mode 100644 test/unit/TestAsset/TrustingHyphenWordPlatform.php diff --git a/src/Sql/Exception/InvalidArgumentException.php b/src/Sql/Exception/InvalidArgumentException.php index 8066080c9..beec573d0 100644 --- a/src/Sql/Exception/InvalidArgumentException.php +++ b/src/Sql/Exception/InvalidArgumentException.php @@ -24,6 +24,8 @@ class InvalidArgumentException extends Exception\InvalidArgumentException final public const string EMPTY_EXPRESSION = 'Supplied expression must not be an empty string.'; + final public const string EMPTY_IDENTIFIER_PART = 'Identifier "%s" is empty or has an empty qualifier part'; + final public const string EMPTY_PREFIX = '$prefix must be a valid table prefix or null, empty string given'; final public const string EMPTY_SCHEMA = '$schema must be a valid schema name or null, empty string given'; @@ -80,6 +82,10 @@ class InvalidArgumentException extends Exception\InvalidArgumentException final public const string UNKNOWN_COLUMN_KEY = 'The key %s was not found in this objects column list'; + final public const string UNQUOTABLE_IDENTIFIER = + 'Identifier "%s" holds expression syntax that cannot be quoted as a name;' + . ' pass it as an Expression instead'; + final public const string VALUES_WITH_MERGE_FLAG = 'An array of values cannot be provided with the merge flag' . ' when a PhpDb\Sql\Select instance already exists as the value source'; @@ -99,6 +105,11 @@ public static function forEmptyExpression(): self return new self(self::EMPTY_EXPRESSION); } + public static function forEmptyIdentifierPart(string $identifier): self + { + return new self(sprintf(self::EMPTY_IDENTIFIER_PART, $identifier)); + } + public static function forEmptyPrefix(): self { return new self(self::EMPTY_PREFIX); @@ -207,6 +218,11 @@ public static function forUnknownColumnKey(string $name): self return new self(sprintf(self::UNKNOWN_COLUMN_KEY, $name)); } + public static function forUnquotableIdentifier(string $identifier): self + { + return new self(sprintf(self::UNQUOTABLE_IDENTIFIER, $identifier)); + } + public static function forValuesWithMergeFlag(): self { return new self(self::VALUES_WITH_MERGE_FLAG); diff --git a/src/Sql/IdentifierPiece.php b/src/Sql/IdentifierPiece.php new file mode 100644 index 000000000..b446d1a08 --- /dev/null +++ b/src/Sql/IdentifierPiece.php @@ -0,0 +1,63 @@ ++\/%|!~^&;]/'; + + public static function of(string $piece, ?self $previous, bool $spaced, PlatformInterface $platform): self + { + if ('.' === $piece) { + return self::Dot; + } + + if ('*' === $piece) { + return self::Dot === $previous ? self::QualifiedStar : self::Keyword; + } + + $isName = $platform->quoteIdentifierInFragment($piece) === $platform->quoteIdentifier($piece); + + return match (true) { + $spaced && 'as' === strtolower($piece) => self::Keyword, + 1 === preg_match(self::OPERATOR, $piece) + || (! $isName && 1 === preg_match(self::EXPRESSION_CHARACTERS, $piece)) + => self::Unquotable, + $isName => self::Name, + default => self::Whole, + }; + } +} diff --git a/src/Sql/IdentifierQuoter.php b/src/Sql/IdentifierQuoter.php new file mode 100644 index 000000000..98cfce3bc --- /dev/null +++ b/src/Sql/IdentifierQuoter.php @@ -0,0 +1,94 @@ +quoteIdentifierInFragment($name); + } + + return match (IdentifierShape::of($name, $platform)) { + IdentifierShape::Fragment => $platform->quoteIdentifierInFragment($name), + IdentifierShape::Whole => self::quoteChain($name, $platform), + IdentifierShape::Unquotable => throw Exception\InvalidArgumentException::forUnquotableIdentifier($name), + IdentifierShape::EmptyPart => throw Exception\InvalidArgumentException::forEmptyIdentifierPart($name), + }; + } + + /** + * Quote a column, quoting each side of an inline `AS` alias separately where the whole cannot be + * + * @throws Exception\InvalidArgumentException + */ + public static function quoteColumn(string $column, PlatformInterface $platform): string + { + if ( + 1 !== preg_match(self::PLAIN_NAME, $column) + && IdentifierShape::Unquotable === IdentifierShape::of($column, $platform) + ) { + /** @var list $parts */ + $parts = preg_split(self::ALIAS_KEYWORD, $column); + if (2 === count($parts)) { + /** @var array{string, string} $parts */ + [$name, $alias] = $parts; + + return self::quote($name, $platform) . ' AS ' . self::quote($alias, $platform); + } + } + + return self::quote($column, $platform); + } + + /** + * Quote each qualifier part whole, leaving a `*` part bare + */ + private static function quoteChain(string $name, PlatformInterface $platform): string + { + return implode( + $platform->getIdentifierSeparator(), + array_map( + static fn(string $part): string => '*' === $part ? $part : $platform->quoteIdentifier($part), + array_map(trim(...), explode('.', $name)), + ), + ); + } +} diff --git a/src/Sql/IdentifierShape.php b/src/Sql/IdentifierShape.php new file mode 100644 index 000000000..bb76cc076 --- /dev/null +++ b/src/Sql/IdentifierShape.php @@ -0,0 +1,80 @@ + $pieces */ + $pieces = preg_split(self::PIECES, $name, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + foreach ($pieces as $piece) { + if (' ' === $piece) { + $spaced = true; + continue; + } + + $kind = IdentifierPiece::of($piece, $previous, $spaced, $platform); + if (IdentifierPiece::Unquotable === $kind) { + return self::Unquotable; + } + + if (IdentifierPiece::Dot === $kind && (null === $previous || IdentifierPiece::Dot === $previous)) { + return self::EmptyPart; + } + + $whole = + $whole + || IdentifierPiece::Whole === $kind + || (IdentifierPiece::Name === $kind && IdentifierPiece::Name === $previous && $spaced); + $keyword = $keyword || IdentifierPiece::Keyword === $kind; + $previous = $kind; + $spaced = false; + } + + return match (true) { + null === $previous, IdentifierPiece::Dot === $previous => self::EmptyPart, + $whole => $keyword ? self::Unquotable : self::Whole, + default => self::Fragment, + }; + } +} diff --git a/src/Sql/Select.php b/src/Sql/Select.php index bd56e0540..7efb21194 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -13,11 +13,9 @@ use function array_key_exists; use function array_key_first; -use function array_map; use function count; use function explode; use function gettype; -use function implode; use function is_array; use function is_int; use function is_numeric; @@ -32,9 +30,6 @@ use function strtoupper; use function trim; -use const PREG_SPLIT_DELIM_CAPTURE; -use const PREG_SPLIT_NO_EMPTY; - /** * @api * @@ -757,7 +752,7 @@ protected function processWhere( } /** - * Quote a select, join or group column string as an identifier chain + * Quote a select, join or group column string through IdentifierQuoter * * @param ColumnReference|ColumnValue $column * @throws Exception\RuntimeException @@ -771,7 +766,7 @@ protected function resolveColumnValue( ?string $namedParameterPrefix = null, ): string { if (is_array($column) && ($column['isIdentifier'] ?? false) && is_string($column['column'])) { - return ($column['fromTable'] ?? '') . $this->quoteColumnIdentifier($column['column'], $platform); + return ($column['fromTable'] ?? '') . IdentifierQuoter::quoteColumn($column['column'], $platform); } return parent::resolveColumnValue($column, $platform, $driver, $parameterContainer, $namedParameterPrefix); @@ -792,38 +787,6 @@ private function getWhere(): Where return $this->where ??= new Where(); } - /** - * Whether fragment quoting renders a column string as valid SQL - * - * It does when each piece between the safe delimiters (space, `.` and `*`) is quoted as one - * whole name and no two pieces are separated by spaces alone. - */ - private function isFragmentQuotable(string $column, PlatformInterface $platform): bool - { - $previousIsName = false; - $pieces = preg_split('/([ .*])/', $column, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - foreach (false === $pieces ? [] : $pieces as $piece) { - if (' ' === $piece) { - continue; - } - - $isName = '.' !== $piece && self::SQL_STAR !== $piece; - if ( - $isName - && ( - $previousIsName - || $platform->quoteIdentifierInFragment($piece) !== $platform->quoteIdentifier($piece) - ) - ) { - return false; - } - - $previousIsName = $isName; - } - - return true; - } - private function prefixFromTable(?string $fromTable, PlatformInterface $platform): string { return $this->prefixColumnsWithTable && null !== $fromTable && '' !== $fromTable @@ -831,27 +794,6 @@ private function prefixFromTable(?string $fromTable, PlatformInterface $platform : ''; } - /** - * Quote a column string as an identifier chain, splitting only on the table qualifier dot - * - * A `*` part is left bare. A string holding an inline ` AS ` alias, or one that fragment quoting - * already renders as valid SQL, keeps fragment quoting. - */ - private function quoteColumnIdentifier(string $column, PlatformInterface $platform): string - { - if (stripos($column, needle: ' as ') !== false || $this->isFragmentQuotable($column, $platform)) { - return $platform->quoteIdentifierInFragment($column); - } - - return implode( - $platform->getIdentifierSeparator(), - array_map( - static fn(string $part): string => self::SQL_STAR === $part ? $part : $platform->quoteIdentifier($part), - explode('.', $column), - ), - ); - } - /** * @param Select|TableReference|null $table * @return array{0: string|null, 1: string} diff --git a/test/unit/Sql/IdentifierQuoterTest.php b/test/unit/Sql/IdentifierQuoterTest.php new file mode 100644 index 000000000..b5ed8f071 --- /dev/null +++ b/test/unit/Sql/IdentifierQuoterTest.php @@ -0,0 +1,197 @@ + */ + public static function emptyPartProvider(): array + { + return [ + 'empty' => [''], + 'spaces only' => [' '], + 'leading dot' => ['.a'], + 'trailing dot' => ['a.'], + 'double dot' => ['a..b'], + 'spaced dots' => ['x-y. .b'], + ]; + } + + /** @return array */ + public static function hyphenWordPlatformProvider(): array + { + return [ + 'hyphen' => ['x-y', '`x-y`'], + 'hyphen arithmetic' => ['a-b * c', '`a-b` * `c`'], + 'space' => ['my col', '`my col`'], + 'qualified space' => ['t.my col', '`t`.`my col`'], + ]; + } + + /** @return array */ + public static function quotedColumnProvider(): array + { + return [ + 'plain' => ['a', '"a"'], + 'fragment alias' => ['a as b', '"a" as "b"'], + 'hyphenated name alias' => ['x-y as z', '"x-y" AS "z"'], + 'qualified hyphen alias' => ['t.x-y AS my col', '"t"."x-y" AS "my col"'], + 'hyphen' => ['x-y', '"x-y"'], + ]; + } + + /** @return array */ + public static function quotedNameProvider(): array + { + return [ + 'plain' => ['a', '"a"'], + 'qualified' => ['t.a', '"t"."a"'], + 'schema qualified' => ['s.t.a', '"s"."t"."a"'], + 'star' => ['*', '*'], + 'qualified star' => ['t.*', '"t".*'], + 'multiplication' => ['price*qty', '"price"*"qty"'], + 'spaced multiplication' => ['price * qty', '"price" * "qty"'], + 'alias' => ['a as b', '"a" as "b"'], + 'trailing space' => ['a ', '"a" '], + 'hyphen' => ['x-y', '"x-y"'], + 'qualified hyphen' => ['t-1.x-y', '"t-1"."x-y"'], + 'hyphen then star' => ['x-y.*', '"x-y".*'], + 'space' => ['my col', '"my col"'], + 'qualified space' => ['t.my col', '"t"."my col"'], + 'padded hyphen' => [' x-y ', '"x-y"'], + 'padded qualified hyphen' => ['t. x-y', '"t"."x-y"'], + 'as after qualifier' => ['t.as', '"t"."as"'], + 'as alone' => ['as', '"as"'], + 'non-ascii letter' => ['café', '"café"'], + 'quote character' => ['a"b', '"a\'b"'], + ]; + } + + /** @return array */ + public static function unquotableColumnProvider(): array + { + return [ + 'function call with alias' => ['COUNT(id) as c'], + 'two aliases' => ['x-y as z as w'], + 'arithmetic' => ['x-y * 2'], + ]; + } + + /** @return array */ + public static function unquotableNameProvider(): array + { + return [ + 'function call' => ['COUNT(id)'], + 'function call of star' => ['COUNT(*)'], + 'spaced minus' => ['a - b'], + 'comparison' => ['a=b'], + 'hyphen times number' => ['x-y * 2'], + 'number times hyphen' => ['price*x-y'], + 'hyphenated name with alias' => ['x-y as z'], + ]; + } + + #[Test] + #[DataProvider('hyphenWordPlatformProvider')] + public function followsThePlatformsOwnWordCharacters(string $name, string $expected): void + { + static::assertSame($expected, IdentifierQuoter::quote($name, new TrustingHyphenWordPlatform())); + } + + #[Test] + public function leavesNamesUnquotedWhenThePlatformDoesNotQuoteIdentifiers(): void + { + $platform = new class extends Sql92 { + protected bool $quoteIdentifiers = false; + }; + + static::assertSame('x-y.my col', IdentifierQuoter::quote('x-y.my col', $platform)); + } + + #[Test] + #[DataProvider('quotedNameProvider')] + public function quotesANameSoItRendersAsWritten(string $name, string $expected): void + { + static::assertSame($expected, IdentifierQuoter::quote($name, new TrustingSql92Platform())); + } + + #[Test] + public function quotesAPlainNameByFragmentWithoutReadingItsPieces(): void + { + $fragment = static fn(string $name): string => "[{$name}]"; + $whole = static fn(string $name): string => "{{$name}}"; + $platform = $this->createStub(PlatformInterface::class); + $platform->method('quoteIdentifierInFragment')->willReturnCallback($fragment); + $platform->method('quoteIdentifier')->willReturnCallback($whole); + + static::assertSame('[t.a]', IdentifierQuoter::quote('t.a', $platform)); + } + + #[Test] + #[DataProvider('quotedColumnProvider')] + public function quotesEachSideOfAnInlineColumnAliasWhereTheWholeCannotBe(string $column, string $expected): void + { + static::assertSame($expected, IdentifierQuoter::quoteColumn($column, new TrustingSql92Platform())); + } + + #[Test] + #[DataProvider('unquotableColumnProvider')] + public function rejectsAColumnWhoseSidesCannotBeQuoted(string $column): void + { + $this->expectException(InvalidArgumentException::class); + + IdentifierQuoter::quoteColumn($column, new TrustingSql92Platform()); + } + + #[Test] + #[DataProvider('emptyPartProvider')] + public function rejectsANameWithAnEmptyPart(string $name): void + { + $this->expectExceptionObject(InvalidArgumentException::forEmptyIdentifierPart($name)); + + IdentifierQuoter::quote($name, new TrustingSql92Platform()); + } + + #[Test] + #[DataProvider('unquotableNameProvider')] + public function rejectsExpressionSyntaxThatNoQuotingRendersAsWritten(string $name): void + { + $this->expectExceptionObject(InvalidArgumentException::forUnquotableIdentifier($name)); + + IdentifierQuoter::quote($name, new TrustingSql92Platform()); + } + + #[Test] + public function separatesQualifierPartsWithThePlatformSeparator(): void + { + $platform = $this->createStub(PlatformInterface::class); + $platform->method('quoteIdentifierInFragment') + ->willReturnCallback( + static fn(string $name): string => "[{$name}]" === '[x-y]' ? '[x][-][y]' : "[{$name}]", + ); + $platform->method('quoteIdentifier')->willReturnCallback(static fn(string $name): string => "[{$name}]"); + $platform->method('getIdentifierSeparator')->willReturn('::'); + + static::assertSame('[t]::[x-y]', IdentifierQuoter::quote('t.x-y', $platform)); + } +} diff --git a/test/unit/Sql/SelectColumnNameTest.php b/test/unit/Sql/SelectColumnNameTest.php index 2daf4093e..f7f00b759 100644 --- a/test/unit/Sql/SelectColumnNameTest.php +++ b/test/unit/Sql/SelectColumnNameTest.php @@ -4,6 +4,7 @@ namespace PhpDbTest\Sql; +use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Expression; use PhpDb\Sql\Select; use PhpDbTest\TestAsset\TrustingSql92Platform; @@ -17,8 +18,6 @@ #[CoversMethod(Select::class, 'processSelect')] #[CoversMethod(Select::class, 'processGroup')] #[CoversMethod(Select::class, 'resolveColumnValue')] -#[CoversMethod(Select::class, 'quoteColumnIdentifier')] -#[CoversMethod(Select::class, 'isFragmentQuotable')] final class SelectColumnNameTest extends TestCase { /** @@ -33,7 +32,6 @@ public static function groupColumnProvider(): array 'dotted hyphen' => ['t-1.x-y', '"t-1"."x-y"'], 'space' => ['my col', '"my col"'], 'quote character' => ['a"b', '"a\'b"'], - 'function call' => ['YEAR(created)', '"YEAR(created)"'], 'arithmetic' => ['a * b', '"a" * "b"'], 'arithmetic without spaces' => ['a*b', '"a"*"b"'], 'expression' => [new Expression('YEAR(created)'), 'YEAR(created)'], @@ -71,59 +69,59 @@ public static function joinColumnProvider(): array public static function selectColumnProvider(): array { return [ - 'plain' => [['bar'], true, 'SELECT "foo"."bar" AS "bar" FROM "foo"'], - 'plain aliased' => [['b' => 'bar'], true, 'SELECT "foo"."bar" AS "b" FROM "foo"'], - 'dotted' => [['t.bar'], true, 'SELECT "foo"."t"."bar" AS "t.bar" FROM "foo"'], - 'dotted without prefix' => [['t.bar'], false, 'SELECT "t"."bar" AS "t.bar" FROM "foo"'], - 'star' => [['*'], true, 'SELECT "foo".* FROM "foo"'], - 'star without prefix' => [['*'], false, 'SELECT * FROM "foo"'], - 'qualified star' => [['t.*'], true, 'SELECT "foo"."t".* AS "t.*" FROM "foo"'], - 'qualified star without prefix' => [['t.*'], false, 'SELECT "t".* AS "t.*" FROM "foo"'], - 'hyphen' => [['x-y'], true, 'SELECT "foo"."x-y" AS "x-y" FROM "foo"'], - 'hyphen aliased' => [['a' => 'x-y'], true, 'SELECT "foo"."x-y" AS "a" FROM "foo"'], - 'hyphen without prefix' => [['x-y'], false, 'SELECT "x-y" AS "x-y" FROM "foo"'], - 'space' => [['my col'], true, 'SELECT "foo"."my col" AS "my col" FROM "foo"'], - 'space aliased' => [['a' => 'my col'], true, 'SELECT "foo"."my col" AS "a" FROM "foo"'], - 'quote character' => [['a"b'], true, 'SELECT "foo"."a\'b" AS "a\'b" FROM "foo"'], - 'quote character aliased' => [['a' => 'a"b'], true, 'SELECT "foo"."a\'b" AS "a" FROM "foo"'], - 'dotted hyphen' => [['t-1.x-y'], false, 'SELECT "t-1"."x-y" AS "t-1.x-y" FROM "foo"'], - 'dotted space aliased' => [ + 'plain' => [['bar'], true, 'SELECT "foo"."bar" AS "bar" FROM "foo"'], + 'plain aliased' => [['b' => 'bar'], true, 'SELECT "foo"."bar" AS "b" FROM "foo"'], + 'dotted' => [['t.bar'], true, 'SELECT "foo"."t"."bar" AS "t.bar" FROM "foo"'], + 'dotted without prefix' => [['t.bar'], false, 'SELECT "t"."bar" AS "t.bar" FROM "foo"'], + 'star' => [['*'], true, 'SELECT "foo".* FROM "foo"'], + 'star without prefix' => [['*'], false, 'SELECT * FROM "foo"'], + 'qualified star' => [['t.*'], true, 'SELECT "foo"."t".* AS "t.*" FROM "foo"'], + 'qualified star without prefix' => [['t.*'], false, 'SELECT "t".* AS "t.*" FROM "foo"'], + 'hyphen' => [['x-y'], true, 'SELECT "foo"."x-y" AS "x-y" FROM "foo"'], + 'hyphen aliased' => [['a' => 'x-y'], true, 'SELECT "foo"."x-y" AS "a" FROM "foo"'], + 'hyphen without prefix' => [['x-y'], false, 'SELECT "x-y" AS "x-y" FROM "foo"'], + 'space' => [['my col'], true, 'SELECT "foo"."my col" AS "my col" FROM "foo"'], + 'space aliased' => [['a' => 'my col'], true, 'SELECT "foo"."my col" AS "a" FROM "foo"'], + 'quote character' => [['a"b'], true, 'SELECT "foo"."a\'b" AS "a\'b" FROM "foo"'], + 'quote character aliased' => [['a' => 'a"b'], true, 'SELECT "foo"."a\'b" AS "a" FROM "foo"'], + 'dotted hyphen' => [['t-1.x-y'], false, 'SELECT "t-1"."x-y" AS "t-1.x-y" FROM "foo"'], + 'dotted space aliased' => [ ['a' => 't 1.my col'], false, 'SELECT "t 1"."my col" AS "a" FROM "foo"', ], - 'function call' => [['COUNT(id)'], false, 'SELECT "COUNT(id)" AS "COUNT(id)" FROM "foo"'], - 'arithmetic' => [ + 'hyphenated name with inline alias' => [['x-y as z'], false, 'SELECT "x-y" AS "z" FROM "foo"'], + 'padded hyphen' => [['x-y '], false, 'SELECT "x-y" AS "x-y " FROM "foo"'], + 'arithmetic' => [ ['price * qty'], true, 'SELECT "foo"."price" * "qty" AS "price * qty" FROM "foo"', ], - 'arithmetic without prefix' => [ + 'arithmetic without prefix' => [ ['price * qty'], false, 'SELECT "price" * "qty" AS "price * qty" FROM "foo"', ], - 'arithmetic without spaces' => [['a*b'], false, 'SELECT "a"*"b" AS "a*b" FROM "foo"'], - 'arithmetic aliased' => [ + 'arithmetic without spaces' => [['a*b'], false, 'SELECT "a"*"b" AS "a*b" FROM "foo"'], + 'arithmetic aliased' => [ ['total' => 'price * qty'], false, 'SELECT "price" * "qty" AS "total" FROM "foo"', ], - 'dotted arithmetic' => [ + 'dotted arithmetic' => [ ['t.price * t.qty'], false, 'SELECT "t"."price" * "t"."qty" AS "t.price * t.qty" FROM "foo"', ], - 'arithmetic with hyphenated name' => [['x-y * 2'], false, 'SELECT "x-y * 2" AS "x-y * 2" FROM "foo"'], - 'inline alias' => [['bar AS baz'], true, 'SELECT "foo"."bar" AS "baz" FROM "foo"'], - 'inline alias lower case' => [['bar as baz'], true, 'SELECT "foo"."bar" as "baz" FROM "foo"'], - 'inline alias without prefix' => [['t.bar AS baz'], false, 'SELECT "t"."bar" AS "baz" FROM "foo"'], - 'expression' => [ + 'inline alias' => [['bar AS baz'], true, 'SELECT "foo"."bar" AS "baz" FROM "foo"'], + 'inline alias lower case' => [['bar as baz'], true, 'SELECT "foo"."bar" as "baz" FROM "foo"'], + 'inline alias without prefix' => [['t.bar AS baz'], false, 'SELECT "t"."bar" AS "baz" FROM "foo"'], + 'expression' => [ [new Expression('COUNT(id)')], true, 'SELECT COUNT(id) AS Expression1 FROM "foo"', ], - 'expression aliased' => [ + 'expression aliased' => [ ['c' => new Expression('COUNT(id)')], true, 'SELECT COUNT(id) AS "c" FROM "foo"', @@ -131,6 +129,29 @@ public static function selectColumnProvider(): array ]; } + /** @return array */ + public static function unquotableColumnProvider(): array + { + return [ + 'select function call' => [ + static fn(Select $select): Select => $select->columns(['COUNT(id)']), + 'COUNT(id)', + ], + 'select arithmetic with hyphen' => [ + static fn(Select $select): Select => $select->columns(['x-y * 2']), + 'x-y * 2', + ], + 'group function call' => [ + static fn(Select $select): Select => $select->group('YEAR(created)'), + 'YEAR(created)', + ], + 'join function call' => [ + static fn(Select $select): Select => $select->join('zac', 'm = n', ['COUNT(id)']), + 'COUNT(id)', + ], + ]; + } + #[Test] #[DataProvider('groupColumnProvider')] public function quotesGroupColumnStringsAsIdentifierChains(string|Expression $column, string $expected): void @@ -172,4 +193,16 @@ public function quotesSelectColumnStringsAsIdentifierChains( self::assertSame($expected, $select->getSqlString(new TrustingSql92Platform())); } + + /** @param callable(Select): Select $build */ + #[Test] + #[DataProvider('unquotableColumnProvider')] + public function rejectsColumnStringsHoldingExpressionSyntax(callable $build, string $column): void + { + $select = $build(new Select('foo')); + + $this->expectExceptionObject(InvalidArgumentException::forUnquotableIdentifier($column)); + + $select->getSqlString(new TrustingSql92Platform()); + } } diff --git a/test/unit/TestAsset/TrustingHyphenWordPlatform.php b/test/unit/TestAsset/TrustingHyphenWordPlatform.php new file mode 100644 index 000000000..1e65219ed --- /dev/null +++ b/test/unit/TestAsset/TrustingHyphenWordPlatform.php @@ -0,0 +1,27 @@ +quoteTrustedValue($value); + } +} From b59f42d5ef79c773864e2963b336a52b3742067c Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Wed, 4 Nov 2026 16:25:41 +1100 Subject: [PATCH 5/6] Quote predicate and identifier arguments through IdentifierQuoter Replace quoteIdentifierArgument() with the shared IdentifierQuoter, so identifier arguments follow the same rule as columns and ORDER BY names. Expression syntax that no identifier quoting renders as written, such as a+b or COUNT(id), and an empty name now throw InvalidArgumentException instead of rendering broken SQL. --- src/Sql/AbstractSql.php | 71 +------------------ test/unit/Sql/PredicateIdentifierNameTest.php | 59 ++++++++++++++- 2 files changed, 58 insertions(+), 72 deletions(-) diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index d7e6f90f2..ff3833369 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -19,25 +19,18 @@ use function array_key_exists; use function array_key_first; -use function array_map; use function count; -use function explode; use function get_object_vars; use function implode; use function is_array; use function is_string; use function preg_match; use function preg_replace; -use function preg_split; use function rtrim; use function str_replace; -use function strtolower; use function strtoupper; use function vsprintf; -use const PREG_SPLIT_DELIM_CAPTURE; -use const PREG_SPLIT_NO_EMPTY; - /** * @api * @@ -256,7 +249,7 @@ protected function processExpression( $parameterContainer, ) : $platform->quoteValue((string) $argument->getValue()), - $argument instanceof Identifier => $this->quoteIdentifierArgument($argument->getValue(), $platform), + $argument instanceof Identifier => IdentifierQuoter::quote($argument->getValue(), $platform), $argument instanceof Literal => $argument->getValue(), $argument instanceof Identifiers => $this->processIdentifiersArgument($argument, $platform), $argument instanceof SelectArgument => $this->processExpressionOrSelect( @@ -339,7 +332,7 @@ protected function processIdentifiersArgument( $processedIdentifiers = []; foreach ($identifiers as $identifier) { - $processedIdentifiers[] = $this->quoteIdentifierArgument($identifier, $platform); + $processedIdentifiers[] = IdentifierQuoter::quote($identifier, $platform); } return implode(', ', $processedIdentifiers); @@ -628,66 +621,6 @@ private function matchSpecification(array $specifications, int $parameterCount): throw Exception\RuntimeException::forUnsupportedParameterCount(); } - /** - * Quote an identifier argument, keeping fragment quoting wherever it renders valid SQL - * - * Fragment quoting leaves spaces, dots, `*` and `AS` bare and quotes every other piece the - * platform splits out, so a name holding any other delimiter renders as adjacent quoted - * pieces, and names separated only by spaces render as a bare sequence of identifiers. Both - * are invalid, so such a name is quoted whole as an identifier chain instead, split only on - * the qualifier dot with a `*` part left bare. - */ - private function quoteIdentifierArgument(string $identifier, PlatformInterface $platform): string - { - $isValidFragment = true; - $previousIsName = false; - $afterSpace = false; - - foreach ((array) preg_split( - '/([ .*])/', - $identifier, - -1, - PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, - ) as $part) { - if (' ' === $part) { - $afterSpace = true; - continue; - } - - $isName = '.' !== $part && '*' !== $part && 'as' !== strtolower((string) $part); - if ( - $isName - && ( - ( - $previousIsName - && $afterSpace - ) - || $platform->quoteIdentifierInFragment((string) $part) !== $platform->quoteIdentifier( - (string) $part, - ) - ) - ) { - $isValidFragment = false; - break; - } - - $previousIsName = $isName; - $afterSpace = false; - } - - if ($isValidFragment) { - return $platform->quoteIdentifierInFragment($identifier); - } - - return implode( - $platform->getIdentifierSeparator(), - array_map( - static fn(string $part): string => '*' === $part ? $part : $platform->quoteIdentifier($part), - explode('.', $identifier), - ), - ); - } - private function quoteJoinTableIdentifier(TableIdentifier $identifier, PlatformInterface $platform): string { [$table, $schema] = $identifier->getTableAndSchema(); diff --git a/test/unit/Sql/PredicateIdentifierNameTest.php b/test/unit/Sql/PredicateIdentifierNameTest.php index 07a908701..4516bf7cb 100644 --- a/test/unit/Sql/PredicateIdentifierNameTest.php +++ b/test/unit/Sql/PredicateIdentifierNameTest.php @@ -5,9 +5,11 @@ namespace PhpDbTest\Sql; use Closure; +use PhpDb\Adapter\Platform\PlatformInterface; use PhpDb\Sql\AbstractSql; use PhpDb\Sql\Argument; use PhpDb\Sql\Delete; +use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\Predicate\Expression; use PhpDb\Sql\Predicate\Literal; use PhpDb\Sql\Predicate\Predicate; @@ -26,7 +28,6 @@ #[Group('unit')] #[CoversMethod(AbstractSql::class, 'processExpression')] #[CoversMethod(AbstractSql::class, 'processIdentifiersArgument')] -#[CoversMethod(AbstractSql::class, 'quoteIdentifierArgument')] final class PredicateIdentifierNameTest extends TestCase { /** @@ -71,7 +72,6 @@ public static function hyphenWordPlatformProvider(): array 'dotted hyphen' => ['t-1.x-y', '`t-1`.`x-y`'], 'space' => ['my col', '`my col`'], 'quote character' => ['a`b', '`a``b`'], - 'plus' => ['a+b', '`a+b`'], ]; } @@ -95,6 +95,45 @@ public static function predicateMethodProvider(): array ]; } + /** + * @return array + */ + public static function rejectedNameProvider(): array + { + return [ + 'function call' => [ + new TrustingSql92Platform(), + 'COUNT(id)', + InvalidArgumentException::forUnquotableIdentifier('COUNT(id)'), + ], + 'spaced minus' => [ + new TrustingSql92Platform(), + 'a - b', + InvalidArgumentException::forUnquotableIdentifier('a - b'), + ], + 'arithmetic with hyphen' => [ + new TrustingSql92Platform(), + 'x-y * 2', + InvalidArgumentException::forUnquotableIdentifier('x-y * 2'), + ], + 'plus on hyphen platform' => [ + new TrustingHyphenWordPlatform(), + 'a+b', + InvalidArgumentException::forUnquotableIdentifier('a+b'), + ], + 'empty' => [ + new TrustingSql92Platform(), + '', + InvalidArgumentException::forEmptyIdentifierPart(''), + ], + 'empty qualifier part' => [ + new TrustingSql92Platform(), + 'a..b', + InvalidArgumentException::forEmptyIdentifierPart('a..b'), + ], + ]; + } + /** * Names whose fragment quoting renders valid SQL, and that rendering * @@ -112,7 +151,6 @@ public static function unchangedNameProvider(): array 'inline alias' => ['x as y', '"x" as "y"'], 'inline alias upper' => ['x AS y', '"x" AS "y"'], 'comma' => ['a,b', '"a,b"'], - 'empty' => ['', ''], ]; } @@ -299,4 +337,19 @@ public function quotesWhereArrayKeyWithNullAsIsNullName(string $name, string $qu $select->getSqlString(new TrustingSql92Platform()), ); } + + #[Test] + #[DataProvider('rejectedNameProvider')] + public function rejectsWhereNamesThatCannotBeQuotedAsWritten( + PlatformInterface $platform, + string $name, + InvalidArgumentException $expected, + ): void { + $select = new Select('foo'); + $select->where->equalTo($name, 1); + + $this->expectExceptionObject($expected); + + $select->getSqlString($platform); + } } From 59d7710b31186fcaccbdff8810097d8d12c56809 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Wed, 4 Nov 2026 17:04:51 +1100 Subject: [PATCH 6/6] Cover the identifier exception factories in InvalidArgumentExceptionTest The test restricts coverage to the factories it lists, so the new forEmptyIdentifierPart() and forUnquotableIdentifier() counted as uncovered and failed codecov/patch. --- .../Sql/Exception/InvalidArgumentExceptionTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/unit/Sql/Exception/InvalidArgumentExceptionTest.php b/test/unit/Sql/Exception/InvalidArgumentExceptionTest.php index 5a77eebfd..b4c921f6f 100644 --- a/test/unit/Sql/Exception/InvalidArgumentExceptionTest.php +++ b/test/unit/Sql/Exception/InvalidArgumentExceptionTest.php @@ -22,6 +22,7 @@ #[CoversMethod(InvalidArgumentException::class, 'forMissingColumnLength')] #[CoversMethod(InvalidArgumentException::class, 'forMissingDecimalDigits')] #[CoversMethod(InvalidArgumentException::class, 'forEmptyExpression')] +#[CoversMethod(InvalidArgumentException::class, 'forEmptyIdentifierPart')] #[CoversMethod(InvalidArgumentException::class, 'forEmptyPrefix')] #[CoversMethod(InvalidArgumentException::class, 'forEmptySchema')] #[CoversMethod(InvalidArgumentException::class, 'forEmptySeparator')] @@ -40,6 +41,7 @@ #[CoversMethod(InvalidArgumentException::class, 'forSelectWithMergeFlag')] #[CoversMethod(InvalidArgumentException::class, 'forUnenclosedValuesPlaceholder')] #[CoversMethod(InvalidArgumentException::class, 'forUnknownArgumentType')] +#[CoversMethod(InvalidArgumentException::class, 'forUnquotableIdentifier')] #[CoversMethod(InvalidArgumentException::class, 'forUnknownColumnKey')] #[CoversMethod(InvalidArgumentException::class, 'forValuesWithMergeFlag')] final class InvalidArgumentExceptionTest extends TestCase @@ -91,6 +93,11 @@ public static function namedConstructorProvider(): array [], InvalidArgumentException::EMPTY_EXPRESSION, ], + 'empty identifier part' => [ + 'forEmptyIdentifierPart', + ['a..b'], + InvalidArgumentException::EMPTY_IDENTIFIER_PART, + ], 'empty prefix' => [ 'forEmptyPrefix', [], @@ -181,6 +188,11 @@ public static function namedConstructorProvider(): array ['email'], InvalidArgumentException::UNKNOWN_COLUMN_KEY, ], + 'unquotable identifier' => [ + 'forUnquotableIdentifier', + ['COUNT(id)'], + InvalidArgumentException::UNQUOTABLE_IDENTIFIER, + ], 'values with merge flag' => [ 'forValuesWithMergeFlag', [],