diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bad5d327..e149923a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,7 @@ - New #984: Add `createQuery()` and `select()` methods to `ConnectionInterface` (@Tigrov) - Chg #985: Rename `insertWithReturningPks()` to `insertReturningPks()` in `CommandInterface` and `DMLQueryBuilderInterface` (@Tigrov) - Enh #992: Add optional type casting to `DataReaderInterface` using columns (@Tigrov) -- New #988, #1053: Add `CaseExpression` and `CaseExpressionBuilder` to build `CASE-WHEN-THEN-ELSE` SQL expressions (@Tigrov) +- New #988, #1053, #1067: Add `CaseX` and `CaseXBuilder` to build `CASE-WHEN-THEN-ELSE` SQL expressions (@Tigrov) - Enh #991: Improve types in `ConnectionInterface::transaction()` (@kikara) - Chg #998: Add `yiisoft/db-implementation` virtual package as dependency (@vjik) - Chg #999: Remove `requireTransaction()` method and `$isolationLevel` property from `AbstractCommand` (@vjik) diff --git a/docs/guide/en/expressions/statements.md b/docs/guide/en/expressions/statements.md index d55f5924f..cd5728874 100644 --- a/docs/guide/en/expressions/statements.md +++ b/docs/guide/en/expressions/statements.md @@ -1,11 +1,6 @@ # Statement Expressions The library provides classes to represent SQL statements as expressions. - -> [!WARNING] -> The statements do not quote string values or column names, use [Value](../../../../src/Expression/Value/Value.php) -> object for string values and [ColumnName](../../../../src/Expression/Value/ColumnName.php) object for column names -> or quote the values directly. The following expression classes are available: @@ -15,11 +10,35 @@ The following expression classes are available: The [CaseX](../../../../src/Expression/Statement/CaseX.php) expression allows you to create SQL `CASE` statements. +The `CaseX` class accepts the following arguments: + +- `value` comparison condition in the `CASE` expression: + - `string` is treated as a table column name which will be quoted before usage in the SQL statement; + - `array` is treated as a condition to check, see `QueryInterface::where()`; + - other values will be converted to their string representation using `QueryBuilderInterface::buildValue()`. + If not provided, the `CASE` expression will be a WHEN-THEN structure without a specific case value. +- `valueType` optional data type of the `CASE` expression which can be used in some DBMS to specify the expected type; +- `...args` List of `WHEN-THEN` conditions and their corresponding results represented + as [WhenThen](https://github.com/yiisoft/db/blob/master/src/Expression/Statement/WhenThen.php) instances + or `ELSE` value in the `CASE` expression. String `ELSE` value will be quoted before usage in the SQL statement. + +For example: + ```php $case = new CaseX( - new Column('status'), - when1: new Wnen(new Value('active'), new Value('Active User')), - when2: new Wnen("'inactive'", "'Inactive User'"), - else: new Value('Unknown Status'), + 'status', + when1: new WnenThen('active', 'Active User'), + when2: new WnenThen('inactive', 'Inactive User'), + else: 'Unknown Status', ); ``` + +This will generate the following SQL: + +```sql +CASE "status" + WHEN 'active' THEN 'Active User' + WHEN 'inactive' THEN 'Inactive User' + ELSE 'Unknown Status' +END +``` diff --git a/src/Expression/Statement/Builder/CaseXBuilder.php b/src/Expression/Statement/Builder/CaseXBuilder.php index 191735f9e..2c5ee642a 100644 --- a/src/Expression/Statement/Builder/CaseXBuilder.php +++ b/src/Expression/Statement/Builder/CaseXBuilder.php @@ -11,7 +11,7 @@ use Yiisoft\Db\QueryBuilder\QueryBuilderInterface; use function gettype; -use function is_string; +use function is_array; /** * Builds expressions for {@see CaseX}. @@ -37,50 +37,50 @@ public function build(ExpressionInterface $expression, array &$params = []): str $sql = 'CASE'; if ($expression->value !== null) { - $sql .= ' ' . $this->buildCondition($expression->value, $params); + $sql .= ' ' . $this->buildCaseValue($expression->value, $params); } - foreach ($expression->when as $when) { - $sql .= ' WHEN ' . $this->buildCondition($when->condition, $params); - $sql .= ' THEN ' . $this->buildResult($when->result, $params); + foreach ($expression->whenThen as $whenThen) { + $sql .= ' WHEN ' . $this->buildCondition($whenThen->when, $params); + $sql .= ' THEN ' . $this->queryBuilder->buildValue($whenThen->then, $params); } if ($expression->hasElse()) { - $sql .= ' ELSE ' . $this->buildResult($expression->else, $params); + $sql .= ' ELSE ' . $this->queryBuilder->buildValue($expression->else, $params); } return $sql . ' END'; } /** - * Builds the condition part of the CASE expression based on their type. + * Builds the case value part of the CASE expression based on their type. * * @return string The SQL condition string. */ - protected function buildCondition(mixed $condition, array &$params): string + protected function buildCaseValue(mixed $value, array &$params): string { /** * @var string * @psalm-suppress MixedArgument */ - return match (gettype($condition)) { - GettypeResult::ARRAY => $this->queryBuilder->buildCondition($condition, $params), - GettypeResult::STRING => $condition, - default => $this->queryBuilder->buildValue($condition, $params), + return match (gettype($value)) { + GettypeResult::ARRAY => $this->queryBuilder->buildCondition($value, $params), + GettypeResult::STRING => $this->queryBuilder->getQuoter()->quoteColumnName($value), + default => $this->queryBuilder->buildValue($value, $params), }; } /** - * Builds the result part of the `CASE` expression based on its type. + * Builds the condition part of the CASE expression based on their type. * - * @return string The SQL result string. + * @return string The SQL condition string. */ - protected function buildResult(mixed $result, array &$params): string + protected function buildCondition(mixed $condition, array &$params): string { - if (is_string($result)) { - return $result; + if (is_array($condition)) { + return $this->queryBuilder->buildCondition($condition, $params); } - return $this->queryBuilder->buildValue($result, $params); + return $this->queryBuilder->buildValue($condition, $params); } } diff --git a/src/Expression/Statement/CaseX.php b/src/Expression/Statement/CaseX.php index 55cf4ddfa..982e0d878 100644 --- a/src/Expression/Statement/CaseX.php +++ b/src/Expression/Statement/CaseX.php @@ -22,9 +22,9 @@ * * ```php * $case = new CaseX( - * when1: new When('condition1', 'result1'), - * when2: new When('condition2', 'result2'), - * else: 'defaultResult', + * when1: new WhenThen(true, 'result1'), + * when2: new WhenThen(false, 'result2'), + * else: 'default result', * ); * ``` * @@ -32,9 +32,9 @@ * * ```sql * CASE - * WHEN condition1 THEN result1 - * WHEN condition2 THEN result2 - * ELSE defaultResult + * WHEN TRUE THEN 'result1' + * WHEN FALSE THEN 'result2' + * ELSE 'default result' * END * ``` * @@ -42,29 +42,29 @@ * * ```php * $case = new CaseX( - * 'expression', - * when1: new When(1, 'result1'), - * when2: new When(2, 'result2'), - * else: 'defaultResult', + * 'column_name', + * when1: new WhenThen('one', 'result1'), + * when2: new WhenThen('two', 'result2'), + * else: 'default result', * ); * ``` * * This will be generated into a SQL `CASE` expression like: * * ```sql - * CASE expression - * WHEN 1 THEN result1 - * WHEN 2 THEN result2 - * ELSE defaultResult + * CASE "column_name" + * WHEN 'one' THEN 'result1' + * WHEN 'two' THEN 'result2' + * ELSE 'default result' * END * ``` */ final class CaseX implements ExpressionInterface { /** - * @var When[] List of `WHEN` conditions and their corresponding results in the `CASE` expression. + * @var WhenThen[] List of `WHEN-THEN` conditions and their corresponding results in the `CASE` expression. */ - public readonly array $when; + public readonly array $whenThen; /** * @var mixed The result to return if no conditions match in the CASE expression. * If not set, the `CASE` expression will not have an `ELSE` clause. @@ -75,25 +75,26 @@ final class CaseX implements ExpressionInterface /** * @param mixed $value Comparison condition in the `CASE` expression: - * - `string` is treated as a SQL expression; + * - `string` is treated as a table column name which will be quoted before usage in the SQL statement; * - `array` is treated as a condition to check, see {@see QueryInterface::where()}; * - other values will be converted to their string representation using {@see QueryBuilderInterface::buildValue()}. - * If not provided, the `CASE` expression will be a WHEN-THEN structure without a specific case value. - * @param ColumnInterface|string $valueType Optional data type of the CASE expression which can be used in some DBMS - * to specify the expected type (for example in PostgreSQL). - * @param mixed|When ...$args List of `WHEN` conditions and their corresponding results represented - * as {@see When} instances or `ELSE` value in the `CASE` expression. + * If not provided, the `CASE` expression will be a `WHEN-THEN` structure without a specific case value. + * @param ColumnInterface|string $valueType Optional data type of the `CASE` expression which can be used + * in some DBMS to specify the expected type (for example, in PostgreSQL). + * @param mixed|WhenThen ...$args List of `WHEN-THEN` conditions and their corresponding results represented + * as {@see WhenThen} instances or `ELSE` value in the `CASE` expression. String `ELSE` value will be quoted + * before usage in the SQL statement. */ public function __construct( public readonly mixed $value = null, public readonly string|ColumnInterface $valueType = '', mixed ...$args, ) { - $when = []; + $whenThen = []; foreach ($args as $arg) { - if ($arg instanceof When) { - $when[] = $arg; + if ($arg instanceof WhenThen) { + $whenThen[] = $arg; } elseif ($this->hasElse()) { throw new InvalidArgumentException('`CASE` expression can have only one `ELSE` value.'); } else { @@ -101,11 +102,11 @@ public function __construct( } } - if (empty($when)) { - throw new InvalidArgumentException('`CASE` expression must have at least one `WHEN` clause.'); + if (empty($whenThen)) { + throw new InvalidArgumentException('`CASE` expression must have at least one `WHEN-THEN` clause.'); } - $this->when = $when; + $this->whenThen = $whenThen; } /** diff --git a/src/Expression/Statement/When.php b/src/Expression/Statement/When.php deleted file mode 100644 index 314319fb1..000000000 --- a/src/Expression/Statement/When.php +++ /dev/null @@ -1,31 +0,0 @@ - [null], - 'string' => ['field = 1'], + 'string' => ['field'], 'expression' => [new Expression('field = 1')], 'boolean' => [true], 'float' => [2.3], @@ -36,22 +36,22 @@ public static function dataValues(): array #[DataProvider('dataValues')] public function testConstruct(mixed $value): void { - $case = new CaseX($value, when: $when = new When(1, 2)); + $case = new CaseX($value, when: $whenThen = new WhenThen(1, 2)); $this->assertSame($value, $case->value); $this->assertSame('', $case->valueType); - $this->assertSame([$when], $case->when); + $this->assertSame([$whenThen], $case->whenThen); } public function testConstructType(): void { - $case = new CaseX(valueType: 'int', when: new When(1, 2)); + $case = new CaseX(valueType: 'int', when: new WhenThen(1, 2)); $this->assertNull($case->value); $this->assertSame('int', $case->valueType); $intCol = new IntegerColumn(); - $case = new CaseX(valueType: $intCol, when: new When(1, 2)); + $case = new CaseX(valueType: $intCol, when: new WhenThen(1, 2)); $this->assertNull($case->value); $this->assertSame($intCol, $case->valueType); } @@ -59,43 +59,43 @@ public function testConstructType(): void public function testConstructorWhen() { // Test with one when clauses - $when = new When('field = 1', 'result1'); - $case = new CaseX(when: $when); + $whenThen = new WhenThen('value', 'result1'); + $case = new CaseX(when: $whenThen); $this->assertNull($case->value); $this->assertSame('', $case->valueType); - $this->assertSame([$when], $case->when); + $this->assertSame([$whenThen], $case->whenThen); // Test with multiple when clauses - $when = [ - 'when0' => new When('field = 1', 'result1'), - 'when1' => new When('field = 2', 'result2'), + $whenThen = [ + 'when0' => new WhenThen('value1', 'result1'), + 'when1' => new WhenThen('value2', 'result2'), ]; - $case = new CaseX(...$when); + $case = new CaseX(...$whenThen); $this->assertNull($case->value); $this->assertSame('', $case->valueType); - $this->assertSame(array_values($when), $case->when); + $this->assertSame(array_values($whenThen), $case->whenThen); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('`CASE` expression must have at least one `WHEN` clause.'); + $this->expectExceptionMessage('`CASE` expression must have at least one `WHEN-THEN` clause.'); new CaseX(); } public function testElse(): void { - $case = new CaseX(when: new When(1, 2)); + $case = new CaseX(when: new WhenThen(1, 2)); $this->assertFalse($case->hasElse()); $this->assertFalse(isset($case->else)); - $case = new CaseX(when: new When(1, 2), else: null); + $case = new CaseX(when: new WhenThen(1, 2), else: null); $this->assertTrue($case->hasElse()); $this->assertNull($case->else); - $case = new CaseX(when: new When(1, 2), else: 'result'); + $case = new CaseX(when: new WhenThen(1, 2), else: 'result'); $this->assertTrue($case->hasElse()); $this->assertSame('result', $case->else); @@ -103,14 +103,14 @@ public function testElse(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('`CASE` expression can have only one `ELSE` value.'); - new CaseX(when: new When(1, 2), else1: 'result1', else2: 'result2'); + new CaseX(when: new WhenThen(1, 2), else1: 'result1', else2: 'result2'); } public function testWhen(): void { - $when = new When('field = 1', 'result1'); + $when = new WhenThen('value', 'result1'); - $this->assertSame('field = 1', $when->condition); - $this->assertSame('result1', $when->result); + $this->assertSame('value', $when->when); + $this->assertSame('result1', $when->then); } } diff --git a/tests/Provider/QueryBuilderProvider.php b/tests/Provider/QueryBuilderProvider.php index 6c4a3e23a..8e0dab243 100644 --- a/tests/Provider/QueryBuilderProvider.php +++ b/tests/Provider/QueryBuilderProvider.php @@ -8,7 +8,7 @@ use DateTimeImmutable; use DateTimeZone; use Yiisoft\Db\Constant\DataType; -use Yiisoft\Db\Expression\Statement\When; +use Yiisoft\Db\Expression\Statement\WhenThen; use Yiisoft\Db\Expression\Value\ArrayValue; use Yiisoft\Db\Expression\Function\ArrayMerge; use Yiisoft\Db\Expression\Value\Param; @@ -30,6 +30,7 @@ use Yiisoft\Db\Query\Query; use Yiisoft\Db\QueryBuilder\Condition\All; use Yiisoft\Db\QueryBuilder\Condition\Between; +use Yiisoft\Db\QueryBuilder\Condition\Equals; use Yiisoft\Db\QueryBuilder\Condition\In; use Yiisoft\Db\QueryBuilder\Condition\Like; use Yiisoft\Db\QueryBuilder\Condition\LikeConjunction; @@ -2015,21 +2016,34 @@ public static function caseXBuilder(): array return [ 'with case expression' => [ new CaseX( - '(1 + 2)', - when1: new When(1, 1), - when2: new When(2, new Expression('2')), - when3: new When(3, '(2 + 1)'), + 'column_name', + when1: new WhenThen(1, 1), + when2: new WhenThen(2, new Expression('(1 + 1)')), + when3: new WhenThen(3, '3'), else: $param = new Param(4, DataType::INTEGER), ), - 'CASE (1 + 2) WHEN 1 THEN 1 WHEN 2 THEN 2 WHEN 3 THEN (2 + 1) ELSE :qp0 END', - [':qp0' => $param], - 3, + static::replaceQuotes('CASE [[column_name]] WHEN 1 THEN 1 WHEN 2 THEN (1 + 1) WHEN 3 THEN :qp0 ELSE :qp1 END'), + [ + ':qp0' => new Param('3', DataType::STRING), + ':qp1' => $param, + ], + 2, + ], + 'with case condition' => [ + new CaseX( + ['=', 'column_name', 1], + when1: new WhenThen(true, 1), + else: 2, + ), + static::replaceQuotes('CASE [[column_name]] = 1 WHEN TRUE THEN 1 ELSE 2 END'), + [], + 2, ], 'without case expression' => [ new CaseX( - when1: new When(['=', 'column_name', 1], new Value('a')), - when2: new When( - static::replaceQuotes('[[column_name]] = 2'), + when1: new WhenThen(['=', 'column_name', 1], 'a'), + when2: new WhenThen( + new Equals('column_name', 2), (new Query(self::getDb()))->select($param = new Param('b', DataType::STRING)) ), ),