diff --git a/src/Sql/AbstractSql.php b/src/Sql/AbstractSql.php index 5ea9aaad..9577af36 100644 --- a/src/Sql/AbstractSql.php +++ b/src/Sql/AbstractSql.php @@ -16,6 +16,7 @@ use PhpDb\Sql\Argument\Value; use PhpDb\Sql\Argument\Values; use PhpDb\Sql\Platform\PlatformDecoratorInterface; +use Stringable; use function array_key_first; use function count; @@ -24,6 +25,7 @@ use function is_array; use function is_string; use function rtrim; +use function sprintf; use function str_replace; use function strtoupper; use function vsprintf; @@ -33,7 +35,11 @@ * * @psalm-type ParameterSpecification = array|null * @psalm-type Specification = string|array> - * @psalm-type SqlParameters = array>> + * @psalm-type SqlParameterValue = string|int|float|bool|Stringable|null + * @psalm-type SqlParameters = array< + * array-key, + * SqlParameterValue|array>, + * > * @psalm-type ColumnValue = Select|ExpressionInterface|string|int|float|bool|null * @psalm-type ColumnReference = array{ * column: ColumnValue, @@ -506,19 +512,19 @@ protected function resolveTable( } /** - * @param array> $values + * @param array> $values * @return list - * @throws Exception\RuntimeException When a value is a nested list rather than a string. + * @throws Exception\RuntimeException When a value is a nested list rather than a scalar. */ private function flattenParameterList(array $values): array { $strings = []; foreach ($values as $value) { - if (! is_string($value)) { + if (is_array($value)) { throw Exception\RuntimeException::forUnsupportedParameterCountOf(count($value)); } - $strings[] = $value; + $strings[] = $this->stringifyParameter($value); } return $strings; @@ -526,7 +532,7 @@ private function flattenParameterList(array $values): array /** * @param array $parameterSpecification - * @param array> $values + * @param array> $values * @throws Exception\RuntimeException When no format accepts the number of values. */ private function formatParameterList(array $parameterSpecification, array $values): string @@ -567,27 +573,42 @@ private function quoteJoinTableIdentifier(TableIdentifier $identifier, PlatformI /** * @param ParameterSpecification $parameterSpecification - * @param string|array> $parameter + * @param SqlParameterValue|array> $parameter * @throws Exception\RuntimeException */ - private function renderParameter(?array $parameterSpecification, string|array $parameter): string - { + private function renderParameter( + ?array $parameterSpecification, + string|int|float|bool|Stringable|array|null $parameter, + ): string { if (null === $parameterSpecification) { - return is_string($parameter) - ? $parameter - : throw Exception\RuntimeException::forUnsupportedParameterCountOf(count($parameter)); + return is_array($parameter) + ? throw Exception\RuntimeException::forUnsupportedParameterCountOf(count($parameter)) + : $this->stringifyParameter($parameter); } + $parameters = is_array($parameter) ? $parameter : [$parameter]; + $combinedBy = $parameterSpecification['combinedby'] ?? null; if (null === $combinedBy) { - return $this->formatParameterList($parameterSpecification, (array) $parameter); + return $this->formatParameterList($parameterSpecification, $parameters); } $rendered = []; - foreach ((array) $parameter as $values) { - $rendered[] = $this->formatParameterList($parameterSpecification, (array) $values); + foreach ($parameters as $values) { + $rendered[] = $this->formatParameterList( + $parameterSpecification, + is_array($values) ? $values : [$values], + ); } return implode($combinedBy, $rendered); } + + /** + * Converts a scalar clause parameter to the text vsprintf's %s conversion would produce. + */ + private function stringifyParameter(string|int|float|bool|Stringable|null $value): string + { + return is_string($value) ? $value : sprintf('%s', $value); + } } diff --git a/src/Sql/Select.php b/src/Sql/Select.php index 183750be..e8e58deb 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -36,6 +36,7 @@ * @property ?Having $having * @property ?Join $joins * @psalm-import-type Specification from AbstractSql + * @psalm-import-type SqlParameterValue from AbstractSql * @psalm-import-type PredicateValue from Predicate\PredicateSet * @psalm-import-type JoinName from Join * @psalm-type TableReference = string|TableIdentifier|array @@ -537,7 +538,11 @@ protected function processJoins( return $this->processJoin($this->joins, $platform, $driver, $parameterContainer); } - /** @return array{0: string}|null */ + /** + * Platform overrides may return the raw limit or offset unquoted; any scalar is rendered as vsprintf's %s would. + * + * @return array{0: SqlParameterValue}|null + */ protected function processLimit( PlatformInterface $platform, ?DriverInterface $driver = null, @@ -556,7 +561,11 @@ protected function processLimit( return [$platform->quoteValue((string) $this->limit)]; } - /** @return array{0: string}|null */ + /** + * Platform overrides may return the raw limit or offset unquoted; any scalar is rendered as vsprintf's %s would. + * + * @return array{0: SqlParameterValue}|null + */ protected function processOffset( PlatformInterface $platform, ?DriverInterface $driver = null, diff --git a/test/unit/Sql/UnpreparedScalarLimitOffsetTest.php b/test/unit/Sql/UnpreparedScalarLimitOffsetTest.php new file mode 100644 index 00000000..03b693fb --- /dev/null +++ b/test/unit/Sql/UnpreparedScalarLimitOffsetTest.php @@ -0,0 +1,119 @@ +|null>>}> + */ + public static function limitSpecificationProvider(): array + { + return [ + 'slot without parameter specification' => [['LIMIT %1$s' => [null]]], + 'slot with parameter specification' => [['LIMIT %1$s' => [[1 => '%1$s']]]], + 'slot combined by separator' => [['LIMIT %1$s' => [[1 => '%1$s', 'combinedby' => ', ']]]], + ]; + } + + /** + * @return array + */ + public static function scalarLimitOffsetProvider(): array + { + return [ + 'int limit' => [10, null, 'SELECT "t".* FROM "t" LIMIT 10'], + 'float limit' => [2.5, null, 'SELECT "t".* FROM "t" LIMIT 2.5'], + 'numeric string limit' => ['10', null, 'SELECT "t".* FROM "t" LIMIT 10'], + 'stringable limit' => [new ObjectToString('10'), null, 'SELECT "t".* FROM "t" LIMIT 10'], + 'int offset only' => [null, 5, 'SELECT "t".* FROM "t" LIMIT 18446744073709551615 OFFSET 5'], + 'float offset only' => [null, 5.0, 'SELECT "t".* FROM "t" LIMIT 18446744073709551615 OFFSET 5'], + 'int limit and int offset' => [10, 20, 'SELECT "t".* FROM "t" LIMIT 10 OFFSET 20'], + 'int limit and string offset' => [10, '20', 'SELECT "t".* FROM "t" LIMIT 10 OFFSET 20'], + ]; + } + + #[Test] + public function rendersFloatsWithTheSamePrecisionAsVsprintf(): void + { + $select = new UnpreparedScalarLimitSelect('t', 0.1 + 0.2); + + self::assertSame( + sprintf('SELECT "t".* FROM "t" LIMIT %s', 0.1 + 0.2), + $select->getSqlString(new TrustingSql92Platform()), + ); + } + + /** + * @param array|null>> $specification + */ + #[Test] + #[DataProvider('limitSpecificationProvider')] + public function rendersIntegerLimitThroughArraySpecifications(array $specification): void + { + $select = new UnpreparedScalarLimitSelect('t', 10, limitSpecification: $specification); + + self::assertSame('SELECT "t".* FROM "t" LIMIT 10', $select->getSqlString(new TrustingSql92Platform())); + } + + #[Test] + #[DataProvider('scalarLimitOffsetProvider')] + public function rendersScalarLimitAndOffsetReturnedUnquotedByProcessHooks( + int|float|string|Stringable|null $limit, + int|float|string|Stringable|null $offset, + string $expected, + ): void { + $select = new UnpreparedScalarLimitSelect('t', $limit, $offset); + + self::assertSame($expected, $select->getSqlString(new TrustingSql92Platform())); + } + + #[Test] + public function stillRejectsNestedListForSlotWithoutParameterSpecification(): void + { + $select = new UnpreparedScalarLimitSelect('t', ['1', '2'], limitSpecification: ['LIMIT %1$s' => [null]]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf(RuntimeException::UNSUPPORTED_PARAMETER_COUNT_OF, 2)); + + $select->getSqlString(new TrustingSql92Platform()); + } + + #[Test] + public function stillRejectsNestedListReturnedForStringSpecification(): void + { + $select = new UnpreparedScalarLimitSelect('t', ['1', '2']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf(RuntimeException::UNSUPPORTED_PARAMETER_COUNT_OF, 2)); + + $select->getSqlString(new TrustingSql92Platform()); + } +} diff --git a/test/unit/TestAsset/UnpreparedScalarLimitSelect.php b/test/unit/TestAsset/UnpreparedScalarLimitSelect.php new file mode 100644 index 00000000..9cb0087c --- /dev/null +++ b/test/unit/TestAsset/UnpreparedScalarLimitSelect.php @@ -0,0 +1,82 @@ +|null + */ +final class UnpreparedScalarLimitSelect extends Select +{ + /** + * @param RawValue $rawLimit + * @param RawValue $rawOffset + * @param array|null>>|null $limitSpecification + */ + public function __construct( + string $table, + private readonly int|float|string|Stringable|array|null $rawLimit, + private readonly int|float|string|Stringable|array|null $rawOffset = null, + private readonly ?array $limitSpecification = null, + ) { + parent::__construct($table); + + if (null !== $this->limitSpecification) { + $this->specifications[self::LIMIT] = $this->limitSpecification; + } + } + + #[Override] + protected function localizeVariables(): void + { + parent::localizeVariables(); + + if (null === $this->rawLimit && null !== $this->rawOffset) { + $this->specifications[self::LIMIT] = 'LIMIT 18446744073709551615'; + } + } + + /** @return array{0: RawValue}|null */ + #[Override] + protected function processLimit( + PlatformInterface $platform, + ?DriverInterface $driver = null, + ?ParameterContainer $parameterContainer = null, + ): ?array { + if (null === $this->rawLimit && null !== $this->rawOffset) { + return ['']; + } + + if (null === $this->rawLimit) { + return null; + } + + return [$this->rawLimit]; + } + + /** @return array{0: RawValue}|null */ + #[Override] + protected function processOffset( + PlatformInterface $platform, + ?DriverInterface $driver = null, + ?ParameterContainer $parameterContainer = null, + ): ?array { + if (null === $this->rawOffset) { + return null; + } + + return [$this->rawOffset]; + } +}