diff --git a/docs/book/sql/where-having.md b/docs/book/sql/where-having.md index 31573e4f..a7cafade 100644 --- a/docs/book/sql/where-having.md +++ b/docs/book/sql/where-having.md @@ -517,10 +517,35 @@ class In implements PredicateInterface array|Select|ArgumentInterface $valueSet ) : static; public function getValueSet() : ?ArgumentInterface; + public function setSpecification(string $specification) : static; + public function getSpecification() : ?string; public function getExpressionData() : ExpressionData; } ``` +A custom specification refers to the identifier and the value set with one +placeholder each, plain (`%s`) or positional (`%1$s`, `%2$s`). The value set's +placeholder receives the whole list, comma-separated, with no parentheses +added; an empty list renders as `NULL`. + +```php title="Using a custom specification with In" +$where->addPredicate( + (new Predicate\In('id', [1, 2, 3]))->setSpecification('%1$s = ANY (ARRAY[%2$s])') +); +``` + +Produces: + +```sql title="SQL output for a custom In specification" +WHERE "id" = ANY (ARRAY['1', '2', '3']) +``` + +The value set must be referenced exactly once, as must an identifier that binds +a parameter (such as `Argument::value()`), so the rendered placeholders always +match the bound parameters. A specification that repeats or omits one, or +references an argument the predicate does not have, throws +`PhpDb\Sql\Predicate\Exception\InvalidArgumentException`. + ## Range Predicates ### between() and notBetween() diff --git a/src/Sql/Predicate/CustomSpecification.php b/src/Sql/Predicate/CustomSpecification.php new file mode 100644 index 00000000..24b024a6 --- /dev/null +++ b/src/Sql/Predicate/CustomSpecification.php @@ -0,0 +1,125 @@ + $arguments + * @throws InvalidArgumentException When a placeholder names a missing argument, or an + * argument that binds values is not referenced exactly once. + */ + public static function expand(string $specification, array $arguments): string + { + $offsets = self::offsets($arguments); + $referenced = []; + $sequential = 0; + $expanded = ''; + + /** @var list $parts */ + $parts = preg_split('/%%(*SKIP)(*FAIL)|(%(?:\d+\$)?s)/', $specification, flags: PREG_SPLIT_DELIM_CAPTURE); + foreach ($parts as $part => $text) { + if (0 === ($part % 2)) { + $expanded .= $text; + continue; + } + + $index = '%s' === $text ? $sequential++ : (int) substr($text, offset: 1, length: -2) - 1; + $argument = $arguments[$index] ?? throw InvalidArgumentException::forSpecificationArgumentOutOfRange( + $specification, + $index + 1, + count($arguments), + ); + + $referenced[] = $index; + $expanded .= self::placeholders($argument, $offsets[$index] ?? 1); + } + + $references = array_count_values($referenced); + foreach ($arguments as $index => $argument) { + $count = $references[$index] ?? 0; + if (1 !== $count && self::bindsValues($argument)) { + throw InvalidArgumentException::forSpecificationArgumentNotReferencedOnce( + $specification, + $index + 1, + $count, + ); + } + } + + return $expanded; + } + + /** + * Whether rendering the argument binds parameters, so repeating or omitting it would misalign them + */ + private static function bindsValues(ArgumentInterface $argument): bool + { + return ! in_array( + $argument->getType(), + [ArgumentType::Identifier, ArgumentType::Identifiers, ArgumentType::Literal], + strict: true, + ); + } + + /** + * Position of each argument's first rendered value + * + * @param list $arguments + * @return array + */ + private static function offsets(array $arguments): array + { + $offsets = []; + $position = 1; + foreach ($arguments as $index => $argument) { + $offsets[$index] = $position; + $position += $argument instanceof Values ? count($argument->getValue()) : 1; + } + + return $offsets; + } + + /** + * Positional placeholders for every value an argument renders, starting at $offset + */ + private static function placeholders(ArgumentInterface $argument, int $offset): string + { + $count = $argument instanceof Values ? count($argument->getValue()) : 1; + + return 0 === $count + ? 'NULL' + : implode(', ', array_map( + static fn(int $position): string => "%{$position}\$s", + range($offset, $offset + $count - 1), + )); + } +} diff --git a/src/Sql/Predicate/Exception/InvalidArgumentException.php b/src/Sql/Predicate/Exception/InvalidArgumentException.php index 4209eb36..100c5266 100644 --- a/src/Sql/Predicate/Exception/InvalidArgumentException.php +++ b/src/Sql/Predicate/Exception/InvalidArgumentException.php @@ -35,6 +35,10 @@ class InvalidArgumentException extends Exception\InvalidArgumentException final public const string PREDICATE_WITH_STRING_KEY = 'Using Predicate must not use string keys'; + final public const string SPECIFICATION_ARGUMENT_NOT_REFERENCED_ONCE = 'Specification "%s" must reference argument %d exactly once because it binds values, but references it %d times'; + + final public const string SPECIFICATION_ARGUMENT_OUT_OF_RANGE = 'Specification "%s" references argument %d, but only %d arguments are available'; + public static function forInvalidCombination(): self { return new self(self::INVALID_COMBINATION); @@ -89,4 +93,25 @@ public static function forPredicateWithStringKey(): self { return new self(self::PREDICATE_WITH_STRING_KEY); } + + public static function forSpecificationArgumentNotReferencedOnce( + string $specification, + int $position, + int $references, + ): self { + return new self(sprintf( + self::SPECIFICATION_ARGUMENT_NOT_REFERENCED_ONCE, + $specification, + $position, + $references, + )); + } + + public static function forSpecificationArgumentOutOfRange( + string $specification, + int $position, + int $available, + ): self { + return new self(sprintf(self::SPECIFICATION_ARGUMENT_OUT_OF_RANGE, $specification, $position, $available)); + } } diff --git a/src/Sql/Predicate/In.php b/src/Sql/Predicate/In.php index 8e4c898b..317d1685 100644 --- a/src/Sql/Predicate/In.php +++ b/src/Sql/Predicate/In.php @@ -52,12 +52,21 @@ public function getExpressionData(): array throw InvalidArgumentException::forMissingValueSet(); } + $values = [$this->identifier, $this->valueSet]; + + if (null !== $this->specification) { + return [ + 'spec' => CustomSpecification::expand($this->specification, $values), + 'values' => $values, + ]; + } + $identifierSpec = $this->identifier->getSpecification(); $valueSetSpec = $this->valueSet->getSpecification(); return [ - 'spec' => $this->specification ?? "{$identifierSpec} {$this->operator} {$valueSetSpec}", - 'values' => [$this->identifier, $this->valueSet], + 'spec' => "{$identifierSpec} {$this->operator} {$valueSetSpec}", + 'values' => $values, ]; } diff --git a/test/unit/Sql/Predicate/CustomSpecificationTest.php b/test/unit/Sql/Predicate/CustomSpecificationTest.php new file mode 100644 index 00000000..4dfec38e --- /dev/null +++ b/test/unit/Sql/Predicate/CustomSpecificationTest.php @@ -0,0 +1,158 @@ +, string}> */ + public static function expansionProvider(): array + { + return [ + 'one value, positional placeholders' => [ + '%1$s = ANY (ARRAY[%2$s])', + [Argument::identifier('id'), Argument::values([1])], + '%1$s = ANY (ARRAY[%2$s])', + ], + 'two values, positional placeholders' => [ + '%1$s = ANY (ARRAY[%2$s])', + [Argument::identifier('id'), Argument::values([1, 2])], + '%1$s = ANY (ARRAY[%2$s, %3$s])', + ], + 'three values, plain placeholders' => [ + '%s = ANY (ARRAY[%s])', + [Argument::identifier('id'), Argument::values([1, 2, 3])], + '%1$s = ANY (ARRAY[%2$s, %3$s, %4$s])', + ], + 'list placed before the identifier' => [ + 'ARRAY[%2$s] @> ARRAY[%1$s]', + [Argument::identifier('id'), Argument::values([1, 2])], + 'ARRAY[%2$s, %3$s] @> ARRAY[%1$s]', + ], + 'identifier referenced twice' => [ + '(%1$s IS NULL OR %1$s IN (%2$s))', + [Argument::identifier('id'), Argument::values([1, 2])], + '(%1$s IS NULL OR %1$s IN (%2$s, %3$s))', + ], + 'escaped percent sign kept' => [ + "%1\$s IN (%2\$s) AND 'a%%' <> ''", + [Argument::identifier('id'), Argument::values([1, 2])], + "%1\$s IN (%2\$s, %3\$s) AND 'a%%' <> ''", + ], + 'escaped percent before an s kept' => [ + "%1\$s IN (%2\$s) AND 'a%%s' <> ''", + [Argument::identifier('id'), Argument::values([1, 2])], + "%1\$s IN (%2\$s, %3\$s) AND 'a%%s' <> ''", + ], + 'empty list' => [ + '%1$s = ANY (ARRAY[%2$s])', + [Argument::identifier('id'), Argument::values([])], + '%1$s = ANY (ARRAY[NULL])', + ], + 'sub-select value set' => [ + '%1$s = ANY %2$s', + [Argument::identifier('id'), Argument::select(new Select('u'))], + '%1$s = ANY %2$s', + ], + 'list identifier shifts the value set' => [ + 'ROW(%1$s) IN (%2$s)', + [Argument::values([1, 2]), Argument::values([3, 4])], + 'ROW(%1$s, %2$s) IN (%3$s, %4$s)', + ], + ]; + } + + /** @return array, string}> */ + public static function inconsistentSpecificationProvider(): array + { + $inList = [Argument::identifier('id'), Argument::values([1, 2])]; + + return [ + 'list referenced twice' => [ + '%1$s IN (%2$s) OR %1$s = ANY (ARRAY[%2$s])', + $inList, + sprintf( + InvalidArgumentException::SPECIFICATION_ARGUMENT_NOT_REFERENCED_ONCE, + '%1$s IN (%2$s) OR %1$s = ANY (ARRAY[%2$s])', + 2, + 2, + ), + ], + 'list never referenced' => [ + '%1$s IS NOT NULL', + $inList, + sprintf(InvalidArgumentException::SPECIFICATION_ARGUMENT_NOT_REFERENCED_ONCE, '%1$s IS NOT NULL', 2, 0), + ], + 'bound value referenced twice' => [ + '%1$s IN (%2$s) OR %1$s IS NULL', + [Argument::value('a'), Argument::values([1])], + sprintf( + InvalidArgumentException::SPECIFICATION_ARGUMENT_NOT_REFERENCED_ONCE, + '%1$s IN (%2$s) OR %1$s IS NULL', + 1, + 2, + ), + ], + 'missing argument referenced' => [ + '%1$s IN (%3$s)', + $inList, + sprintf(InvalidArgumentException::SPECIFICATION_ARGUMENT_OUT_OF_RANGE, '%1$s IN (%3$s)', 3, 2), + ], + 'too many plain placeholders' => [ + '%s IN (%s) OR %s', + $inList, + sprintf(InvalidArgumentException::SPECIFICATION_ARGUMENT_OUT_OF_RANGE, '%s IN (%s) OR %s', 3, 2), + ], + ]; + } + + /** @param list $arguments */ + #[Test] + #[DataProvider('expansionProvider')] + public function eachPlaceholderCoversEveryValueItsArgumentRenders( + string $specification, + array $arguments, + string $expected, + ): void { + static::assertSame($expected, CustomSpecification::expand($specification, $arguments)); + } + + /** @param list $arguments */ + #[Test] + #[DataProvider('inconsistentSpecificationProvider')] + public function specificationThatWouldMisalignParametersIsRejected( + string $specification, + array $arguments, + string $expectedMessage, + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + + CustomSpecification::expand($specification, $arguments); + } + + #[Test] + public function unboundArgumentsMayBeOmitted(): void + { + static::assertSame( + 'EXISTS (%2$s)', + CustomSpecification::expand('EXISTS (%2$s)', [Argument::literal('1'), Argument::values([1])]), + ); + } +} diff --git a/test/unit/Sql/Predicate/Exception/InvalidArgumentExceptionTest.php b/test/unit/Sql/Predicate/Exception/InvalidArgumentExceptionTest.php index 78a97583..aaecb87f 100644 --- a/test/unit/Sql/Predicate/Exception/InvalidArgumentExceptionTest.php +++ b/test/unit/Sql/Predicate/Exception/InvalidArgumentExceptionTest.php @@ -26,67 +26,79 @@ #[CoversMethod(InvalidArgumentException::class, 'forMissingValueSet')] #[CoversMethod(InvalidArgumentException::class, 'forMixedCaseMagicProperty')] #[CoversMethod(InvalidArgumentException::class, 'forPredicateWithStringKey')] +#[CoversMethod(InvalidArgumentException::class, 'forSpecificationArgumentNotReferencedOnce')] +#[CoversMethod(InvalidArgumentException::class, 'forSpecificationArgumentOutOfRange')] final class InvalidArgumentExceptionTest extends TestCase { /** @return array, string}> */ public static function namedConstructorProvider(): array { return [ - 'invalid combination' => [ + 'invalid combination' => [ 'forInvalidCombination', [], InvalidArgumentException::INVALID_COMBINATION, ], - 'invalid list predicate' => [ + 'invalid list predicate' => [ 'forInvalidListPredicate', [], InvalidArgumentException::INVALID_LIST_PREDICATE, ], - 'missing identifier' => [ + 'missing identifier' => [ 'forMissingIdentifier', [], InvalidArgumentException::MISSING_IDENTIFIER, ], - 'missing left expression' => [ + 'missing left expression' => [ 'forMissingLeftExpression', [], InvalidArgumentException::MISSING_LEFT_EXPRESSION, ], - 'missing like expression' => [ + 'missing like expression' => [ 'forMissingLikeExpression', [], InvalidArgumentException::MISSING_LIKE_EXPRESSION, ], - 'missing max value' => [ + 'missing max value' => [ 'forMissingMaxValue', [], InvalidArgumentException::MISSING_MAX_VALUE, ], - 'missing min value' => [ + 'missing min value' => [ 'forMissingMinValue', [], InvalidArgumentException::MISSING_MIN_VALUE, ], - 'missing right expression' => [ + 'missing right expression' => [ 'forMissingRightExpression', [], InvalidArgumentException::MISSING_RIGHT_EXPRESSION, ], - 'missing value set' => [ + 'missing value set' => [ 'forMissingValueSet', [], InvalidArgumentException::MISSING_VALUE_SET, ], - 'mixed case magic property' => [ + 'mixed case magic property' => [ 'forMixedCaseMagicProperty', ['Or'], InvalidArgumentException::MIXED_CASE_MAGIC_PROPERTY, ], - 'predicate with string key' => [ + 'predicate with string key' => [ 'forPredicateWithStringKey', [], InvalidArgumentException::PREDICATE_WITH_STRING_KEY, ], + 'specification argument not referenced once' => [ + 'forSpecificationArgumentNotReferencedOnce', + ['%1$s IN (%2$s) OR %1$s IN (%2$s)', 2, 2], + InvalidArgumentException::SPECIFICATION_ARGUMENT_NOT_REFERENCED_ONCE, + ], + 'specification argument out of range' => [ + 'forSpecificationArgumentOutOfRange', + ['%1$s IN (%3$s)', 3, 2], + InvalidArgumentException::SPECIFICATION_ARGUMENT_OUT_OF_RANGE, + ], ]; } diff --git a/test/unit/Sql/Predicate/InTest.php b/test/unit/Sql/Predicate/InTest.php index cc927901..8c5132ff 100644 --- a/test/unit/Sql/Predicate/InTest.php +++ b/test/unit/Sql/Predicate/InTest.php @@ -4,6 +4,9 @@ namespace PhpDbTest\Sql\Predicate; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Adapter\Driver\DriverInterface; +use PhpDb\Adapter\StatementContainer; use PhpDb\Sql\Argument; use PhpDb\Sql\Argument\Select as ArgumentSelect; use PhpDb\Sql\Argument\Values; @@ -12,7 +15,9 @@ use PhpDb\Sql\Predicate\Exception\InvalidArgumentException; use PhpDb\Sql\Predicate\In; use PhpDb\Sql\Select; +use PhpDbTest\TestAsset\TrustingSql92Platform; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -26,6 +31,65 @@ #[Group('unit')] final class InTest extends TestCase { + /** @return array}> */ + public static function customSpecificationPreparedProvider(): array + { + return [ + 'one value, positional placeholders' => [ + (new In('id', [1]))->setSpecification('%1$s = ANY (ARRAY[%2$s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[:where1])', + ['where1' => 1], + ], + 'two values, positional placeholders' => [ + (new In('id', [1, 2]))->setSpecification('%1$s = ANY (ARRAY[%2$s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[:where1, :where2])', + ['where1' => 1, 'where2' => 2], + ], + 'three values, plain placeholders' => [ + (new In('id', [1, 2, 3]))->setSpecification('%s = ANY (ARRAY[%s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[:where1, :where2, :where3])', + ['where1' => 1, 'where2' => 2, 'where3' => 3], + ], + 'empty list' => [ + (new In('id', []))->setSpecification('%1$s = ANY (ARRAY[%2$s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[NULL])', + [], + ], + ]; + } + + /** @return array */ + public static function customSpecificationSqlProvider(): array + { + return [ + 'one value, positional placeholders' => [ + (new In('id', [1]))->setSpecification('%1$s = ANY (ARRAY[%2$s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[\'1\'])', + ], + 'two values, positional placeholders' => [ + (new In('id', [1, 2]))->setSpecification('%1$s = ANY (ARRAY[%2$s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[\'1\', \'2\'])', + ], + 'three values, plain placeholders' => [ + (new In('id', [1, 2, 3]))->setSpecification('%s = ANY (ARRAY[%s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[\'1\', \'2\', \'3\'])', + ], + 'list placed before the identifier' => [ + (new In('id', [1, 2]))->setSpecification('ARRAY[%2$s] @> ARRAY[%1$s]'), + 'SELECT "t".* FROM "t" WHERE ARRAY[\'1\', \'2\'] @> ARRAY["id"]', + ], + 'empty list' => [ + (new In('id', []))->setSpecification('%1$s = ANY (ARRAY[%2$s])'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (ARRAY[NULL])', + ], + 'sub-select value set' => [ + (new In('id', (new Select('u'))->columns(['id'])->where(['active' => 1]))) + ->setSpecification('%1$s = ANY %2$s'), + 'SELECT "t".* FROM "t" WHERE "id" = ANY (SELECT "u"."id" AS "id" FROM "u" WHERE "active" = \'1\')', + ], + ]; + } + #[Test] public function canPassIdentifierAndEmptyValueSetToConstructor(): void { @@ -62,6 +126,47 @@ public function canPassIdentifierAndValueSetToConstructor(): void static::assertEquals(ArgumentType::Values, $valueSet->getType()); } + /** @param array $expectedParameters */ + #[Test] + #[DataProvider('customSpecificationPreparedProvider')] + public function customSpecificationBindsEveryListValueOnce( + In $in, + string $expectedSql, + array $expectedParameters, + ): void { + $statement = $this->prepare((new Select('t'))->where([$in])); + + static::assertSame( + [$expectedSql, $expectedParameters], + [$statement->getSql(), $statement->getParameterContainer()?->getNamedArray()], + ); + } + + #[Test] + public function customSpecificationBindsSubSelectParametersOnce(): void + { + $in = (new In('id', (new Select('u'))->columns(['id'])->where(['active' => 1]))) + ->setSpecification('%s = ANY %s'); + $statement = $this->prepare((new Select('t'))->where([$in])); + + static::assertSame( + [ + 'SELECT "t".* FROM "t" WHERE "id" = ANY (SELECT "u"."id" AS "id" FROM "u" WHERE "active" = :subselect1where1)', + ['subselect1where1' => 1], + ], + [$statement->getSql(), $statement->getParameterContainer()?->getNamedArray()], + ); + } + + #[Test] + #[DataProvider('customSpecificationSqlProvider')] + public function customSpecificationRendersEveryListValue(In $in, string $expectedSql): void + { + $select = (new Select('t'))->where([$in]); + + static::assertSame($expectedSql, $select->getSqlString(new TrustingSql92Platform())); + } + #[Test] public function emptyConstructorYieldsNullIdentifierAndValueSet(): void { @@ -319,4 +424,19 @@ public function valueSetIsMutable(): void static::assertEquals([3, 4, 5], $valueSet2->getValue()); static::assertEquals(ArgumentType::Values, $valueSet2->getType()); } + + private function prepare(Select $select): StatementContainer + { + $driver = $this->createStub(DriverInterface::class); + $driver->method('formatParameterName')->willReturnCallback(static fn(string $name): string => ":{$name}"); + + $adapter = $this->createStub(AdapterInterface::class); + $adapter->method('getDriver')->willReturn($driver); + $adapter->method('getPlatform')->willReturn(new TrustingSql92Platform()); + + $statement = new StatementContainer(); + $select->prepareStatement($adapter, $statement); + + return $statement; + } } diff --git a/test/unit/Sql/Predicate/NotInTest.php b/test/unit/Sql/Predicate/NotInTest.php index 645c0cc4..a0c3050a 100644 --- a/test/unit/Sql/Predicate/NotInTest.php +++ b/test/unit/Sql/Predicate/NotInTest.php @@ -9,11 +9,38 @@ use PhpDb\Sql\ArgumentType; use PhpDb\Sql\Predicate\NotIn; use PhpDb\Sql\Select; +use PhpDbTest\TestAsset\TrustingSql92Platform; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +#[CoversMethod(NotIn::class, 'getExpressionData')] +#[Group('unit')] final class NotInTest extends TestCase { + /** @return array, string}> */ + public static function customSpecificationProvider(): array + { + return [ + 'one value' => [[1], 'SELECT "t".* FROM "t" WHERE NOT ("id" = ANY (ARRAY[\'1\']))'], + 'two values' => [[1, 2], 'SELECT "t".* FROM "t" WHERE NOT ("id" = ANY (ARRAY[\'1\', \'2\']))'], + 'three values' => [[1, 2, 3], 'SELECT "t".* FROM "t" WHERE NOT ("id" = ANY (ARRAY[\'1\', \'2\', \'3\']))'], + ]; + } + + /** @param list $valueSet */ + #[Test] + #[DataProvider('customSpecificationProvider')] + public function customSpecificationRendersEveryListValue(array $valueSet, string $expectedSql): void + { + $notIn = (new NotIn('id', $valueSet))->setSpecification('NOT (%s = ANY (ARRAY[%s]))'); + $select = (new Select('t'))->where([$notIn]); + + static::assertSame($expectedSql, $select->getSqlString(new TrustingSql92Platform())); + } + #[Test] public function getExpressionDataWithSubselect(): void {