From ea5dbb952052067950434eda9925c98da5509df4 Mon Sep 17 00:00:00 2001 From: Simon Mundy Date: Tue, 13 Oct 2026 12:36:57 +1100 Subject: [PATCH] Render every value of an In list through a custom specification A custom In/NotIn specification refers to the value set with one placeholder, but the value set renders one value per list item. With more than one value the extra values were dropped from the SQL while still being bound, leaving orphan parameters in prepared statements. Custom specifications are now rewritten so each placeholder covers every value its argument renders (positional placeholders, an empty list as NULL), and a specification that repeats or omits an argument that binds values, or names a missing argument, throws a Predicate InvalidArgumentException. --- docs/book/sql/where-having.md | 25 +++ src/Sql/Predicate/CustomSpecification.php | 125 ++++++++++++++ .../Exception/InvalidArgumentException.php | 25 +++ src/Sql/Predicate/In.php | 13 +- .../Sql/Predicate/CustomSpecificationTest.php | 158 ++++++++++++++++++ .../InvalidArgumentExceptionTest.php | 34 ++-- test/unit/Sql/Predicate/InTest.php | 120 +++++++++++++ test/unit/Sql/Predicate/NotInTest.php | 27 +++ 8 files changed, 514 insertions(+), 13 deletions(-) create mode 100644 src/Sql/Predicate/CustomSpecification.php create mode 100644 test/unit/Sql/Predicate/CustomSpecificationTest.php 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 {