Skip to content

Commit d144273

Browse files
authored
ebean-core#3686: use parenthesis-aware ORDER BY removal in buildRowCountQuery to avoid breaking nested subqueries (#3729)
1 parent 41c5ebd commit d144273

File tree

3 files changed

+185
-1
lines changed

3 files changed

+185
-1
lines changed

ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ <T> CQueryRowCount buildRowCountQuery(OrmQueryRequest<T> request) {
280280
sql = wrapSelectCount(sql);
281281
} else if (wrap || query.isRawSql()) {
282282
// remove order by - mssql does not accept order by in subqueries
283-
int pos = sql.lastIndexOf(" order by ");
283+
int pos = lastTopLevelOrderBy(sql);
284284
if (pos != -1) {
285285
sql = sql.substring(0, pos);
286286
}
@@ -300,6 +300,27 @@ private <T> boolean includesAggregation(OrmQueryRequest<T> request, SpiQuery<T>
300300
return request.descriptor().includesAggregation(query.detail());
301301
}
302302

303+
/**
304+
* Find the last " order by " that is not inside parentheses (i.e. not inside a subquery).
305+
* Returns the position or -1 if not found.
306+
*/
307+
static int lastTopLevelOrderBy(String sql) {
308+
String target = " order by ";
309+
int depth = 0;
310+
int lastFound = -1;
311+
for (int i = 0; i < sql.length(); i++) {
312+
char c = sql.charAt(i);
313+
if (c == '(') {
314+
depth++;
315+
} else if (c == ')') {
316+
depth--;
317+
} else if (depth == 0 && c == ' ' && sql.regionMatches(true, i, target, 0, target.length())) {
318+
lastFound = i;
319+
}
320+
}
321+
return lastFound;
322+
}
323+
303324
private String wrapSelectCount(String sql) {
304325
sql = "select count(*) from ( " + sql + ")";
305326
if (selectCountWithAlias) {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package io.ebeaninternal.server.query;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
class CQueryBuilderTest {
8+
9+
/**
10+
* Simulates what buildRowCountQuery does: strip the top-level order by, then wrap with count.
11+
* This is the logic that must use lastTopLevelOrderBy instead of lastIndexOf.
12+
*/
13+
private static String simulateCountWrap(String sql) {
14+
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
15+
if (pos != -1) {
16+
sql = sql.substring(0, pos);
17+
}
18+
return "select count(*) from ( " + sql + ") as c";
19+
}
20+
21+
private static int countChar(String s, char c) {
22+
int count = 0;
23+
for (int i = 0; i < s.length(); i++) {
24+
if (s.charAt(i) == c) count++;
25+
}
26+
return count;
27+
}
28+
29+
@Test
30+
void lastTopLevelOrderBy_simple() {
31+
String sql = "select t0.id from ad t0 order by t0.id";
32+
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
33+
assertThat(pos).isEqualTo(sql.indexOf(" order by "));
34+
}
35+
36+
@Test
37+
void lastTopLevelOrderBy_noOrderBy() {
38+
String sql = "select t0.id from ad t0";
39+
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
40+
assertThat(pos).isEqualTo(-1);
41+
}
42+
43+
@Test
44+
void lastTopLevelOrderBy_insideSubquery() {
45+
// order by is only inside a subquery - should not be found at top level
46+
String sql = "select t0.id from ad t0 where t0.id in (select t0.id from ad t0 order by t0.rebate)";
47+
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
48+
assertThat(pos).isEqualTo(-1);
49+
}
50+
51+
@Test
52+
void lastTopLevelOrderBy_bothLevels() {
53+
// order by inside subquery AND at top level - should find only the top-level one
54+
String sql = "select t0.id from ad t0 where t0.id in (select t0.id from ad t0 order by t0.rebate) order by t0.id";
55+
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
56+
assertThat(sql.substring(pos)).isEqualTo(" order by t0.id");
57+
}
58+
59+
@Test
60+
void lastTopLevelOrderBy_nestedSubqueries() {
61+
// deeply nested order by should not be found
62+
String sql = "select t0.id from ad t0 where t0.id in (select t0.id from ad t0 where t0.x in (select id from foo order by bar))";
63+
int pos = CQueryBuilder.lastTopLevelOrderBy(sql);
64+
assertThat(pos).isEqualTo(-1);
65+
}
66+
67+
/**
68+
* Reproduces the exact scenario from https://github.com/ebean-orm/ebean/issues/3686
69+
*
70+
* With lastIndexOf(" order by "), the inner subquery's order by is matched,
71+
* stripping its closing parenthesis and producing unbalanced SQL.
72+
*/
73+
@Test
74+
void countWrap_formulaJoinWithSubqueryOrderBy_issue3686() {
75+
// This is the SQL that buildRowCountQuery would produce before wrapping,
76+
// matching the bug report: @Formula with JOIN + IN subquery with distinctOn + orderBy
77+
String innerSql = "select t0.id from ad t0"
78+
+ " LEFT JOIN price_range ON price_range.ad_id = t0.id"
79+
+ " where t0.id in (select distinct on (t0.rebate) t0.id from ad t0 order by t0.rebate)";
80+
81+
String countSql = simulateCountWrap(innerSql);
82+
83+
// The subquery's closing ) must be preserved
84+
assertThat(countSql).contains("order by t0.rebate)");
85+
// Parentheses must be balanced
86+
assertThat(countChar(countSql, '(')).isEqualTo(countChar(countSql, ')'));
87+
// Should end with ") as c" - the outer count wrapper's closing paren
88+
assertThat(countSql).endsWith(") as c");
89+
}
90+
91+
@Test
92+
void countWrap_topLevelOrderByIsStripped() {
93+
// When there IS a top-level order by, it should be stripped
94+
String innerSql = "select t0.id from ad t0"
95+
+ " LEFT JOIN price_range ON price_range.ad_id = t0.id"
96+
+ " where t0.id in (select distinct on (t0.rebate) t0.id from ad t0 order by t0.rebate)"
97+
+ " order by price_range.discounted_price";
98+
99+
String countSql = simulateCountWrap(innerSql);
100+
101+
// Top-level order by should be removed
102+
assertThat(countSql).doesNotContain("discounted_price");
103+
// But inner subquery order by must remain intact
104+
assertThat(countSql).contains("order by t0.rebate)");
105+
// Parentheses must be balanced
106+
assertThat(countChar(countSql, '(')).isEqualTo(countChar(countSql, ')'));
107+
}
108+
109+
@Test
110+
void countWrap_simpleOrderByIsStripped() {
111+
// Simple case: top-level order by with no subquery
112+
String innerSql = "select t0.id from ad t0 order by t0.id";
113+
114+
String countSql = simulateCountWrap(innerSql);
115+
116+
assertThat(countSql).isEqualTo("select count(*) from ( select t0.id from ad t0) as c");
117+
}
118+
}

ebean-test/src/test/java/org/tests/query/joins/TestQueryJoinOnFormula.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,51 @@ public void test_ChildPersonParentFindCount() {
333333
assertThat(loggedSql.get(0)).contains("where coalesce(f2.child_age, 0) = ?");
334334
}
335335

336+
/**
337+
* Test for https://github.com/ebean-orm/ebean/issues/3686
338+
* findCount with Formula join + subquery containing ORDER BY
339+
* should produce valid SQL with properly balanced parentheses.
340+
*
341+
* Before the fix, lastIndexOf(" order by ") matched the ORDER BY inside the
342+
* IN subquery, stripping its closing parenthesis and producing invalid SQL.
343+
*/
344+
@Test
345+
public void test_findCount_formulaJoin_subqueryWithOrderBy_issue3686() {
346+
347+
LoggedSql.start();
348+
349+
// Subquery with orderBy — the order by inside the subquery triggers the bug.
350+
Query<Order> subQuery = DB.find(Order.class)
351+
.select("id")
352+
.orderBy("id");
353+
354+
// Outer query: formula join (totalItems) via where clause + findCount
355+
int count = DB.find(Order.class)
356+
.where()
357+
.in("id", subQuery)
358+
.eq("totalItems", 3)
359+
.findCount();
360+
361+
assertThat(count).isEqualTo(2);
362+
363+
List<String> sql = LoggedSql.stop();
364+
assertEquals(1, sql.size());
365+
366+
String countSql = sql.get(0);
367+
// The count query must wrap with select count(*) from ( ... )
368+
assertThat(countSql).contains("select count(*) from (");
369+
// The subquery's ORDER BY and closing ) must be preserved
370+
assertThat(countSql).contains("order by t0.id)");
371+
372+
// Parentheses must be balanced in the generated SQL
373+
int open = 0, close = 0;
374+
for (char c : countSql.toCharArray()) {
375+
if (c == '(') open++;
376+
if (c == ')') close++;
377+
}
378+
assertThat(open).as("parentheses must be balanced in: " + countSql).isEqualTo(close);
379+
}
380+
336381
@Test
337382
public void test_softRef() {
338383

0 commit comments

Comments
 (0)