diff --git a/CHANGELOG.md b/CHANGELOG.md index e28b7b4f8..d67b12b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,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: Add `CaseExpression` and `CaseExpressionBuilder` to build `CASE-WHEN-THEN-ELSE` SQL expressions (@Tigrov) - Enh #991: Improve types in `ConnectionInterface::transaction()` (@kikara) ## 1.3.0 March 21, 2024 diff --git a/rector.php b/rector.php index ffdfc6c47..c38726efa 100644 --- a/rector.php +++ b/rector.php @@ -4,6 +4,7 @@ use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector; use Rector\Config\RectorConfig; +use Rector\Php74\Rector\Property\RestoreDefaultNullToNullableTypePropertyRector; use Rector\Php80\Rector\Ternary\GetDebugTypeRector; use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; @@ -24,6 +25,9 @@ ]); $rectorConfig->skip([ + RestoreDefaultNullToNullableTypePropertyRector::class => [ + __DIR__ . '/src/Expression/CaseExpression.php', + ], GetDebugTypeRector::class => [ __DIR__ . '/tests/AbstractColumnTest.php', ], diff --git a/src/Expression/CaseExpression.php b/src/Expression/CaseExpression.php new file mode 100644 index 000000000..ef50cb39c --- /dev/null +++ b/src/Expression/CaseExpression.php @@ -0,0 +1,207 @@ +addWhen('condition1', 'result1') + * ->addWhen('condition2', 'result2') + * ->else('defaultResult'); + * ``` + * + * This will be generated into a SQL CASE expression like: + * + * ```sql + * CASE + * WHEN condition1 THEN result1 + * WHEN condition2 THEN result2 + * ELSE defaultResult + * END + * ``` + * + * Example with a specific case value: + * + * ```php + * $case = (new CaseExpression('expression')) + * ->addWhen(1, 'result1') + * ->addWhen(2, 'result2') + * ->else('defaultResult'); + * ``` + * + * This will be generated into a SQL CASE expression like: + * + * ```sql + * CASE expression + * WHEN 1 THEN result1 + * WHEN 2 THEN result2 + * ELSE defaultResult + * END + * ``` + */ +final class CaseExpression implements ExpressionInterface +{ + /** + * @var WhenClause[] List of WHEN conditions and their corresponding results in the CASE expression. + */ + private array $whenClauses; + /** + * @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. + * + * @psalm-suppress PropertyNotSetInConstructor + */ + private mixed $else; + + /** + * @param mixed $case Comparison condition in the CASE expression: + * - `string` is treated as a SQL expression; + * - `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 $caseType Optional data type of the CASE expression which can be used in some DBMS + * to specify the expected type (for example in PostgreSQL). + * @param WhenClause ...$when List of WHEN conditions and their corresponding results in the CASE expression. + */ + public function __construct( + private mixed $case = null, + private string|ColumnInterface $caseType = '', + WhenClause ...$when, + ) { + $this->whenClauses = $when; + } + + /** + * Adds a condition and its corresponding result to the CASE expression. + * + * @param mixed $when The condition to check (WHEN): + * - `string` is treated as a SQL expression; + * - `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()}. + * @param mixed $then The result to return if the condition is `true` (THEN): + * - `string` is treated as a SQL expression; + * - other values will be converted to their string representation using {@see QueryBuilderInterface::buildValue()}. + */ + public function addWhen(mixed $when, mixed $then): self + { + $this->whenClauses[] = new WhenClause($when, $then); + return $this; + } + + /** + * Sets the value to compare against in the CASE expression. + * + * @param mixed $case Comparison condition in the CASE expression: + * - `string` is treated as a SQL expression; + * - `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. + */ + public function case(mixed $case): self + { + $this->case = $case; + return $this; + } + + /** + * Sets the optional data type of the CASE expression which can be used in some DBMS to specify the expected type + * (for example in PostgreSQL). + */ + public function caseType(string|ColumnInterface $caseType): self + { + $this->caseType = $caseType; + return $this; + } + + /** + * Sets the result to return if no conditions match in the CASE expression. + * + * @param mixed $else The result to return if no conditions match (ELSE). + * - `string` is treated as a SQL expression; + * - other values will be converted to their string representation using {@see QueryBuilderInterface::buildValue()}. + * If not set, the CASE expression will not have an ELSE clause. + */ + public function else(mixed $else): self + { + $this->else = $else; + return $this; + } + + /** + * Returns the comparison condition in the CASE expression. + * + * @psalm-mutation-free + */ + public function getCase(): mixed + { + return $this->case; + } + + /** + * Returns the data type of the CASE expression. + * + * @psalm-mutation-free + */ + public function getCaseType(): string|ColumnInterface + { + return $this->caseType; + } + + /** + * Returns the result to return if no conditions match in the CASE expression. + * + * @psalm-mutation-free + */ + public function getElse(): mixed + { + return $this->else ?? null; + } + + /** + * Returns WHEN conditions and their corresponding results in the CASE expression. + * + * @return WhenClause[] List of WHEN conditions and their corresponding results in the CASE expression. + * + * @psalm-mutation-free + */ + public function getWhen(): array + { + return $this->whenClauses; + } + + /** + * Returns `true` if the CASE expression has an ELSE clause, `false` otherwise. + * + * @psalm-mutation-free + */ + public function hasElse(): bool + { + return array_key_exists('else', get_object_vars($this)); + } + + /** + * Sets WHEN conditions and their corresponding results in the CASE expression. + * + * @param WhenClause ...$whenClauses List of WHEN conditions and their corresponding results in the CASE expression. + */ + public function setWhen(WhenClause ...$whenClauses): self + { + $this->whenClauses = $whenClauses; + return $this; + } +} diff --git a/src/Expression/CaseExpressionBuilder.php b/src/Expression/CaseExpressionBuilder.php new file mode 100644 index 000000000..bab0a06dd --- /dev/null +++ b/src/Expression/CaseExpressionBuilder.php @@ -0,0 +1,90 @@ +getWhen(); + + if (empty($whenClauses)) { + throw new InvalidArgumentException('The CASE expression must have at least one WHEN clause.'); + } + + $sql = 'CASE'; + + $case = $expression->getCase(); + + if ($case !== null) { + $sql .= ' ' . $this->buildCondition($case, $params); + } + + foreach ($whenClauses as $when) { + $sql .= ' WHEN ' . $this->buildCondition($when->condition, $params); + $sql .= ' THEN ' . $this->buildResult($when->result, $params); + } + + if ($expression->hasElse()) { + $sql .= ' ELSE ' . $this->buildResult($expression->getElse(), $params); + } + + return $sql . ' END'; + } + + /** + * Builds the condition part of the CASE expression based on their type. + * + * @return string The SQL condition string. + */ + protected function buildCondition(mixed $condition, 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), + }; + } + + /** + * Builds the result part of the CASE expression based on its type. + * + * @return string The SQL result string. + */ + protected function buildResult(mixed $result, array &$params): string + { + if (is_string($result)) { + return $result; + } + + return $this->queryBuilder->buildValue($result, $params); + } +} diff --git a/src/Expression/WhenClause.php b/src/Expression/WhenClause.php new file mode 100644 index 000000000..a60a7bb5b --- /dev/null +++ b/src/Expression/WhenClause.php @@ -0,0 +1,31 @@ + JsonExpressionBuilder::class, ArrayExpression::class => ArrayExpressionBuilder::class, StructuredExpression::class => StructuredExpressionBuilder::class, + CaseExpression::class => CaseExpressionBuilder::class, ]; } diff --git a/tests/AbstractQueryBuilderTest.php b/tests/AbstractQueryBuilderTest.php index f1de1ce59..11149b758 100644 --- a/tests/AbstractQueryBuilderTest.php +++ b/tests/AbstractQueryBuilderTest.php @@ -16,6 +16,7 @@ use InvalidArgumentException; use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Exception\NotSupportedException; +use Yiisoft\Db\Expression\CaseExpression; use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Expression\ExpressionBuilderInterface; use Yiisoft\Db\Expression\ExpressionInterface; @@ -2510,4 +2511,34 @@ public function testBuildValue(mixed $value, string $expected, array $expectedPa $this->assertSame($expected, $qb->buildValue($value, $params)); Assert::arraysEquals($expectedParams, $params); } + + #[DataProviderExternal(QueryBuilderProvider::class, 'caseExpressionBuilder')] + public function testCaseExpressionBuilder( + CaseExpression $case, + string $expectedSql, + array $expectedParams, + string|int $expectedResult, + ): void { + $db = $this->getConnection(); + $qb = $db->getQueryBuilder(); + + $params = []; + + $this->assertSame($expectedSql, $qb->buildExpression($case, $params)); + $this->assertEquals($expectedParams, $params); + } + + public function testCaseExpressionBuilderEmpty(): void + { + $db = $this->getConnection(); + $qb = $db->getQueryBuilder(); + + $params = []; + $case = new CaseExpression(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The CASE expression must have at least one WHEN clause.'); + + $qb->buildExpression($case, $params); + } } diff --git a/tests/Common/CommonQueryBuilderTest.php b/tests/Common/CommonQueryBuilderTest.php index c93facc64..b26b965a8 100644 --- a/tests/Common/CommonQueryBuilderTest.php +++ b/tests/Common/CommonQueryBuilderTest.php @@ -4,11 +4,13 @@ namespace Yiisoft\Db\Tests\Common; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use Yiisoft\Db\Command\CommandInterface; use Yiisoft\Db\Command\Param; use Yiisoft\Db\Constant\DataType; use Yiisoft\Db\Exception\Exception; +use Yiisoft\Db\Expression\CaseExpression; use Yiisoft\Db\Schema\Column\ColumnInterface; use Yiisoft\Db\Tests\AbstractQueryBuilderTest; use Yiisoft\Db\Tests\Provider\QueryBuilderProvider; @@ -180,4 +182,24 @@ public function testUpdateWithoutTypecasting(): void ':qp3' => '1', ], $params); } + + #[DataProviderExternal(QueryBuilderProvider::class, 'caseExpressionBuilder')] + public function testCaseExpressionBuilder( + CaseExpression $case, + string $expectedSql, + array $expectedParams, + string|int $expectedResult, + ): void { + parent::testCaseExpressionBuilder($case, $expectedSql, $expectedParams, $expectedResult); + + $db = $this->getConnection(); + + $result = $db->select($case) + ->from($this->getConnection()->select(['column_name' => 2])) + ->scalar(); + + $this->assertEquals($expectedResult, $result); + + $db->close(); + } } diff --git a/tests/Db/Expression/CaseExpressionTest.php b/tests/Db/Expression/CaseExpressionTest.php new file mode 100644 index 000000000..2dcfe0411 --- /dev/null +++ b/tests/Db/Expression/CaseExpressionTest.php @@ -0,0 +1,170 @@ + [null], + 'string' => ['field = 1'], + 'expression' => [new Expression('field = 1')], + 'boolean' => [true], + 'float' => [2.3], + 'int' => [1], + 'array' => [['=', 'field', 1]], + ]; + } + + #[DataProvider('dataCase')] + public function testConstruct(mixed $case): void + { + $expression = new CaseExpression($case); + + $this->assertSame($case, $expression->getCase()); + $this->assertSame('', $expression->getCaseType()); + $this->assertSame([], $expression->getWhen()); + } + + public function testConstructType(): void + { + $expression = new CaseExpression(caseType: 'int'); + + $this->assertNull($expression->getCase()); + $this->assertSame('int', $expression->getCaseType()); + + $intCol = new IntegerColumn(); + $expression = new CaseExpression(caseType: $intCol); + $this->assertNull($expression->getCase()); + $this->assertSame($intCol, $expression->getCaseType()); + $this->assertSame([], $expression->getWhen()); + } + + public function testConstructorWhenClauses() + { + // Test with one when clauses + $whenClause = new WhenClause('field = 1', 'result1'); + $expression = new CaseExpression(when: $whenClause); + + $this->assertNull($expression->getCase()); + $this->assertSame('', $expression->getCaseType()); + $this->assertSame(['when' => $whenClause], $expression->getWhen()); + + // Test with multiple when clauses + $whenClauses = [ + 'when0' => new WhenClause('field = 1', 'result1'), + 'when1' => new WhenClause('field = 2', 'result2'), + ]; + $expression = new CaseExpression(...$whenClauses); + + $this->assertNull($expression->getCase()); + $this->assertSame('', $expression->getCaseType()); + $this->assertSame($whenClauses, $expression->getWhen()); + } + + #[DataProvider('dataCase')] + public function testCase(mixed $case): void + { + $expression = new CaseExpression(); + + $this->assertNull($expression->getCase()); + + $expression->case($case); + + $this->assertSame($case, $expression->getCase()); + } + + public function testCaseType(): void + { + $expression = new CaseExpression(); + + $this->assertSame('', $expression->getCaseType()); + + $expression->caseType('int'); + + $this->assertSame('int', $expression->getCaseType()); + + $intCol = new IntegerColumn(); + $expression->caseType($intCol); + + $this->assertSame($intCol, $expression->getCaseType()); + } + + public function testWhen(): void + { + $expression = new CaseExpression(); + + $this->assertSame([], $expression->getWhen()); + + $expression->addWhen('field = 1', 'result1'); + $expression->addWhen('field = 2', 'result2'); + + Assert::arraysEquals( + [ + new WhenClause('field = 1', 'result1'), + new WhenClause('field = 2', 'result2'), + ], + $expression->getWhen(), + ); + + $expression->setWhen(); + + $this->assertSame([], $expression->getWhen()); + + $whenClauses = [new WhenClause('field = 3', 'result3')]; + $expression->setWhen(...$whenClauses); + + $this->assertSame($whenClauses, $expression->getWhen()); + + $whenClauses = [ + new WhenClause('field = 3', 'result3'), + new WhenClause('field = 4', 'result4'), + ]; + $expression->setWhen(...$whenClauses); + + $this->assertSame($whenClauses, $expression->getWhen()); + } + + public function testElse(): void + { + $expression = new CaseExpression(); + + $this->assertFalse($expression->hasElse()); + $this->assertNull($expression->getElse()); + + $expression->else(null); + + $this->assertTrue($expression->hasElse()); + $this->assertNull($expression->getElse()); + + $expression->else('result'); + + $this->assertTrue($expression->hasElse()); + $this->assertSame('result', $expression->getElse()); + } + + public function testWhenClause(): void + { + $when = new WhenClause('field = 1', 'result1'); + + $this->assertSame('field = 1', $when->condition); + $this->assertSame('result1', $when->result); + } +} diff --git a/tests/Provider/QueryBuilderProvider.php b/tests/Provider/QueryBuilderProvider.php index 46179d125..42dffd7c2 100644 --- a/tests/Provider/QueryBuilderProvider.php +++ b/tests/Provider/QueryBuilderProvider.php @@ -12,6 +12,7 @@ use Yiisoft\Db\Constant\PseudoType; use Yiisoft\Db\Constant\ReferentialAction; use Yiisoft\Db\Constraint\ForeignKeyConstraint; +use Yiisoft\Db\Expression\CaseExpression; use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Expression\JsonExpression; use Yiisoft\Db\Query\Query; @@ -1855,4 +1856,36 @@ public static function buildValue(): array ], ]; } + + public static function caseExpressionBuilder(): array + { + return [ + 'with case expression' => [ + (new CaseExpression('(1 + 2)')) + ->addWhen(1, 1) + ->addWhen(2, new Expression('2')) + ->addWhen(3, '(2 + 1)') + ->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, + ], + 'without case expression' => [ + (new CaseExpression()) + ->addWhen(['=', 'column_name', 1], $paramA = new Param('a', DataType::STRING)) + ->addWhen( + DbHelper::replaceQuotes('[[column_name]] = 2', static::getDriverName()), + (new Query(self::getDb()))->select($paramB = new Param('b', DataType::STRING)) + ), + DbHelper::replaceQuotes( + << 1, ':qp1' => $paramA, ':pv2' => $paramB], + 'b', + ], + ]; + } } diff --git a/tests/Support/TestTrait.php b/tests/Support/TestTrait.php index 9034f6f5c..8a2704c63 100644 --- a/tests/Support/TestTrait.php +++ b/tests/Support/TestTrait.php @@ -33,7 +33,7 @@ protected function getDriver(): PdoDriverInterface return new PdoDriver($this->dsn); } - protected function getDriverName(): string + protected static function getDriverName(): string { return 'db'; }