Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions src/Sql/AbstractSql.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -33,7 +35,11 @@
*
* @psalm-type ParameterSpecification = array<int|string, string>|null
* @psalm-type Specification = string|array<string, list<ParameterSpecification>>
* @psalm-type SqlParameters = array<array-key, string|array<array-key, string|array<array-key, string>>>
* @psalm-type SqlParameterValue = string|int|float|bool|Stringable|null
* @psalm-type SqlParameters = array<
* array-key,
* SqlParameterValue|array<array-key, SqlParameterValue|array<array-key, SqlParameterValue>>,
* >
* @psalm-type ColumnValue = Select|ExpressionInterface|string|int|float|bool|null
* @psalm-type ColumnReference = array{
* column: ColumnValue,
Expand Down Expand Up @@ -106,7 +112,7 @@
}
}

return rtrim(implode(' ', $sqls), characters: "\n ,");

Check warning on line 115 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "UnwrapRtrim": @@ @@ } } - return rtrim(implode(' ', $sqls), characters: "\n ,"); + return implode(' ', $sqls); } /**
}

/**
Expand All @@ -114,7 +120,7 @@
* @param SqlParameters $parameters
* @throws Exception\RuntimeException
*/
protected function createSqlFromSpecificationAndParameters(array|string $specifications, array $parameters): string

Check warning on line 123 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "ProtectedVisibility": @@ @@ * @PARAM SqlParameters $parameters * @throws Exception\RuntimeException */ - protected function createSqlFromSpecificationAndParameters(array|string $specifications, array $parameters): string + private function createSqlFromSpecificationAndParameters(array|string $specifications, array $parameters): string { if (is_string($specifications)) { return vsprintf($specifications, $this->flattenParameterList($parameters));
{
if (is_string($specifications)) {
return vsprintf($specifications, $this->flattenParameterList($parameters));
Expand All @@ -136,16 +142,16 @@
* @param list<ArgumentInterface> $arguments
* @return list<ArgumentInterface>
*/
protected function flattenExpressionValues(array $arguments): array

Check warning on line 145 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "ProtectedVisibility": @@ @@ * @PARAM list<ArgumentInterface> $arguments * @return list<ArgumentInterface> */ - protected function flattenExpressionValues(array $arguments): array + private function flattenExpressionValues(array $arguments): array { $hasValues = false; foreach ($arguments as $argument) {
{
$hasValues = false;

Check warning on line 147 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "FalseValue": @@ @@ */ protected function flattenExpressionValues(array $arguments): array { - $hasValues = false; + $hasValues = true; foreach ($arguments as $argument) { if (! $argument instanceof Values) { continue;
foreach ($arguments as $argument) {
if (! $argument instanceof Values) {

Check warning on line 149 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "LogicalNot": @@ @@ { $hasValues = false; foreach ($arguments as $argument) { - if (! $argument instanceof Values) { + if ($argument instanceof Values) { continue; }

Check warning on line 149 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "InstanceOf_": @@ @@ { $hasValues = false; foreach ($arguments as $argument) { - if (! $argument instanceof Values) { + if ($argument instanceof Values) { continue; }
continue;
}

$hasValues = true;
break;

Check warning on line 154 in src/Sql/AbstractSql.php

View workflow job for this annotation

GitHub Actions / qa / Mutation testing

Escaped Mutant for Mutator "Break_": @@ @@ } $hasValues = true; - break; + continue; } if (! $hasValues) {
}

if (! $hasValues) {
Expand Down Expand Up @@ -506,27 +512,27 @@
}

/**
* @param array<array-key, string|array<array-key, mixed>> $values
* @param array<array-key, SqlParameterValue|array<array-key, mixed>> $values
* @return list<string>
* @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;
}

/**
* @param array<int|string, string> $parameterSpecification
* @param array<array-key, string|array<array-key, mixed>> $values
* @param array<array-key, SqlParameterValue|array<array-key, mixed>> $values
* @throws Exception\RuntimeException When no format accepts the number of values.
*/
private function formatParameterList(array $parameterSpecification, array $values): string
Expand Down Expand Up @@ -567,27 +573,42 @@

/**
* @param ParameterSpecification $parameterSpecification
* @param string|array<array-key, string|array<array-key, string>> $parameter
* @param SqlParameterValue|array<array-key, SqlParameterValue|array<array-key, SqlParameterValue>> $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);
}
}
13 changes: 11 additions & 2 deletions src/Sql/Select.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string|TableIdentifier|Select>
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
119 changes: 119 additions & 0 deletions test/unit/Sql/UnpreparedScalarLimitOffsetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace PhpDbTest\Sql;

use PhpDb\Sql\AbstractSql;
use PhpDb\Sql\Exception\RuntimeException;
use PhpDbTest\TestAsset\ObjectToString;
use PhpDbTest\TestAsset\TrustingSql92Platform;
use PhpDbTest\TestAsset\UnpreparedScalarLimitSelect;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Stringable;

use function sprintf;

#[Group('unit')]
#[CoversMethod(AbstractSql::class, 'buildSqlString')]
#[CoversMethod(AbstractSql::class, 'createSqlFromSpecificationAndParameters')]
#[CoversMethod(AbstractSql::class, 'flattenParameterList')]
#[CoversMethod(AbstractSql::class, 'formatParameterList')]
#[CoversMethod(AbstractSql::class, 'renderParameter')]
#[CoversMethod(AbstractSql::class, 'stringifyParameter')]
final class UnpreparedScalarLimitOffsetTest extends TestCase
{
/**
* @return array<string, array{0: array<string, list<array<int|string, string>|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<string, array{
* 0: int|float|string|Stringable|null,
* 1: int|float|string|Stringable|null,
* 2: string,
* }>
*/
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<string, list<array<int|string, string>|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());
}
}
82 changes: 82 additions & 0 deletions test/unit/TestAsset/UnpreparedScalarLimitSelect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace PhpDbTest\TestAsset;

use Override;
use PhpDb\Adapter\Driver\DriverInterface;
use PhpDb\Adapter\ParameterContainer;
use PhpDb\Adapter\Platform\PlatformInterface;
use PhpDb\Sql\Select;
use Stringable;

/**
* Renders LIMIT and OFFSET the way the phpdb-mysql and phpdb-sqlite SelectDecorators do when
* the statement is not prepared: the raw value is returned as the clause parameter, without
* being cast to a string.
*
* @psalm-type RawValue = int|float|string|Stringable|list<string>|null
*/
final class UnpreparedScalarLimitSelect extends Select
{
/**
* @param RawValue $rawLimit
* @param RawValue $rawOffset
* @param array<string, list<array<int|string, string>|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];
}
}
Loading