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 bae6b26c3de IGNITE-29043 SQL Calcite: Support recursive CTEs without 
the RECURSIVE keyword (#13562)
bae6b26c3de is described below

commit bae6b26c3deb2b6bfe480254b2563f6422160819
Author: Vladislav Pyatkov <[email protected]>
AuthorDate: Wed Sep 9 17:04:23 2026 +0300

    IGNITE-29043 SQL Calcite: Support recursive CTEs without the RECURSIVE 
keyword (#13562)
---
 .../query/calcite/prepare/IgniteSqlValidator.java  |   3 +
 .../calcite/prepare/RecursiveCteRewriter.java      | 118 +++++++++++++++++++++
 .../integration/RecursiveCteIntegrationTest.java   |  26 +++++
 .../calcite/planner/RecursiveCtePlannerTest.java   | 109 +++++++++++++++++++
 4 files changed, 256 insertions(+)

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 5f31f4d64a5..0edb3a3602f 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
@@ -510,6 +510,9 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
 
         node = super.performUnconditionalRewrites(node, underFrom);
 
+        if (node instanceof SqlWithItem)
+            RecursiveCteRewriter.inferRecursion((SqlWithItem)node);
+
         if (config() instanceof Config && ((Config)config()).sqlNodeRewriter() 
!= null)
             node = ((Config)config()).sqlNodeRewriter().rewrite(this, 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
new file mode 100644
index 00000000000..b1092153308
--- /dev/null
+++ 
b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/RecursiveCteRewriter.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.prepare;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlJoin;
+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.SqlSelect;
+import org.apache.calcite.sql.SqlWith;
+import org.apache.calcite.sql.SqlWithItem;
+
+/** Infers an omitted RECURSIVE keyword 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(
+        SqlKind.AS, SqlKind.TABLE_REF, SqlKind.EXTEND, SqlKind.SNAPSHOT, 
SqlKind.TABLESAMPLE,
+        SqlKind.LATERAL, SqlKind.PIVOT, SqlKind.UNPIVOT, 
SqlKind.MATCH_RECOGNIZE
+    );
+
+    /** */
+    private RecursiveCteRewriter() {
+        // No-op.
+    }
+
+    /**
+     * Called from the validator's existing bottom-up rewrite, so nested WITH 
items have already been processed.
+     * Only UNION can define a recursive CTE in Calcite. Other CTEs and 
explicitly recursive items need no scan.
+     * The seed uses the enclosing scope; only a reference in the right UNION 
operand can refer to this item.
+     */
+    static void inferRecursion(SqlWithItem item) {
+        if (!item.recursive.booleanValue() && item.query.getKind() == 
SqlKind.UNION
+            && references(((SqlCall)item.query).operand(1), item.name, false))
+            item.recursive = SqlLiteral.createBoolean(true, 
item.recursive.getParserPosition());
+    }
+
+    /** Finds the first unqualified table reference, respecting nested WITH 
scopes and ignoring column names. */
+    private static boolean references(SqlNode node, SqlIdentifier name, 
boolean from) {
+        if (node == null)
+            return false;
+
+        if (node instanceof SqlIdentifier)
+            // Match Calcite's WithRecursiveScope: parser casing is already 
applied, including quoted identifiers.
+            return from && ((SqlIdentifier)node).names.equals(name.names);
+
+        if (node instanceof SqlWith) {
+            SqlWith with = (SqlWith)node;
+
+            for (SqlNode withNode : with.withList) {
+                SqlWithItem item = (SqlWithItem)withNode;
+                boolean shadows = item.name.names.equals(name.names);
+
+                // 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;
+
+                if (references(qry, name, false))
+                    return true;
+
+                // This item is visible in subsequent items and in the WITH 
body.
+                if (shadows)
+                    return false;
+            }
+
+            return references(with.body, name, false);
+        }
+
+        if (node instanceof SqlJoin) {
+            SqlJoin join = (SqlJoin)node;
+
+            return references(join.getLeft(), name, true)
+                || references(join.getRight(), name, true)
+                || references(join.getCondition(), name, false);
+        }
+
+        List<SqlNode> operands;
+
+        if (node instanceof SqlCall)
+            operands = ((SqlCall)node).getOperandList();
+        else if (node instanceof SqlNodeList)
+            operands = (SqlNodeList)node;
+        else
+            return false;
+
+        for (int i = 0; i < operands.size(); i++) {
+            SqlNode operand = operands.get(i);
+            boolean childFrom = node instanceof SqlSelect
+                ? operand == ((SqlSelect)node).getFrom()
+                : from && i == 0 && FROM_WRAPPERS.contains(node.getKind());
+
+            if (references(operand, name, childFrom))
+                return true;
+        }
+
+        return false;
+    }
+}
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 be0a8aa3039..8989bad03de 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
@@ -31,6 +31,32 @@ public class RecursiveCteIntegrationTest extends 
AbstractBasicIntegrationTest {
     /** Number of invocations of a non-deterministic function. */
     private static final AtomicInteger nonDeterministicCallCnt = new 
AtomicInteger();
 
+    /** Explicit and inferred recursion must produce the same rows. */
+    @Test
+    public void testOptionalRecursiveKeyword() {
+        for (String keyword : new String[] {"", "RECURSIVE "}) {
+            assertQuery("WITH " + keyword + "seed(n) AS (SELECT 1), numbers(n) 
AS (" +
+                "SELECT n FROM seed UNION ALL SELECT n + 1 FROM numbers WHERE 
n < 3), " +
+                "result AS (SELECT * FROM numbers) SELECT * FROM result")
+                .returns(1)
+                .returns(2)
+                .returns(3)
+                .check();
+
+            assertQuery("SELECT * FROM (WITH " + keyword + "\"Numbers\"(n) AS 
(" +
+                "SELECT 1 UNION ALL SELECT x.n + 1 FROM \"Numbers\" x WHERE 
x.n < 3) " +
+                "SELECT * FROM \"Numbers\")")
+                .returns(1)
+                .returns(2)
+                .returns(3)
+                .check();
+
+            assertThrows("WITH " + keyword + "numbers(n) AS (" +
+                "SELECT 1 UNION SELECT n + 1 FROM numbers WHERE n < 3) SELECT 
* FROM numbers",
+                IgniteSQLException.class, "only UNION ALL is supported");
+        }
+    }
+
     /** */
     @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 658c1431bda..769b8233558 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
@@ -17,10 +17,17 @@
 
 package org.apache.ignite.internal.processors.query.calcite.planner;
 
+import java.util.ArrayList;
+import java.util.List;
 import org.apache.calcite.rel.core.Exchange;
 import org.apache.calcite.rel.core.Spool;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlWithItem;
 import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.util.SqlBasicVisitor;
 import 
org.apache.ignite.internal.processors.query.calcite.exec.exp.IgniteScalarFunction;
+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;
@@ -69,6 +76,108 @@ public class RecursiveCtePlannerTest extends 
AbstractPlannerTest {
             .and(input(0, isInstanceOf(IgniteValues.class)))
             .and(input(1, 
hasChildThat(isInstanceOf(IgniteRecursiveTableScan.class))))
         );
+
+        assertPlan(sql.replace("WITH RECURSIVE", "WITH"), schema, 
isInstanceOf(IgniteRepeatUnion.class)
+            .and(input(1, 
hasChildThat(isInstanceOf(IgniteRecursiveTableScan.class)))));
+    }
+
+    /** The inferred flag must be set before Calcite registers CTE scopes, 
including nested WITH clauses. */
+    @Test
+    public void testImplicitRecursiveFlags() throws Exception {
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT x.n + 1 FROM numbers x WHERE x.n < 3) SELECT * FROM 
numbers", true);
+
+        assertRecursiveFlags("WITH seed(n) AS (SELECT 1), numbers(n) AS 
(SELECT n FROM seed UNION ALL " +
+            "SELECT n + 1 FROM numbers WHERE n < 3), result AS (SELECT * FROM 
numbers) SELECT * FROM result",
+            false, true, false);
+
+        assertRecursiveFlags("SELECT * FROM (WITH numbers(n) AS (SELECT 1 
UNION ALL " +
+            "SELECT n + 1 FROM numbers WHERE n < 3) SELECT * FROM numbers)", 
true);
+
+        assertRecursiveFlags("WITH \"Numbers\"(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n + 1 FROM \"Numbers\" WHERE n < 3) SELECT * FROM 
\"Numbers\"", true);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n + 1 FROM (SELECT * FROM numbers) x WHERE n < 3) SELECT * 
FROM numbers", true);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT (SELECT n + 1 FROM numbers WHERE n < 3)) SELECT * FROM 
numbers", true);
+    }
+
+    /** Identifiers in expressions, aliases and qualified table names must not 
enable recursion. */
+    @Test
+    public void testOrdinaryCteFlags() throws Exception {
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT n FROM numbers) 
SELECT * FROM numbers", false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT n FROM numbers UNION 
ALL SELECT 2) " +
+            "SELECT * FROM numbers", false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT numbers FROM (VALUES (2)) x(numbers)) SELECT * FROM 
numbers", false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT numbers.n FROM (VALUES (2)) numbers(n)) SELECT * FROM 
numbers", false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n FROM PUBLIC.numbers) SELECT * FROM numbers", false);
+
+        assertRecursiveFlags("WITH \"numbers\"(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n FROM NUMBERS) SELECT * FROM \"numbers\"", false);
+    }
+
+    /** Inner CTEs hide the outer name only where they are visible. */
+    @Test
+    public void testNestedCteFlags() throws Exception {
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT * FROM (WITH numbers(n) AS (SELECT 2) SELECT * FROM 
numbers)) SELECT * FROM numbers",
+            false, false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT * FROM (WITH numbers(n) AS (SELECT 2), x AS (SELECT * FROM 
numbers) SELECT * FROM x)) " +
+            "SELECT * FROM numbers", false, false, false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT * FROM (WITH numbers(n) AS (SELECT 2 UNION ALL SELECT n + 
1 FROM numbers WHERE n < 3) " +
+            "SELECT * FROM numbers)) SELECT * FROM numbers", false, true);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n + 1 FROM (WITH numbers(n) AS (SELECT n FROM numbers 
WHERE n < 3) " +
+            "SELECT * FROM numbers)) SELECT * FROM numbers", true, false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n + 1 FROM (WITH x AS (SELECT n FROM numbers WHERE n < 3), 
numbers(n) AS (SELECT 9) " +
+            "SELECT * FROM x)) SELECT * FROM numbers", true, false, false);
+
+        assertRecursiveFlags("WITH numbers(n) AS (SELECT 1 UNION ALL " +
+            "SELECT n + 1 FROM (WITH numbers(n) AS (SELECT n FROM numbers 
WHERE n < 2 UNION ALL " +
+            "SELECT n + 1 FROM numbers WHERE n < 2) SELECT * FROM numbers)) 
SELECT * FROM numbers", true, true);
+    }
+
+    /** Validates real SQL and checks flags on parsed WITH items in pre-order. 
*/
+    private void assertRecursiveFlags(String sql, boolean... expected) throws 
Exception {
+        IgniteSchema schema = createSchema(createTable("NUMBERS", 
IgniteDistributions.single(), "N", Integer.class));
+
+        try (IgnitePlanner planner = plannerCtx(sql, schema).planner()) {
+            SqlNode node = planner.parse(sql);
+            List<SqlWithItem> items = new ArrayList<>();
+
+            node.accept(new SqlBasicVisitor<Void>() {
+                /** {@inheritDoc} */
+                @Override public Void visit(SqlCall call) {
+                    if (call instanceof SqlWithItem)
+                        items.add((SqlWithItem)call);
+
+                    return super.visit(call);
+                }
+            });
+
+            planner.validate(node);
+
+            assertEquals(sql, expected.length, items.size());
+
+            for (int i = 0; i < expected.length; i++)
+                assertEquals(sql + " [item=" + i + ']', expected[i], 
items.get(i).recursive.booleanValue());
+        }
     }
 
     /** A replicated source can be read on the coordinator without an 
exchange. */

Reply via email to