Skip to content

Commit

Permalink
Merge pull request #235 from nextras/multi-or-with-fqn
Browse files Browse the repository at this point in the history
Extend %multiOr, %and & %or support for passing column as Fqn instance
  • Loading branch information
hrach authored Mar 20, 2024
2 parents a6e16ad + 66b935a commit bf717b4
Show file tree
Hide file tree
Showing 3 changed files with 186 additions and 35 deletions.
56 changes: 40 additions & 16 deletions docs/param-modifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,21 @@ $connection->query('WHERE [roles.privileges] ?| ARRAY[%...s[]]', ['backend', 'fr

Other available modifiers:

| Modifier | Description |
|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `%and` | AND condition |
| `%or` | OR condition |
| `%multiOr` | OR condition with multiple conditions in pairs |
| `%values`, `%values[]` | expands array for INSERT clause, multi insert |
| `%set` | expands array for SET clause |
| `%table`, `%table[]` | escapes string as table name, may contain a database or schema name separated by a dot; surrounding parentheses are not added to `%table[]` modifier; `%table` supports also processing a `Nextras\Dbal\Platforms\Data\Fqn` instance. |
| `%column`, `%column[]` | escapes string as column name, may contain a database name, schema name or asterisk (`*`) separated by a dot; surrounding parentheses are not added to `%column[]` modifier; |
| `%ex` | expands array as processor arguments |
| `%raw` | inserts string argument as is |
| `%%` | escapes to single `%` (useful in `date_format()`, etc.) |
| `[[`, `]]` | escapes to single `[` or `]` (useful when working with array, etc.) |

Let's examine `%and` and `%or` behavior. If array key is numeric and its value is an array, value is expanded with `%ex` modifier. (See below.)
| Modifier | Description |
|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `%and` | AND condition |
| `%or` | OR condition |
| `%multiOr` | OR condition with multiple conditions in pairs |
| `%values`, `%values[]` | expands array for INSERT clause, multi insert |
| `%set` | expands array for SET clause |
| `%table`, `%table[]` | escapes string as table name, may contain a database or schema name separated by a dot; surrounding parentheses are not added to `%table[]` modifier; `%table` supports formatting a `Nextras\Dbal\Platforms\Data\Fqn` instance. |
| `%column`, `%column[]` | escapes string as column name, may contain a database name, schema name or asterisk (`*`) separated by a dot; surrounding parentheses are not added to `%column[]` modifier; `%table` supports formatting a `Nextras\Dbal\Platforms\Data\Fqn` instance. |
| `%ex` | expands array as processor arguments |
| `%raw` | inserts string argument as is |
| `%%` | escapes to single `%` (useful in `date_format()`, etc.) |
| `[[`, `]]` | escapes to single `[` or `]` (useful when working with array, etc.) |

Let's examine `%and` and `%or` behavior. If an array key is numeric and its value is an array, value is expanded with `%ex` modifier. If the first value it this array is an `Fqn` instance, the resulted SQL is constructed similarly to a key-value array, the modifier is an optional string on the second index. (See below.)

```php
$connection->query('%and', [
Expand All @@ -75,9 +75,15 @@ $connection->query('%or', [
['[age] IN %i[]', [23, 25]],
]);
// `city` = 'Winterfell' OR `age` IN (23, 25)

$connection->query('%or', [
[new Fqn(schema: '', name: 'city'), 'Winterfell'],
[new Fqn(schema: '', name: 'age'), [23, 25], '%i[]'],
]);
// `city` = 'Winterfell' OR `age` IN (23, 25)
```

If you want select multiple rows with combined condition for each row, you may use multi-column `IN` expression. However, some databases do not support this feature, therefore Dbal provides universal `%multiOr` modifier that will handle this for you and will use alternative expanded verbose syntax. MultiOr modifier supports optional modifier appended to the column name, set it for all entries. Let's see an example:
If you want to select multiple rows with combined condition for each row, you may use multi-column `IN` expression. However, some databases do not support this feature, therefore, Dbal provides universal `%multiOr` modifier that will handle this for you and will use alternative expanded verbose syntax. MultiOr modifier supports optional modifier appended to the column name; it has to be set for all entries. Let's see an example:

```php
$connection->query('%multiOr', [
Expand All @@ -92,6 +98,24 @@ $connection->query('%multiOr', [
// (tag_id = 1 AND book_id = 23) OR (tag_id = 4 AND book_id = 12) OR (tag_id = 9 AND book_id = 83)
```

Alternatively, if you need to pass the column name as `Fqn` instance, use a data format where the array consists of list columns, then the list of values and optional list of modifiers.

```php
$aFqn = new Fqn('tbl', 'tag_id');
$bFqn = new Fqn('tbl', 'book_id');
$connection->query('%multiOr', [
[[$aFqn, 1, '%i'], [$bFqn, 23]],
[[$aFqn, 4, '%i'], [$bFqn, 12]],
[[$aFqn, 9, '%i'], [$bFqn, 83]],
]);

// MySQL or PostgreSQL
// (tbl.tag_id, tbl.book_id) IN ((1, 23), (4, 12), (9, 83))

// SQL Server
// (tbl.tag_id = 1 AND tbl.book_id = 23) OR (tbl.tag_id = 4 AND tbl.book_id = 12) OR (tbl.tag_id = 9 AND tbl.book_id = 83)
```

Examples of inserting and updating:

```php
Expand Down
112 changes: 94 additions & 18 deletions src/SqlProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public function process(array $args): string
if (!is_string($args[$j])) {
throw new InvalidArgumentException($j === 0
? 'Query fragment must be string.'
: "Redundant query parameter or missing modifier in query fragment '$args[$i]'."
: "Redundant query parameter or missing modifier in query fragment '$args[$i]'.",
);
}

Expand Down Expand Up @@ -530,6 +530,32 @@ private function processValues(array $value): string


/**
* Handles multiple condition formats for AND and OR operators.
*
* Key-based:
* ```
* $connection->query('%or', [
* 'city' => 'Winterfell',
* 'age%i[]' => [23, 25],
* ]);
* ```
*
* Auto-expanding:
* ```
* $connection->query('%or', [
* 'city' => 'Winterfell',
* ['[age] IN %i[]', [23, 25]],
* ]);
* ```
*
* Fqn instsance-based:
* ```
* $connection->query('%or', [
* [new Fqn(schema: '', name: 'city'), 'Winterfell'],
* [new Fqn(schema: '', name: 'age'), [23, 25], '%i[]'],
* ]);
* ```
*
* @param array<int|string, mixed> $value
*/
private function processWhere(string $type, array $value): string
Expand All @@ -546,21 +572,32 @@ private function processWhere(string $type, array $value): string
throw new InvalidArgumentException("Modifier %$type requires items with numeric index to be array, $subValueType given.");
}

$operand = '(' . $this->process($subValue) . ')';
if (count($subValue) > 0 && $subValue[0] instanceof Fqn) {
$column = $this->processModifier('column', $subValue[0]);
$subType = substr($subValue[2] ?? '%any', 1);
if ($subValue[1] === null) {
$op = ' IS ';
} elseif (is_array($subValue[1])) {
$op = ' IN ';
} else {
$op = ' = ';
}
$operand = $column . $op . $this->processModifier($subType, $subValue[1]);
} else {
$operand = '(' . $this->process($subValue) . ')';
}

} else {
$key = explode('%', $_key, 2);
$column = $this->identifierToSql($key[0]);
$subType = $key[1] ?? 'any';

if ($subValue === null) {
$op = ' IS ';
} elseif (is_array($subValue) && $subType !== 'ex') {
$op = ' IN ';
} else {
$op = ' = ';
}

$operand = $column . $op . $this->processModifier($subType, $subValue);
}

Expand All @@ -572,34 +609,73 @@ private function processWhere(string $type, array $value): string


/**
* @param array<string, mixed> $values
* Handles multi-column conditions with multiple paired values.
*
* The implementation considers database support and if not available, delegates to {@see processWhere} and joins
* the resulting SQLs with OR operator.
*
* Key-based:
* ```
* $connection->query('%multiOr', [
* ['tag_id%i' => 1, 'book_id' => 23],
* ['tag_id%i' => 4, 'book_id' => 12],
* ['tag_id%i' => 9, 'book_id' => 83],
* ]);
* ```
*
* Fqn instance-based:
* ```
* $connection->query('%multiOr', [
* [[new Fqn('tbl', 'tag_id'), 1, '%i'], [new Fqn('tbl', 'book_id'), 23]],
* [[new Fqn('tbl', 'tag_id'), 4, '%i'], [new Fqn('tbl', 'book_id'), 12]],
* [[new Fqn('tbl', 'tag_id'), 9, '%i'], [new Fqn('tbl', 'book_id'), 83]],
* ]);
* ```
*
* @param array<string, mixed>|list<list<array{Fqn, mixed, 2?: string}>> $values
*/
private function processMultiColumnOr(array $values): string
{
if ($this->platform->isSupported(IPlatform::SUPPORT_MULTI_COLUMN_IN)) {
if (!$this->platform->isSupported(IPlatform::SUPPORT_MULTI_COLUMN_IN)) {
$sqls = [];
foreach ($values as $value) {
$sqls[] = $this->processWhere('and', $value);
}
return '(' . implode(') OR (', $sqls) . ')';
}

// Detect Fqn instance-based variant
$isFqnBased = ($values[0][0][0] ?? null) instanceof Fqn;
if ($isFqnBased) {
$keys = [];
$modifiers = [];
foreach (array_keys(reset($values)) as $key) {
$exploded = explode('%', (string) $key, 2);
$keys[] = $this->identifierToSql($exploded[0]);
$modifiers[] = $exploded[1] ?? 'any';
foreach ($values[0] as $triple) {
$keys[] = $this->processModifier('column', $triple[0]);
}
foreach ($values as &$subValue) {
$i = 0;
foreach ($subValue as &$subSubValue) {
$subSubValue = $this->processModifier($modifiers[$i++], $subSubValue);
$type = substr($subSubValue[2] ?? '%any', 1);
$subSubValue = $this->processModifier($type, $subSubValue[1]);
}
$subValue = '(' . implode(', ', $subValue) . ')';
}
return '(' . implode(', ', $keys) . ') IN (' . implode(', ', $values) . ')';
}

} else {
$sqls = [];
foreach ($values as $value) {
$sqls[] = $this->processWhere('and', $value);
$keys = [];
$modifiers = [];
foreach (array_keys(reset($values)) as $key) {
$exploded = explode('%', (string) $key, 2);
$keys[] = $this->identifierToSql($exploded[0]);
$modifiers[] = $exploded[1] ?? 'any';
}
foreach ($values as &$subValue) {
$i = 0;
foreach ($subValue as &$subSubValue) {
$subSubValue = $this->processModifier($modifiers[$i++], $subSubValue);
}
return '(' . implode(') OR (', $sqls) . ')';
$subValue = '(' . implode(', ', $subValue) . ')';
}
return '(' . implode(', ', $keys) . ') IN (' . implode(', ', $values) . ')';
}


Expand Down
53 changes: 52 additions & 1 deletion tests/cases/unit/SqlProcessorTest.where.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

use DateTime;
use Mockery;
use Nextras\Dbal\Drivers\IDriver;
use Nextras\Dbal\Exception\InvalidArgumentException;
use Nextras\Dbal\Platforms\Data\Fqn;
use Nextras\Dbal\Platforms\IPlatform;
use Nextras\Dbal\SqlProcessor;
use stdClass;
Expand Down Expand Up @@ -235,6 +235,57 @@ public function testMultiColumnOr()
}


public function testMultiColumnOrWithFqn(): void
{
$this->platform->shouldReceive('formatIdentifier')->with('tbl')->andReturn('tbl');
$this->platform->shouldReceive('formatIdentifier')->once()->with('a')->andReturn('a');
$this->platform->shouldReceive('formatIdentifier')->once()->with('b')->andReturn('b');
$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(true);

$aFqn = new Fqn('tbl', 'a');
$bFqn = new Fqn('tbl', 'b');
Assert::same(
'(tbl.a, tbl.b) IN ((1, 2), (2, 3), (3, 4))',
$this->parser->processModifier('multiOr', [
[[$aFqn, 1], [$bFqn, 2]],
[[$aFqn, 2], [$bFqn, 3]],
[[$aFqn, 3], [$bFqn, 4]],
])
);

$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(false);

Assert::same(
'(tbl.a = 1 AND tbl.b = 2) OR (tbl.a = 2 AND tbl.b = 3) OR (tbl.a = 3 AND tbl.b = 4)',
$this->parser->processModifier('multiOr', [
[[$aFqn, 1], [$bFqn, 2]],
[[$aFqn, 2], [$bFqn, 3]],
[[$aFqn, 3], [$bFqn, 4]],
])
);

$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(true);

Assert::throws(function () use ($aFqn, $bFqn) {
$this->parser->processModifier('multiOr', [
[[$aFqn, 1, '%i'], [$bFqn, 2]],
[[$aFqn, 'a', '%i'], [$bFqn, 2]],
[[$aFqn, 3, '%i'], [$bFqn, 4]],
]);
}, InvalidArgumentException::class, 'Modifier %i expects value to be int, string given.');

$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(false);

Assert::throws(function () use ($aFqn, $bFqn) {
$this->parser->processModifier('multiOr', [
[[$aFqn, 1, '%i'], [$bFqn, 2]],
[[$aFqn, 'a', '%i'], [$bFqn, 2]],
[[$aFqn, 3, '%i'], [$bFqn, 4]],
]);
}, InvalidArgumentException::class, 'Modifier %i expects value to be int, string given.');
}


/**
* @dataProvider provideInvalidData
*/
Expand Down

0 comments on commit bf717b4

Please sign in to comment.