diff --git a/src/Sql/Select.php b/src/Sql/Select.php index 183750be..bd56e054 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; @@ -29,6 +32,9 @@ use function strtoupper; use function trim; +use const PREG_SPLIT_DELIM_CAPTURE; +use const PREG_SPLIT_NO_EMPTY; + /** * @api * @@ -36,6 +42,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 +756,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(); @@ -763,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 @@ -770,6 +831,27 @@ 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/SelectColumnNameTest.php b/test/unit/Sql/SelectColumnNameTest.php new file mode 100644 index 00000000..2daf4093 --- /dev/null +++ b/test/unit/Sql/SelectColumnNameTest.php @@ -0,0 +1,175 @@ + + */ + 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"'], + '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)'], + ]; + } + + /** + * @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"'], + '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"'], + ]; + } + + /** + * @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'], + 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 "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' => [ + [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())); + } +}