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 daf282c8b70..3a9667ff341 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -2492,6 +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->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(); diff --git a/tests/Tests/ORM/Functional/Ticket/GH12178Test.php b/tests/Tests/ORM/Functional/Ticket/GH12178Test.php new file mode 100644 index 00000000000..d5a370bbfc5 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH12178Test.php @@ -0,0 +1,170 @@ +useModelSet('cms'); + + parent::setUp(); + } + + /** + * 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(); + self::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(); + self::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(); + self::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(); + self::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(); + self::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(); + self::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(); + self::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(); + self::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(); + self::assertNotEmpty($sql); + } +}