Skip to content

Commit 225f0b3

Browse files
authored
#3858 fix: OpenCypher aggregation with CASE returns multiple rows (#3859)
1 parent 37919ce commit 225f0b3

3 files changed

Lines changed: 66 additions & 9 deletions

File tree

engine/src/main/java/com/arcadedb/query/opencypher/parser/CypherExpressionBuilder.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,18 +242,22 @@ Expression parseExpression(final Cypher25Parser.ExpressionContext ctx) {
242242
* This is a helper for parsing lower-level expression contexts.
243243
*/
244244
Expression parseExpressionFromText(final ParseTree node) {
245-
// Check for CASE expressions in the parse tree
245+
// All recursive searches below use a length guard: only match when the found
246+
// context covers (almost) the full node text, preventing mis-parsing of
247+
// func(CASE ...) as just the inner CASE. The - 2 allows for whitespace.
248+
final String nodeText = node.getText();
249+
246250
final Cypher25Parser.CaseExpressionContext caseCtx = findCaseExpressionRecursive(node);
247-
if (caseCtx != null)
251+
if (caseCtx != null && caseCtx.getText().length() >= nodeText.length() - 2)
248252
return parseCaseExpression(caseCtx);
249253

250254
final Cypher25Parser.ExtendedCaseExpressionContext extCaseCtx = findExtendedCaseExpressionRecursive(node);
251-
if (extCaseCtx != null)
255+
if (extCaseCtx != null && extCaseCtx.getText().length() >= nodeText.length() - 2)
252256
return parseExtendedCaseExpression(extCaseCtx);
253257

254258
// Check for EXISTS expressions
255259
final Cypher25Parser.ExistsExpressionContext existsCtx = findExistsExpressionRecursive(node);
256-
if (existsCtx != null)
260+
if (existsCtx != null && existsCtx.getText().length() >= nodeText.length() - 2)
257261
return parseExistsExpression(existsCtx);
258262

259263
// Check for logical expressions (AND, OR, XOR, NOT) in the parse tree

engine/src/main/java/com/arcadedb/query/opencypher/parser/ExpressionTypeDetector.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,31 +45,38 @@ class ExpressionTypeDetector {
4545
* Returns null if not a special function.
4646
*/
4747
Expression tryParseSpecialFunctions(final Cypher25Parser.ExpressionContext ctx) {
48+
// All recursive searches below use a length guard: only match when the found
49+
// context covers (almost) the full expression text. Without this, expressions
50+
// like sum(CASE WHEN ... END) would be mis-parsed as just the inner special
51+
// expression, losing the outer function wrapper. The - 2 tolerance allows for
52+
// whitespace that ANTLR's getText() strips from tokens.
53+
final String exprText = ctx.getText();
54+
4855
// count(*) - special grammar rule
4956
final Cypher25Parser.CountStarContext countStarCtx = builder.findCountStarRecursive(ctx);
50-
if (countStarCtx != null) {
57+
if (countStarCtx != null && countStarCtx.getText().length() >= exprText.length() - 2) {
5158
final List<Expression> args = new ArrayList<>();
5259
args.add(new StarExpression());
5360
return new FunctionCallExpression("count", args, false);
5461
}
5562

5663
// EXISTS expression
5764
final Cypher25Parser.ExistsExpressionContext existsCtx = builder.findExistsExpressionRecursive(ctx);
58-
if (existsCtx != null)
65+
if (existsCtx != null && existsCtx.getText().length() >= exprText.length() - 2)
5966
return builder.parseExistsExpression(existsCtx);
6067

6168
// CASE expressions (both forms)
6269
final Cypher25Parser.CaseExpressionContext caseCtx = builder.findCaseExpressionRecursive(ctx);
63-
if (caseCtx != null)
70+
if (caseCtx != null && caseCtx.getText().length() >= exprText.length() - 2)
6471
return builder.parseCaseExpression(caseCtx);
6572

6673
final Cypher25Parser.ExtendedCaseExpressionContext extCaseCtx = builder.findExtendedCaseExpressionRecursive(ctx);
67-
if (extCaseCtx != null)
74+
if (extCaseCtx != null && extCaseCtx.getText().length() >= exprText.length() - 2)
6875
return builder.parseExtendedCaseExpression(extCaseCtx);
6976

7077
// shortestPath expressions
7178
final Cypher25Parser.ShortestPathExpressionContext shortestPathCtx = builder.findShortestPathExpressionRecursive(ctx);
72-
if (shortestPathCtx != null)
79+
if (shortestPathCtx != null && shortestPathCtx.getText().length() >= exprText.length() - 2)
7380
return builder.parseShortestPathExpression(shortestPathCtx);
7481

7582
return null;

engine/src/test/java/com/arcadedb/query/opencypher/CypherCaseTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,52 @@ void caseWithNullHandling() {
186186
assertThat((Object) result.getProperty("category")).isEqualTo("unknown");
187187
}
188188

189+
/**
190+
* Regression test for issue #3858:
191+
* Aggregation with CASE statement should not create implicit GROUP BY on variables inside the CASE.
192+
* When all RETURN items are aggregations, a single row must be returned.
193+
*/
194+
@Test
195+
void aggregationWithCaseNoImplicitGroupBy() {
196+
// Both count(loc) and sum(CASE...) are aggregations — no grouping keys exist.
197+
// Expected: single row {total: 3, pa: 2}
198+
final ResultSet results = database.query("opencypher",
199+
"UNWIND [{state: 'NY'}, {state: 'PA'}, {state: 'PA'}] AS loc " +
200+
"RETURN count(loc) AS total, sum(CASE WHEN loc.state = 'PA' THEN 1 ELSE 0 END) AS pa");
201+
202+
assertThat((boolean) results.hasNext()).isTrue();
203+
final Result result = results.next();
204+
assertThat(((Number) result.getProperty("total")).longValue()).isEqualTo(3L);
205+
assertThat(((Number) result.getProperty("pa")).longValue()).isEqualTo(2L);
206+
assertThat((boolean) results.hasNext()).isFalse();
207+
}
208+
209+
/**
210+
* Regression test for issue #3858 (variant):
211+
* When RETURN has both aggregations and a non-aggregated grouping key,
212+
* the CASE arguments must not act as additional grouping keys.
213+
*/
214+
@Test
215+
void aggregationWithCaseAndGroupByKey() {
216+
// category is the real grouping key; sum(CASE...) must aggregate per category, not per loc.state
217+
final ResultSet results = database.query("opencypher",
218+
"UNWIND [{state: 'NY', cat: 'A'}, {state: 'PA', cat: 'A'}, {state: 'PA', cat: 'B'}] AS loc " +
219+
"RETURN loc.cat AS category, sum(CASE WHEN loc.state = 'PA' THEN 1 ELSE 0 END) AS pa " +
220+
"ORDER BY category");
221+
222+
assertThat((boolean) results.hasNext()).isTrue();
223+
final Result rowA = results.next();
224+
assertThat((Object) rowA.getProperty("category")).isEqualTo("A");
225+
assertThat(((Number) rowA.getProperty("pa")).longValue()).isEqualTo(1L);
226+
227+
assertThat((boolean) results.hasNext()).isTrue();
228+
final Result rowB = results.next();
229+
assertThat((Object) rowB.getProperty("category")).isEqualTo("B");
230+
assertThat(((Number) rowB.getProperty("pa")).longValue()).isEqualTo(1L);
231+
232+
assertThat((boolean) results.hasNext()).isFalse();
233+
}
234+
189235
@Test
190236
void nestedCaseExpressions() {
191237
// Nested CASE expressions

0 commit comments

Comments
 (0)