Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend %multiOr, %and & %or support for passing column as Fqn instance #235

Merged
merged 2 commits into from
Mar 20, 2024
Merged
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
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
Loading