Skip to content

Commit bf717b4

Browse files
authored
Merge pull request #235 from nextras/multi-or-with-fqn
Extend %multiOr, %and & %or support for passing column as Fqn instance
2 parents a6e16ad + 66b935a commit bf717b4

File tree

3 files changed

+186
-35
lines changed

3 files changed

+186
-35
lines changed

docs/param-modifiers.md

+40-16
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,21 @@ $connection->query('WHERE [roles.privileges] ?| ARRAY[%...s[]]', ['backend', 'fr
3939

4040
Other available modifiers:
4141

42-
| Modifier | Description |
43-
|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
44-
| `%and` | AND condition |
45-
| `%or` | OR condition |
46-
| `%multiOr` | OR condition with multiple conditions in pairs |
47-
| `%values`, `%values[]` | expands array for INSERT clause, multi insert |
48-
| `%set` | expands array for SET clause |
49-
| `%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. |
50-
| `%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; |
51-
| `%ex` | expands array as processor arguments |
52-
| `%raw` | inserts string argument as is |
53-
| `%%` | escapes to single `%` (useful in `date_format()`, etc.) |
54-
| `[[`, `]]` | escapes to single `[` or `]` (useful when working with array, etc.) |
55-
56-
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.)
42+
| Modifier | Description |
43+
|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
44+
| `%and` | AND condition |
45+
| `%or` | OR condition |
46+
| `%multiOr` | OR condition with multiple conditions in pairs |
47+
| `%values`, `%values[]` | expands array for INSERT clause, multi insert |
48+
| `%set` | expands array for SET clause |
49+
| `%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. |
50+
| `%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. |
51+
| `%ex` | expands array as processor arguments |
52+
| `%raw` | inserts string argument as is |
53+
| `%%` | escapes to single `%` (useful in `date_format()`, etc.) |
54+
| `[[`, `]]` | escapes to single `[` or `]` (useful when working with array, etc.) |
55+
56+
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.)
5757

5858
```php
5959
$connection->query('%and', [
@@ -75,9 +75,15 @@ $connection->query('%or', [
7575
['[age] IN %i[]', [23, 25]],
7676
]);
7777
// `city` = 'Winterfell' OR `age` IN (23, 25)
78+
79+
$connection->query('%or', [
80+
[new Fqn(schema: '', name: 'city'), 'Winterfell'],
81+
[new Fqn(schema: '', name: 'age'), [23, 25], '%i[]'],
82+
]);
83+
// `city` = 'Winterfell' OR `age` IN (23, 25)
7884
```
7985

80-
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:
86+
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:
8187

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

101+
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.
102+
103+
```php
104+
$aFqn = new Fqn('tbl', 'tag_id');
105+
$bFqn = new Fqn('tbl', 'book_id');
106+
$connection->query('%multiOr', [
107+
[[$aFqn, 1, '%i'], [$bFqn, 23]],
108+
[[$aFqn, 4, '%i'], [$bFqn, 12]],
109+
[[$aFqn, 9, '%i'], [$bFqn, 83]],
110+
]);
111+
112+
// MySQL or PostgreSQL
113+
// (tbl.tag_id, tbl.book_id) IN ((1, 23), (4, 12), (9, 83))
114+
115+
// SQL Server
116+
// (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)
117+
```
118+
95119
Examples of inserting and updating:
96120

97121
```php

src/SqlProcessor.php

+94-18
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public function process(array $args): string
114114
if (!is_string($args[$j])) {
115115
throw new InvalidArgumentException($j === 0
116116
? 'Query fragment must be string.'
117-
: "Redundant query parameter or missing modifier in query fragment '$args[$i]'."
117+
: "Redundant query parameter or missing modifier in query fragment '$args[$i]'.",
118118
);
119119
}
120120

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

531531

532532
/**
533+
* Handles multiple condition formats for AND and OR operators.
534+
*
535+
* Key-based:
536+
* ```
537+
* $connection->query('%or', [
538+
* 'city' => 'Winterfell',
539+
* 'age%i[]' => [23, 25],
540+
* ]);
541+
* ```
542+
*
543+
* Auto-expanding:
544+
* ```
545+
* $connection->query('%or', [
546+
* 'city' => 'Winterfell',
547+
* ['[age] IN %i[]', [23, 25]],
548+
* ]);
549+
* ```
550+
*
551+
* Fqn instsance-based:
552+
* ```
553+
* $connection->query('%or', [
554+
* [new Fqn(schema: '', name: 'city'), 'Winterfell'],
555+
* [new Fqn(schema: '', name: 'age'), [23, 25], '%i[]'],
556+
* ]);
557+
* ```
558+
*
533559
* @param array<int|string, mixed> $value
534560
*/
535561
private function processWhere(string $type, array $value): string
@@ -546,21 +572,32 @@ private function processWhere(string $type, array $value): string
546572
throw new InvalidArgumentException("Modifier %$type requires items with numeric index to be array, $subValueType given.");
547573
}
548574

549-
$operand = '(' . $this->process($subValue) . ')';
575+
if (count($subValue) > 0 && $subValue[0] instanceof Fqn) {
576+
$column = $this->processModifier('column', $subValue[0]);
577+
$subType = substr($subValue[2] ?? '%any', 1);
578+
if ($subValue[1] === null) {
579+
$op = ' IS ';
580+
} elseif (is_array($subValue[1])) {
581+
$op = ' IN ';
582+
} else {
583+
$op = ' = ';
584+
}
585+
$operand = $column . $op . $this->processModifier($subType, $subValue[1]);
586+
} else {
587+
$operand = '(' . $this->process($subValue) . ')';
588+
}
550589

551590
} else {
552591
$key = explode('%', $_key, 2);
553592
$column = $this->identifierToSql($key[0]);
554593
$subType = $key[1] ?? 'any';
555-
556594
if ($subValue === null) {
557595
$op = ' IS ';
558596
} elseif (is_array($subValue) && $subType !== 'ex') {
559597
$op = ' IN ';
560598
} else {
561599
$op = ' = ';
562600
}
563-
564601
$operand = $column . $op . $this->processModifier($subType, $subValue);
565602
}
566603

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

573610

574611
/**
575-
* @param array<string, mixed> $values
612+
* Handles multi-column conditions with multiple paired values.
613+
*
614+
* The implementation considers database support and if not available, delegates to {@see processWhere} and joins
615+
* the resulting SQLs with OR operator.
616+
*
617+
* Key-based:
618+
* ```
619+
* $connection->query('%multiOr', [
620+
* ['tag_id%i' => 1, 'book_id' => 23],
621+
* ['tag_id%i' => 4, 'book_id' => 12],
622+
* ['tag_id%i' => 9, 'book_id' => 83],
623+
* ]);
624+
* ```
625+
*
626+
* Fqn instance-based:
627+
* ```
628+
* $connection->query('%multiOr', [
629+
* [[new Fqn('tbl', 'tag_id'), 1, '%i'], [new Fqn('tbl', 'book_id'), 23]],
630+
* [[new Fqn('tbl', 'tag_id'), 4, '%i'], [new Fqn('tbl', 'book_id'), 12]],
631+
* [[new Fqn('tbl', 'tag_id'), 9, '%i'], [new Fqn('tbl', 'book_id'), 83]],
632+
* ]);
633+
* ```
634+
*
635+
* @param array<string, mixed>|list<list<array{Fqn, mixed, 2?: string}>> $values
576636
*/
577637
private function processMultiColumnOr(array $values): string
578638
{
579-
if ($this->platform->isSupported(IPlatform::SUPPORT_MULTI_COLUMN_IN)) {
639+
if (!$this->platform->isSupported(IPlatform::SUPPORT_MULTI_COLUMN_IN)) {
640+
$sqls = [];
641+
foreach ($values as $value) {
642+
$sqls[] = $this->processWhere('and', $value);
643+
}
644+
return '(' . implode(') OR (', $sqls) . ')';
645+
}
646+
647+
// Detect Fqn instance-based variant
648+
$isFqnBased = ($values[0][0][0] ?? null) instanceof Fqn;
649+
if ($isFqnBased) {
580650
$keys = [];
581-
$modifiers = [];
582-
foreach (array_keys(reset($values)) as $key) {
583-
$exploded = explode('%', (string) $key, 2);
584-
$keys[] = $this->identifierToSql($exploded[0]);
585-
$modifiers[] = $exploded[1] ?? 'any';
651+
foreach ($values[0] as $triple) {
652+
$keys[] = $this->processModifier('column', $triple[0]);
586653
}
587654
foreach ($values as &$subValue) {
588-
$i = 0;
589655
foreach ($subValue as &$subSubValue) {
590-
$subSubValue = $this->processModifier($modifiers[$i++], $subSubValue);
656+
$type = substr($subSubValue[2] ?? '%any', 1);
657+
$subSubValue = $this->processModifier($type, $subSubValue[1]);
591658
}
592659
$subValue = '(' . implode(', ', $subValue) . ')';
593660
}
594661
return '(' . implode(', ', $keys) . ') IN (' . implode(', ', $values) . ')';
662+
}
595663

596-
} else {
597-
$sqls = [];
598-
foreach ($values as $value) {
599-
$sqls[] = $this->processWhere('and', $value);
664+
$keys = [];
665+
$modifiers = [];
666+
foreach (array_keys(reset($values)) as $key) {
667+
$exploded = explode('%', (string) $key, 2);
668+
$keys[] = $this->identifierToSql($exploded[0]);
669+
$modifiers[] = $exploded[1] ?? 'any';
670+
}
671+
foreach ($values as &$subValue) {
672+
$i = 0;
673+
foreach ($subValue as &$subSubValue) {
674+
$subSubValue = $this->processModifier($modifiers[$i++], $subSubValue);
600675
}
601-
return '(' . implode(') OR (', $sqls) . ')';
676+
$subValue = '(' . implode(', ', $subValue) . ')';
602677
}
678+
return '(' . implode(', ', $keys) . ') IN (' . implode(', ', $values) . ')';
603679
}
604680

605681

tests/cases/unit/SqlProcessorTest.where.php

+52-1
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
use DateTime;
88
use Mockery;
9-
use Nextras\Dbal\Drivers\IDriver;
109
use Nextras\Dbal\Exception\InvalidArgumentException;
10+
use Nextras\Dbal\Platforms\Data\Fqn;
1111
use Nextras\Dbal\Platforms\IPlatform;
1212
use Nextras\Dbal\SqlProcessor;
1313
use stdClass;
@@ -235,6 +235,57 @@ public function testMultiColumnOr()
235235
}
236236

237237

238+
public function testMultiColumnOrWithFqn(): void
239+
{
240+
$this->platform->shouldReceive('formatIdentifier')->with('tbl')->andReturn('tbl');
241+
$this->platform->shouldReceive('formatIdentifier')->once()->with('a')->andReturn('a');
242+
$this->platform->shouldReceive('formatIdentifier')->once()->with('b')->andReturn('b');
243+
$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(true);
244+
245+
$aFqn = new Fqn('tbl', 'a');
246+
$bFqn = new Fqn('tbl', 'b');
247+
Assert::same(
248+
'(tbl.a, tbl.b) IN ((1, 2), (2, 3), (3, 4))',
249+
$this->parser->processModifier('multiOr', [
250+
[[$aFqn, 1], [$bFqn, 2]],
251+
[[$aFqn, 2], [$bFqn, 3]],
252+
[[$aFqn, 3], [$bFqn, 4]],
253+
])
254+
);
255+
256+
$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(false);
257+
258+
Assert::same(
259+
'(tbl.a = 1 AND tbl.b = 2) OR (tbl.a = 2 AND tbl.b = 3) OR (tbl.a = 3 AND tbl.b = 4)',
260+
$this->parser->processModifier('multiOr', [
261+
[[$aFqn, 1], [$bFqn, 2]],
262+
[[$aFqn, 2], [$bFqn, 3]],
263+
[[$aFqn, 3], [$bFqn, 4]],
264+
])
265+
);
266+
267+
$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(true);
268+
269+
Assert::throws(function () use ($aFqn, $bFqn) {
270+
$this->parser->processModifier('multiOr', [
271+
[[$aFqn, 1, '%i'], [$bFqn, 2]],
272+
[[$aFqn, 'a', '%i'], [$bFqn, 2]],
273+
[[$aFqn, 3, '%i'], [$bFqn, 4]],
274+
]);
275+
}, InvalidArgumentException::class, 'Modifier %i expects value to be int, string given.');
276+
277+
$this->platform->shouldReceive('isSupported')->once()->with(IPlatform::SUPPORT_MULTI_COLUMN_IN)->andReturn(false);
278+
279+
Assert::throws(function () use ($aFqn, $bFqn) {
280+
$this->parser->processModifier('multiOr', [
281+
[[$aFqn, 1, '%i'], [$bFqn, 2]],
282+
[[$aFqn, 'a', '%i'], [$bFqn, 2]],
283+
[[$aFqn, 3, '%i'], [$bFqn, 4]],
284+
]);
285+
}, InvalidArgumentException::class, 'Modifier %i expects value to be int, string given.');
286+
}
287+
288+
238289
/**
239290
* @dataProvider provideInvalidData
240291
*/

0 commit comments

Comments
 (0)