This is an automated email from the ASF dual-hosted git repository.

vldpyatkov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite.git


The following commit(s) were added to refs/heads/master by this push:
     new e20453758e4 IGNITE-29053 SQL Calcite: Handle ORDER BY in recursive CTE 
(#13589)
e20453758e4 is described below

commit e20453758e4dcde264fb73a0d53c1a927647726f
Author: Vladislav Pyatkov <[email protected]>
AuthorDate: Thu Sep 17 16:21:41 2026 +0300

    IGNITE-29053 SQL Calcite: Handle ORDER BY in recursive CTE (#13589)
---
 .../query/calcite/prepare/IgniteSqlValidator.java  |  3 +
 .../calcite/prepare/RecursiveCteRewriter.java      | 51 ++++++++++++---
 .../integration/RecursiveCteIntegrationTest.java   | 72 ++++++++++++++++++++++
 .../calcite/planner/RecursiveCtePlannerTest.java   | 41 ++++++++++++
 4 files changed, 160 insertions(+), 7 deletions(-)

diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
index 0edb3a3602f..8b671e53d85 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgniteSqlValidator.java
@@ -476,6 +476,9 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
 
     /** {@inheritDoc} */
     @Override protected SqlNode performUnconditionalRewrites(SqlNode node, 
boolean underFrom) {
+        if (node instanceof SqlWithItem)
+            RecursiveCteRewriter.rewriteOrderBy((SqlWithItem)node);
+
         if (node instanceof SqlOrderBy) {
             SqlOrderBy orderBy = (SqlOrderBy)node;
 
diff --git 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/RecursiveCteRewriter.java
 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/RecursiveCteRewriter.java
index b1092153308..2338fada982 100644
--- 
a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/RecursiveCteRewriter.java
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/RecursiveCteRewriter.java
@@ -27,11 +27,14 @@ import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.SqlLiteral;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.SqlOrderBy;
 import org.apache.calcite.sql.SqlSelect;
 import org.apache.calcite.sql.SqlWith;
 import org.apache.calcite.sql.SqlWithItem;
+import org.apache.ignite.internal.processors.cache.query.IgniteQueryErrorCode;
+import org.apache.ignite.internal.processors.query.IgniteSQLException;
 
-/** Infers an omitted RECURSIVE keyword before the validator registers WITH 
scopes. */
+/** Normalizes recursive CTEs before the validator registers WITH scopes. */
 class RecursiveCteRewriter {
     /** FROM operators whose first operand is a table reference. */
     private static final Set<SqlKind> FROM_WRAPPERS = EnumSet.of(
@@ -39,9 +42,39 @@ class RecursiveCteRewriter {
         SqlKind.LATERAL, SqlKind.PIVOT, SqlKind.UNPIVOT, 
SqlKind.MATCH_RECOGNIZE
     );
 
-    /** */
-    private RecursiveCteRewriter() {
-        // No-op.
+    /**
+     * Ignores sorting of the recursive UNION before Calcite wraps it in a 
SELECT, hiding the recursive scope.
+     * Row limiting cannot be discarded because it changes the result. 
Ordinary CTEs retain their ordering.
+     */
+    static void rewriteOrderBy(SqlWithItem item) {
+        if (!(item.query instanceof SqlOrderBy) || 
!hasRecursiveReference(item))
+            return;
+
+        SqlOrderBy orderBy = (SqlOrderBy)item.query;
+
+        if (orderBy.orderList.isEmpty())
+            return;
+
+        if (orderBy.fetch != null || orderBy.offset != null) {
+            throw new IgniteSQLException(
+                "Unsupported recursive CTE: ORDER BY with FETCH, LIMIT or 
OFFSET is not supported",
+                IgniteQueryErrorCode.UNSUPPORTED_OPERATION
+            );
+        }
+
+        item.query = orderBy.query;
+    }
+
+    /** Finds a self-reference in the recursive operand before ORDER BY has 
been rewritten. */
+    private static boolean hasRecursiveReference(SqlWithItem item) {
+        SqlNode qry = withoutOrderBy(item.query);
+
+        return qry.getKind() == SqlKind.UNION && 
references(((SqlCall)qry).operand(1), item.name, false);
+    }
+
+    /** Returns the query expression inside an optional ORDER BY wrapper. */
+    private static SqlNode withoutOrderBy(SqlNode qry) {
+        return qry instanceof SqlOrderBy ? ((SqlOrderBy)qry).query : qry;
     }
 
     /**
@@ -71,11 +104,15 @@ class RecursiveCteRewriter {
                 SqlWithItem item = (SqlWithItem)withNode;
                 boolean shadows = item.name.names.equals(name.names);
 
+                SqlNode qry = withoutOrderBy(item.query);
+
+                // ORDER BY normalization runs before nested items have had 
their recursive flags inferred.
                 // A recursive item shadows the outer name in its recursive 
term, but not in its seed.
-                SqlNode qry = shadows && item.recursive.booleanValue() && 
item.query.getKind() == SqlKind.UNION
-                    ? ((SqlCall)item.query).operand(0) : item.query;
+                SqlNode visibleQry = shadows && qry.getKind() == SqlKind.UNION
+                    && (item.recursive.booleanValue() || 
hasRecursiveReference(item))
+                    ? ((SqlCall)qry).operand(0) : item.query;
 
-                if (references(qry, name, false))
+                if (references(visibleQry, name, false))
                     return true;
 
                 // This item is visible in subsequent items and in the WITH 
body.
diff --git 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/RecursiveCteIntegrationTest.java
 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/RecursiveCteIntegrationTest.java
index 5352cdc3f40..6ca5e893449 100644
--- 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/RecursiveCteIntegrationTest.java
+++ 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/RecursiveCteIntegrationTest.java
@@ -60,6 +60,78 @@ public class RecursiveCteIntegrationTest extends 
AbstractBasicIntegrationTest {
         }
     }
 
+    /** Checks explicit and implicit recursion with ORDER BY inside and 
outside the CTE. */
+    @Test
+    public void testRecursiveCteWithOrderBy() {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            assertQuery("WITH " + keyword + "numbers(n) AS (" +
+                "SELECT 1 " +
+                "UNION ALL " +
+                "SELECT n + 1 FROM numbers WHERE n < 3 " +
+                "ORDER BY n" +
+                ") " +
+                "SELECT n FROM numbers ORDER BY n")
+                .ordered()
+                .returns(1)
+                .returns(2)
+                .returns(3)
+                .check();
+        }
+    }
+
+    /** FETCH, LIMIT and OFFSET cannot be combined with sorting of the 
recursive UNION. */
+    @Test
+    public void testRecursiveCteOrderByWithRowLimitingIsRejected() {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            for (String limit : new String[] {"FETCH FIRST 2 ROWS ONLY", 
"LIMIT 2", "OFFSET 1 ROW"}) {
+                assertThrows("WITH " + keyword + "numbers(n) AS (SELECT 1 
UNION ALL " +
+                    "SELECT n + 1 FROM numbers WHERE n < 3 ORDER BY n " + 
limit + ") SELECT n FROM numbers",
+                    IgniteSQLException.class,
+                    "Unsupported recursive CTE: ORDER BY with FETCH, LIMIT or 
OFFSET is not supported");
+            }
+        }
+    }
+
+    /** A non-recursive CTE retains sorting and row limiting even in a WITH 
RECURSIVE clause. */
+    @Test
+    public void testNonRecursiveCteOrderByWithRowLimiting() {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            assertQuery("WITH " + keyword + "numbers(n) AS (SELECT 1 AS n 
UNION ALL SELECT 3 UNION ALL " +
+                "SELECT 2 ORDER BY n DESC FETCH FIRST 2 ROWS ONLY) SELECT n 
FROM numbers ORDER BY n")
+                .ordered()
+                .returns(2)
+                .returns(3)
+                .check();
+        }
+    }
+
+    /** An inner recursive CTE hides an ordinary outer CTE's name during early 
recursion detection. */
+    @Test
+    public void testOrderByWithNestedCteShadowing() {
+        assertQuery("WITH numbers(n) AS (SELECT 9 AS n UNION ALL " +
+            "SELECT n FROM (WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n + 1 FROM numbers WHERE n < 3 ORDER BY n) SELECT n FROM 
numbers) " +
+            "ORDER BY n DESC FETCH FIRST 2 ROWS ONLY) SELECT n FROM numbers 
ORDER BY n")
+            .ordered()
+            .returns(3)
+            .returns(9)
+            .check();
+    }
+
+    /** Row limiting of a recursive CTE's consumer remains supported. */
+    @Test
+    public void testRecursiveCteOrderByWithOuterRowLimiting() {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            assertQuery("WITH " + keyword + "numbers(n) AS (SELECT 1 UNION ALL 
" +
+                "SELECT n + 1 FROM numbers WHERE n < 5 ORDER BY n) " +
+                "SELECT n FROM numbers ORDER BY n DESC FETCH FIRST 2 ROWS 
ONLY")
+                .ordered()
+                .returns(5)
+                .returns(4)
+                .check();
+        }
+    }
+
     /** */
     @Test
     public void testEmployeeHierarchy() {
diff --git 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/RecursiveCtePlannerTest.java
 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/RecursiveCtePlannerTest.java
index 66c687d631a..d1700071af1 100644
--- 
a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/RecursiveCtePlannerTest.java
+++ 
b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/RecursiveCtePlannerTest.java
@@ -34,6 +34,7 @@ import 
org.apache.ignite.internal.processors.query.calcite.prepare.IgnitePlanner
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteIndexScan;
 import 
org.apache.ignite.internal.processors.query.calcite.rel.IgniteRecursiveTableScan;
 import 
org.apache.ignite.internal.processors.query.calcite.rel.IgniteRepeatUnion;
+import org.apache.ignite.internal.processors.query.calcite.rel.IgniteSort;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteTableScan;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteUnionAll;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteValues;
@@ -85,6 +86,46 @@ public class RecursiveCtePlannerTest extends 
AbstractPlannerTest {
             .and(input(1, 
hasChildThat(isInstanceOf(IgniteRecursiveTableScan.class)))));
     }
 
+    /** Sorting the recursive UNION must not introduce a sort in either 
recursive branch or above it. */
+    @Test
+    public void testRecursiveCteOrderByIsIgnored() throws Exception {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            for (String direction : new String[] {"ASC", "DESC"}) {
+                for (String union : new String[] {"UNION ALL", "UNION 
DISTINCT"}) {
+                    assertPlan("WITH " + keyword + "numbers(n) AS (SELECT 1 " 
+ union +
+                        " SELECT n + 1 FROM numbers WHERE n < 3 ORDER BY n " + 
direction +
+                        ") SELECT n FROM numbers", new 
IgniteSchema(DEFAULT_SCHEMA),
+                        isInstanceOf(IgniteRepeatUnion.class)
+                            
.and(hasChildThat(isInstanceOf(IgniteSort.class)).negate())
+                            .and(input(1, 
hasChildThat(isInstanceOf(IgniteRecursiveTableScan.class)))));
+                }
+            }
+        }
+    }
+
+    /** Row limiting together with ORDER BY must fail explicitly rather than 
be silently discarded. */
+    @Test
+    public void testRecursiveCteOrderByWithRowLimitingIsRejected() throws 
Exception {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            for (String limit : new String[] {"FETCH FIRST 2 ROWS ONLY", 
"LIMIT 2", "OFFSET 1 ROW"}) {
+                String sql = "WITH " + keyword + "numbers(n) AS (SELECT 1 
UNION ALL " +
+                    "SELECT n + 1 FROM numbers WHERE n < 3 ORDER BY n " + 
limit + ") SELECT n FROM numbers";
+
+                try (IgnitePlanner planner = plannerCtx(sql, new 
IgniteSchema(DEFAULT_SCHEMA)).planner()) {
+                    SqlNode node = planner.parse(sql);
+
+                    ValidationException err = 
(ValidationException)GridTestUtils.assertThrows(log,
+                        () -> planner.validate(node), 
ValidationException.class,
+                        "Unsupported recursive CTE: ORDER BY with FETCH, LIMIT 
or OFFSET is not supported");
+
+                    assertTrue(sql, err.getCause() instanceof 
IgniteSQLException);
+                    assertEquals(sql, 
IgniteQueryErrorCode.UNSUPPORTED_OPERATION,
+                        ((IgniteSQLException)err.getCause()).statusCode());
+                }
+            }
+        }
+    }
+
     /** The inferred flag must be set before Calcite registers CTE scopes, 
including nested WITH clauses. */
     @Test
     public void testImplicitRecursiveFlags() throws Exception {

Reply via email to