Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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]
Expand Down
49 changes: 49 additions & 0 deletions src/Query/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
170 changes: 170 additions & 0 deletions tests/Tests/ORM/Functional/Ticket/GH12178Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

declare(strict_types=1);

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.
*/
#[Group('GH-12178')]
class GH12178Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
$this->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);
}
}
Loading