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
25 changes: 25 additions & 0 deletions docs/book/sql/where-having.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
125 changes: 125 additions & 0 deletions src/Sql/Predicate/CustomSpecification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

declare(strict_types=1);

namespace PhpDb\Sql\Predicate;

use PhpDb\Sql\Argument\Values;
use PhpDb\Sql\ArgumentInterface;
use PhpDb\Sql\ArgumentType;
use PhpDb\Sql\Predicate\Exception\InvalidArgumentException;

use function array_count_values;
use function array_map;
use function count;
use function implode;
use function in_array;
use function preg_split;
use function range;
use function substr;

use const PREG_SPLIT_DELIM_CAPTURE;

/**
* Aligns a user-supplied predicate specification with the values its arguments render
*
* A custom specification refers to each argument with a single placeholder, plain (%s) or
* positional (%2$s), but a Values argument renders one value per list item. Each placeholder
* is replaced by positional placeholders for every value its argument renders, so %2$s becomes
* %2$s, %3$s for a two-value list and NULL for an empty one.
*
* @internal
*/
final class CustomSpecification
{
/**
* @param list<ArgumentInterface> $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<string> $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<ArgumentInterface> $arguments
* @return array<int, int>
*/
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),
));
}
}
25 changes: 25 additions & 0 deletions src/Sql/Predicate/Exception/InvalidArgumentException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
}
13 changes: 11 additions & 2 deletions src/Sql/Predicate/In.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}

Expand Down
158 changes: 158 additions & 0 deletions test/unit/Sql/Predicate/CustomSpecificationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

declare(strict_types=1);

namespace PhpDbTest\Sql\Predicate;

use PhpDb\Sql\Argument;
use PhpDb\Sql\ArgumentInterface;
use PhpDb\Sql\Predicate\CustomSpecification;
use PhpDb\Sql\Predicate\Exception\InvalidArgumentException;
use PhpDb\Sql\Select;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

use function sprintf;

#[CoversClass(CustomSpecification::class)]
#[Group('unit')]
final class CustomSpecificationTest extends TestCase
{
/** @return array<string, array{string, list<ArgumentInterface>, 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, array{string, list<ArgumentInterface>, 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<ArgumentInterface> $arguments */
#[Test]
#[DataProvider('expansionProvider')]
public function eachPlaceholderCoversEveryValueItsArgumentRenders(
string $specification,
array $arguments,
string $expected,
): void {
static::assertSame($expected, CustomSpecification::expand($specification, $arguments));
}

/** @param list<ArgumentInterface> $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])]),
);
}
}
Loading
Loading