diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java index 7bd6d324be..fa7e5fbca3 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryBuilder.java @@ -321,6 +321,46 @@ static int lastTopLevelOrderBy(String sql) { return lastFound; } + /** + * Find the index of the top-level (non-nested) "select" keyword in sql. Used to detect if sql + * starts with a WITH clause (CTE) header - SQL Server does not support a WITH clause nested + * inside a subquery/derived table, so it must be hoisted in front of a wrapping SELECT + * (count/exists) rather than wrapped along with the rest of the query. + *

+ * Returns 0 if there is no leading WITH clause (sql starts directly with SELECT), or -1 if no + * top-level SELECT is found at all. + */ + static int topLevelSelectStart(String sql) { + int depth = 0; + int len = sql.length(); + for (int i = 0; i < len; i++) { + char c = sql.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && sql.regionMatches(true, i, "select", 0, 6) + && (i == 0 || !Character.isLetterOrDigit(sql.charAt(i - 1))) + && (i + 6 == len || !Character.isLetterOrDigit(sql.charAt(i + 6)))) { + return i; + } + } + return -1; + } + + /** + * Split off a leading WITH clause (CTE header) from sql, returning {@code {header, body}} so the + * header can be hoisted in front of a wrapping SELECT. Returns an empty header (unchanged sql as + * the body) when there is no leading WITH clause. + */ + static String[] splitCteHeader(String sql) { + int pos = topLevelSelectStart(sql); + if (pos <= 0) { + return new String[]{"", sql}; + } + return new String[]{sql.substring(0, pos), sql.substring(pos)}; + } + static String inlineSqlCommentLabel(String label, ProfileLocation profileLocation, boolean secondary, String simpleName) { if (label != null) { return secondary ? label : CQueryPlan.planLabelWithType(label, simpleName); @@ -329,7 +369,8 @@ static String inlineSqlCommentLabel(String label, ProfileLocation profileLocatio } private String wrapSelectCount(String sql) { - sql = "select count(*) from ( " + sql + ")"; + String[] parts = splitCteHeader(sql); + sql = parts[0] + "select count(*) from ( " + parts[1] + ")"; if (selectCountWithAlias) { sql += " as c"; } @@ -337,10 +378,13 @@ private String wrapSelectCount(String sql) { } static String wrapSelectExists(String sql, boolean existsWithCaseWhen, String existsFromClause) { + String[] parts = splitCteHeader(sql); + String header = parts[0]; + String body = parts[1]; if (existsWithCaseWhen) { - return "select case when exists(" + sql + ") then 1 else 0 end" + existsFromClause; + return header + "select case when exists(" + body + ") then 1 else 0 end" + existsFromClause; } - return "select exists(" + sql + ")"; + return header + "select exists(" + body + ")"; } /** diff --git a/ebean-core/src/test/java/io/ebeaninternal/server/query/CQueryBuilderTest.java b/ebean-core/src/test/java/io/ebeaninternal/server/query/CQueryBuilderTest.java index 32bf4548b6..076a07e833 100644 --- a/ebean-core/src/test/java/io/ebeaninternal/server/query/CQueryBuilderTest.java +++ b/ebean-core/src/test/java/io/ebeaninternal/server/query/CQueryBuilderTest.java @@ -117,6 +117,63 @@ void countWrap_simpleOrderByIsStripped() { assertThat(countSql).isEqualTo("select count(*) from ( select t0.id from ad t0) as c"); } + @Test + void topLevelSelectStart_noLeadingCte_returnsZero() { + String sql = "select 1 from o_order t0 where t0.id > ?"; + assertThat(CQueryBuilder.topLevelSelectStart(sql)).isEqualTo(0); + } + + @Test + void topLevelSelectStart_leadingCte_findsOuterSelect() { + // The only depth-0 "select" is the outer query - the CTE body's "select" is nested in parens. + String sql = "with order_totals as (" + + " select o.id as order_id," + + " sum(d.order_qty * d.unit_price) as total_amount" + + " from o_order o" + + " join o_order_detail d on d.order_id = o.id" + + " group by o.id" + + ")" + + " select order_id, total_amount" + + " from order_totals" + + " where total_amount > ?"; + + int pos = CQueryBuilder.topLevelSelectStart(sql); + assertThat(sql.substring(pos)).startsWith("select order_id, total_amount"); + } + + /** + * SQL Server does not support a WITH clause (CTE) nested inside a subquery/derived table - see + * https://github.com/ebean-orm/ebean/issues/3848 (findCount() wraps raw sql in "select count(*) + * from ( ... )" which breaks when the raw sql is a CTE). The CTE header must be hoisted in front + * of the wrapping SELECT. + */ + @Test + void splitCteHeader_hoistsLeadingWithClause() { + String sql = "with order_totals as (" + + " select o.id as order_id," + + " sum(d.order_qty * d.unit_price) as total_amount" + + " from o_order o" + + " join o_order_detail d on d.order_id = o.id" + + " group by o.id" + + ")" + + " select order_id, total_amount" + + " from order_totals" + + " where total_amount > ?"; + + String[] parts = CQueryBuilder.splitCteHeader(sql); + assertThat(parts[0] + parts[1]).isEqualTo(sql); + assertThat(parts[0]).startsWith("with order_totals as (").endsWith(") "); + assertThat(parts[1]).isEqualTo("select order_id, total_amount from order_totals where total_amount > ?"); + } + + @Test + void splitCteHeader_noCte_returnsEmptyHeader() { + String sql = "select 1 from o_order t0 where t0.id > ?"; + String[] parts = CQueryBuilder.splitCteHeader(sql); + assertThat(parts[0]).isEmpty(); + assertThat(parts[1]).isEqualTo(sql); + } + @Test void wrapSelectExists_default_usesScalarExists() { String sql = CQueryBuilder.wrapSelectExists("select 1 from o_order t0 where t0.id > ?", false, "");