diff --git a/docs/param-modifiers.md b/docs/param-modifiers.md index d0edeed..e4d16a1 100644 --- a/docs/param-modifiers.md +++ b/docs/param-modifiers.md @@ -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', [ @@ -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', [ @@ -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 diff --git a/src/SqlProcessor.php b/src/SqlProcessor.php index 71141f3..a915014 100644 --- a/src/SqlProcessor.php +++ b/src/SqlProcessor.php @@ -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]'.", ); } @@ -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 $value */ private function processWhere(string $type, array $value): string @@ -546,13 +572,25 @@ 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') { @@ -560,7 +598,6 @@ private function processWhere(string $type, array $value): string } else { $op = ' = '; } - $operand = $column . $op . $this->processModifier($subType, $subValue); } @@ -572,34 +609,73 @@ private function processWhere(string $type, array $value): string /** - * @param array $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|list> $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) . ')'; } diff --git a/tests/cases/unit/SqlProcessorTest.where.php b/tests/cases/unit/SqlProcessorTest.where.php index a1858a9..6581778 100644 --- a/tests/cases/unit/SqlProcessorTest.where.php +++ b/tests/cases/unit/SqlProcessorTest.where.php @@ -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; @@ -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 */