Skip to content

Commit

Permalink
extend %multiOr, %and & %or support for passing column Fqn instance
Browse files Browse the repository at this point in the history
  • Loading branch information
hrach committed Mar 20, 2024
1 parent f8a1ddb commit 66b935a
Show file tree
Hide file tree
Showing 3 changed files with 172 additions and 21 deletions.
28 changes: 26 additions & 2 deletions docs/param-modifiers.md
Expand Up @@ -53,7 +53,7 @@ Other available modifiers:
| `%%` | 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.)
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
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
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 66b935a

Please sign in to comment.