From 05676395802958b13940831f6ccbf6885c6d02c2 Mon Sep 17 00:00:00 2001 From: Dinesh0204 <72650101+Dinesh0204@users.noreply.github.com> Date: Sat, 4 Oct 2025 18:15:18 +0530 Subject: [PATCH 1/6] Fix: (#12178) : Allow IN operator after CASE/COALESCE/NULLIF expressions - Added lookahead logic in SimpleConditionalExpression() - Handles CASE, COALESCE, NULLIF, and parenthesized expressions - Added comprehensive test coverage Fixes #12178" --- src/Query/Parser.php | 1 + tests/QueryBuilderInOperatorTest.php | 200 +++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tests/QueryBuilderInOperatorTest.php diff --git a/src/Query/Parser.php b/src/Query/Parser.php index daf282c8b70..d741f3443cc 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2492,6 +2492,7 @@ public function SimpleConditionalExpression(): AST\ExistsExpression|AST\BetweenE assert($token !== null); assert($peek !== null); + // Handle conditional and null-handling expressions (CASE, COALESCE, NULLIF) by peeking ahead in the token stream if ($token->type === TokenType::T_IDENTIFIER || $token->type === TokenType::T_INPUT_PARAMETER || $this->isFunction()) { // Peek beyond the matching closing parenthesis. $beyond = $this->lexer->peek(); diff --git a/tests/QueryBuilderInOperatorTest.php b/tests/QueryBuilderInOperatorTest.php new file mode 100644 index 00000000000..79523d33f0e --- /dev/null +++ b/tests/QueryBuilderInOperatorTest.php @@ -0,0 +1,200 @@ +setUpEntitySchema([ + CmsUser::class, + ]); + } + + /** + * CASE WHEN expression as left operand with IN operator + */ + public function testCaseWhenWithInOperator(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE CASE + WHEN u.id = 1 THEN 0 + WHEN u.id = 2 THEN 1 + ELSE 3 + END IN (:values)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('values', [0, 1]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * Simple CASE WHEN with IN operator + */ + public function testSimpleCaseWhenWithIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE CASE WHEN u.status = :status THEN u.id ELSE 0 END IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('status', 'active'); + $query->setParameter('ids', [1, 2, 3]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * CASE expression with comparison operator + */ + public function testCaseWhenWithEqualsOperator(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE CASE WHEN u.id = 1 THEN 0 ELSE 1 END = :value'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('value', 0); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * CASE WHEN with NOT IN + */ + public function testCaseWhenWithNotIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE CASE WHEN u.status = :status THEN 1 ELSE 0 END NOT IN (:values)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('status', 'active'); + $query->setParameter('values', [0]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * CASE in SELECT with IN in WHERE + */ + public function testCaseInSelectWithInInWhere(): void + { + $dql = 'SELECT u, CASE WHEN u.id = 1 THEN 1 ELSE 0 END AS isFirst + FROM ' . CmsUser::class . ' u + WHERE u.id IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('ids', [1, 2, 3]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * Nested CASE with IN + */ + public function testNestedCaseWithIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE CASE + WHEN u.id = 1 THEN + CASE WHEN u.status = :status THEN 1 ELSE 2 END + ELSE 3 + END IN (:values)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('status', 'active'); + $query->setParameter('values', [1, 2, 3]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * COALESCE with IN + */ + public function testCoalesceWithIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE COALESCE(u.id, 0) IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('ids', [1, 2, 3]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * Arithmetic expression with IN + */ + public function testArithmeticExpressionWithIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE (u.id + 1) IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('ids', [1, 2, 3]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * Parenthesized arithmetic expression with NOT IN (T_NOT handling) + */ + public function testParenthesizedExpressionWithNotIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE (u.id + 1) NOT IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('ids', [2, 3, 4]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * NULLIF with IN operator + */ + public function testNullIfWithIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE NULLIF(u.id, 0) IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('ids', [1, 2]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } + + /** + * Nested COALESCE with IN + */ + public function testNestedCoalesceWithIn(): void + { + $dql = 'SELECT u FROM ' . CmsUser::class . ' u + WHERE COALESCE(u.id, COALESCE(u.status, 0)) IN (:ids)'; + + $query = $this->_em->createQuery($dql); + $query->setParameter('ids', [0, 1, 2]); + + $sql = $query->getSQL(); + $this->assertNotEmpty($sql); + } +} From 21ff4f03ddf2642895083a71061843aabd319bad Mon Sep 17 00:00:00 2001 From: Dinesh0204 <72650101+Dinesh0204@users.noreply.github.com> Date: Sat, 4 Oct 2025 18:15:50 +0530 Subject: [PATCH 2/6] Update Parser.php --- src/Query/Parser.php | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/Query/Parser.php b/src/Query/Parser.php index d741f3443cc..824f6198d84 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2492,7 +2492,55 @@ public function SimpleConditionalExpression(): AST\ExistsExpression|AST\BetweenE assert($token !== null); assert($peek !== null); + // Handle conditional and null-handling expressions (CASE, COALESCE, NULLIF) by peeking ahead in the token stream + if ($token->type === TokenType::T_CASE || $token->type === TokenType::T_COALESCE || $token->type === TokenType::T_NULLIF) { + if ($token->type === TokenType::T_CASE) { + + // For CASE expressions, peek beyond the matching END keyword + $nestingDepth = 1; + + while ($nestingDepth > 0 && ($nextToken = $this->lexer->peek()) !== null) { + if ($nextToken->type === TokenType::T_CASE) { + $nestingDepth++; + } elseif ($nextToken->type === TokenType::T_END) { + $nestingDepth--; + } + } + } else { + // For COALESCE/NULLIF, peek beyond the function's closing parenthesis + $this->lexer->peek(); + $this->peekBeyondClosingParenthesis(false); + } + + // Determine what operator follows the expression + $operatorToken = $this->lexer->peek(); + + if ($operatorToken !== null && $operatorToken->type === TokenType::T_NOT) { + $operatorToken = $this->lexer->peek(); + } + + $this->lexer->resetPeek(); + + // Update token for subsequent operator checks + $token = $operatorToken; + } + + // Handle arithmetic expressions enclosed in parentheses before an IN operator (e.g., (u.id + 1) IN (...)) + if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek !== null && $peek->type !== TokenType::T_SELECT) { + $tokenAfterParenthesis = $this->peekBeyondClosingParenthesis(false); + + if ($tokenAfterParenthesis !== null && $tokenAfterParenthesis->type === TokenType::T_NOT) { + $tokenAfterParenthesis = $this->lexer->peek(); + } + + $this->lexer->resetPeek(); + + // Update token to reflect what comes after the parenthesized expression + if ($tokenAfterParenthesis !== null) { + $token = $tokenAfterParenthesis; + } + } if ($token->type === TokenType::T_IDENTIFIER || $token->type === TokenType::T_INPUT_PARAMETER || $this->isFunction()) { // Peek beyond the matching closing parenthesis. $beyond = $this->lexer->peek(); From dc29f535b9d037fe9a6251d974c5913756c5cebc Mon Sep 17 00:00:00 2001 From: Dinesh0204 <72650101+Dinesh0204@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:55:48 +0530 Subject: [PATCH 3/6] PHPStan and CodingStandards Fix --- src/Query/Parser.php | 2 ++ tests/QueryBuilderInOperatorTest.php | 1 + 2 files changed, 3 insertions(+) diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 824f6198d84..8e361424331 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2528,6 +2528,7 @@ public function SimpleConditionalExpression(): AST\ExistsExpression|AST\BetweenE // Handle arithmetic expressions enclosed in parentheses before an IN operator (e.g., (u.id + 1) IN (...)) if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek !== null && $peek->type !== TokenType::T_SELECT) { + if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type !== TokenType::T_SELECT) { $tokenAfterParenthesis = $this->peekBeyondClosingParenthesis(false); if ($tokenAfterParenthesis !== null && $tokenAfterParenthesis->type === TokenType::T_NOT) { @@ -2541,6 +2542,7 @@ public function SimpleConditionalExpression(): AST\ExistsExpression|AST\BetweenE $token = $tokenAfterParenthesis; } } + if ($token->type === TokenType::T_IDENTIFIER || $token->type === TokenType::T_INPUT_PARAMETER || $this->isFunction()) { // Peek beyond the matching closing parenthesis. $beyond = $this->lexer->peek(); diff --git a/tests/QueryBuilderInOperatorTest.php b/tests/QueryBuilderInOperatorTest.php index 79523d33f0e..fa90ff684c1 100644 --- a/tests/QueryBuilderInOperatorTest.php +++ b/tests/QueryBuilderInOperatorTest.php @@ -16,6 +16,7 @@ class QueryBuilderInOperatorTest extends OrmFunctionalTestCase protected function setUp(): void { parent::setUp(); + $this->setUpEntitySchema([ CmsUser::class, ]); From ea2f786b8558ba93bb72389b3289eb3bd45c64ab Mon Sep 17 00:00:00 2001 From: Dinesh0204 <72650101+Dinesh0204@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:56:03 +0530 Subject: [PATCH 4/6] Update Parser.php --- src/Query/Parser.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 8e361424331..e00266ba763 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2527,7 +2527,6 @@ public function SimpleConditionalExpression(): AST\ExistsExpression|AST\BetweenE } // Handle arithmetic expressions enclosed in parentheses before an IN operator (e.g., (u.id + 1) IN (...)) - if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek !== null && $peek->type !== TokenType::T_SELECT) { if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type !== TokenType::T_SELECT) { $tokenAfterParenthesis = $this->peekBeyondClosingParenthesis(false); From 4ed240cb298a78d2e2884d823c4651984c74ffe0 Mon Sep 17 00:00:00 2001 From: Dinesh0204 <72650101+Dinesh0204@users.noreply.github.com> Date: Sun, 5 Oct 2025 02:27:04 +0530 Subject: [PATCH 5/6] Adhere to Contribute Guidelines --- .../ORM/Functional/Ticket/GH12178Test.php} | 61 +++++-------------- 1 file changed, 15 insertions(+), 46 deletions(-) rename tests/{QueryBuilderInOperatorTest.php => Tests/ORM/Functional/Ticket/GH12178Test.php} (74%) diff --git a/tests/QueryBuilderInOperatorTest.php b/tests/Tests/ORM/Functional/Ticket/GH12178Test.php similarity index 74% rename from tests/QueryBuilderInOperatorTest.php rename to tests/Tests/ORM/Functional/Ticket/GH12178Test.php index fa90ff684c1..d5a370bbfc5 100644 --- a/tests/QueryBuilderInOperatorTest.php +++ b/tests/Tests/ORM/Functional/Ticket/GH12178Test.php @@ -2,24 +2,24 @@ declare(strict_types=1); -namespace Doctrine\Tests\ORM\Query; +namespace Doctrine\Tests\ORM\Functional\Ticket; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\OrmFunctionalTestCase; + use PHPUnit\Framework\Attributes\Group; /** * Test cases for DQL expressions involving CASE, COALESCE, NULLIF, and arithmetic * especially when used with IN / NOT IN operators. */ -class QueryBuilderInOperatorTest extends OrmFunctionalTestCase +#[Group('GH-12178')] +class GH12178Test extends OrmFunctionalTestCase { protected function setUp(): void { - parent::setUp(); + $this->useModelSet('cms'); - $this->setUpEntitySchema([ - CmsUser::class, - ]); + parent::setUp(); } /** @@ -38,7 +38,7 @@ public function testCaseWhenWithInOperator(): void $query->setParameter('values', [0, 1]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -54,22 +54,7 @@ public function testSimpleCaseWhenWithIn(): void $query->setParameter('ids', [1, 2, 3]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); - } - - /** - * CASE expression with comparison operator - */ - public function testCaseWhenWithEqualsOperator(): void - { - $dql = 'SELECT u FROM ' . CmsUser::class . ' u - WHERE CASE WHEN u.id = 1 THEN 0 ELSE 1 END = :value'; - - $query = $this->_em->createQuery($dql); - $query->setParameter('value', 0); - - $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -85,23 +70,7 @@ public function testCaseWhenWithNotIn(): void $query->setParameter('values', [0]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); - } - - /** - * CASE in SELECT with IN in WHERE - */ - public function testCaseInSelectWithInInWhere(): void - { - $dql = 'SELECT u, CASE WHEN u.id = 1 THEN 1 ELSE 0 END AS isFirst - FROM ' . CmsUser::class . ' u - WHERE u.id IN (:ids)'; - - $query = $this->_em->createQuery($dql); - $query->setParameter('ids', [1, 2, 3]); - - $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -121,7 +90,7 @@ public function testNestedCaseWithIn(): void $query->setParameter('values', [1, 2, 3]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -136,7 +105,7 @@ public function testCoalesceWithIn(): void $query->setParameter('ids', [1, 2, 3]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -151,7 +120,7 @@ public function testArithmeticExpressionWithIn(): void $query->setParameter('ids', [1, 2, 3]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -166,7 +135,7 @@ public function testParenthesizedExpressionWithNotIn(): void $query->setParameter('ids', [2, 3, 4]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -181,7 +150,7 @@ public function testNullIfWithIn(): void $query->setParameter('ids', [1, 2]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } /** @@ -196,6 +165,6 @@ public function testNestedCoalesceWithIn(): void $query->setParameter('ids', [0, 1, 2]); $sql = $query->getSQL(); - $this->assertNotEmpty($sql); + self::assertNotEmpty($sql); } } From d04f57bd5d97a1edbe84245ee64550ab6db94255 Mon Sep 17 00:00:00 2001 From: Dinesh0204 <72650101+Dinesh0204@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:30:57 +0530 Subject: [PATCH 6/6] Fix : EBNF and Formatting --- docs/en/reference/dql-doctrine-query-language.rst | 10 ++++++++-- src/Query/Parser.php | 1 - 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 9b0b0539ddd..ff622c943d6 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -1723,7 +1723,13 @@ Conditional Expressions ConditionalExpression ::= ConditionalTerm {"OR" ConditionalTerm}* ConditionalTerm ::= ConditionalFactor {"AND" ConditionalFactor}* ConditionalFactor ::= ["NOT"] ConditionalPrimary - ConditionalPrimary ::= SimpleConditionalExpression | "(" ConditionalExpression ")" + ConditionalPrimary ::= SimpleConditionalExpression + | "(" ConditionalExpression ")" + | CaseExpression + | CoalesceExpression + | NullifExpression + | ArithmeticExpression + SimpleConditionalExpression ::= ComparisonExpression | BetweenExpression | LikeExpression | InExpression | NullComparisonExpression | ExistsExpression | EmptyCollectionComparisonExpression | CollectionMemberExpression | @@ -1819,7 +1825,7 @@ QUANTIFIED/BETWEEN/COMPARISON/LIKE/NULL/EXISTS QuantifiedExpression ::= ("ALL" | "ANY" | "SOME") "(" Subselect ")" BetweenExpression ::= ArithmeticExpression ["NOT"] "BETWEEN" ArithmeticExpression "AND" ArithmeticExpression ComparisonExpression ::= ArithmeticExpression ComparisonOperator ( QuantifiedExpression | ArithmeticExpression ) - InExpression ::= ArithmeticExpression ["NOT"] "IN" "(" (InParameter {"," InParameter}* | Subselect) ")" + InExpression ::= (ArithmeticExpression | CaseExpression | CoalesceExpression | NullifExpression) ["NOT"] "IN" "(" (InParameter {"," InParameter}* | Subselect) ")" InstanceOfExpression ::= IdentificationVariable ["NOT"] "INSTANCE" ["OF"] (InstanceOfParameter | "(" InstanceOfParameter {"," InstanceOfParameter}* ")") InstanceOfParameter ::= AbstractSchemaName | InputParameter LikeExpression ::= StringExpression ["NOT"] "LIKE" StringPrimary ["ESCAPE" char] diff --git a/src/Query/Parser.php b/src/Query/Parser.php index e00266ba763..3a9667ff341 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2496,7 +2496,6 @@ public function SimpleConditionalExpression(): AST\ExistsExpression|AST\BetweenE // Handle conditional and null-handling expressions (CASE, COALESCE, NULLIF) by peeking ahead in the token stream if ($token->type === TokenType::T_CASE || $token->type === TokenType::T_COALESCE || $token->type === TokenType::T_NULLIF) { if ($token->type === TokenType::T_CASE) { - // For CASE expressions, peek beyond the matching END keyword $nestingDepth = 1;