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

xuzifu666 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/main by this push:
     new 6484bed457 [CALCITE-4597] Allow RelNodes to have an empty row type 
(zero fields)
6484bed457 is described below

commit 6484bed4572adbc219b171a9efbbd08a1b662f3a
Author: Yu Xu <[email protected]>
AuthorDate: Sat Sep 19 00:49:02 2026 +0800

    [CALCITE-4597] Allow RelNodes to have an empty row type (zero fields)
---
 .../calcite/plan/volcano/VolcanoPlanner.java       | 20 +++++
 .../org/apache/calcite/rel/EmptyRowTypePolicy.java | 67 +++++++++++++++
 .../java/org/apache/calcite/tools/RelBuilder.java  | 94 +++++++++++++++++++++-
 .../calcite/plan/volcano/VolcanoPlannerTest.java   | 46 +++++++++++
 .../org/apache/calcite/test/RelBuilderTest.java    | 66 +++++++++++++++
 5 files changed, 292 insertions(+), 1 deletion(-)

diff --git 
a/core/src/main/java/org/apache/calcite/plan/volcano/VolcanoPlanner.java 
b/core/src/main/java/org/apache/calcite/plan/volcano/VolcanoPlanner.java
index 8f4cf379b4..b13949f4ea 100644
--- a/core/src/main/java/org/apache/calcite/plan/volcano/VolcanoPlanner.java
+++ b/core/src/main/java/org/apache/calcite/plan/volcano/VolcanoPlanner.java
@@ -38,6 +38,7 @@
 import org.apache.calcite.plan.RelTrait;
 import org.apache.calcite.plan.RelTraitDef;
 import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.rel.EmptyRowTypePolicy;
 import org.apache.calcite.rel.PhysicalNode;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.convert.Converter;
@@ -1266,6 +1267,7 @@ private RelSubset registerImpl(
       throw new AssertionError("Relational expression " + rel
           + " belongs to a different planner than is currently being used.");
     }
+    checkEmptyRowType(rel);
 
     // Now is a good time to ensure that the relational expression
     // implements the interface required by its calling convention.
@@ -1412,6 +1414,24 @@ private RelSubset registerImpl(
     return subset;
   }
 
+  /** Throws if the row type of {@code rel} is empty and the current
+   * {@link EmptyRowTypePolicy} is {@link EmptyRowTypePolicy#FORBIDDEN}.
+   *
+   * <p>The policy is read from the planner's
+   * {@link #getContext() context}; if the context does not contain an
+   * {@link EmptyRowTypePolicy}, the default is
+   * {@link EmptyRowTypePolicy#DISCOURAGED}. */
+  private void checkEmptyRowType(RelNode rel) {
+    final EmptyRowTypePolicy emptyRowTypePolicy =
+        getContext().maybeUnwrap(EmptyRowTypePolicy.class)
+            .orElse(EmptyRowTypePolicy.DISCOURAGED);
+    if (emptyRowTypePolicy == EmptyRowTypePolicy.FORBIDDEN
+        && rel.getRowType().getFieldCount() == 0) {
+      throw new IllegalArgumentException("empty row type is forbidden by "
+          + "the EmptyRowTypePolicy: " + rel);
+    }
+  }
+
   private RelSubset addRelToSet(RelNode rel, RelSet set) {
     RelSubset subset = set.add(rel);
     mapRel2Subset.put(rel, subset);
diff --git a/core/src/main/java/org/apache/calcite/rel/EmptyRowTypePolicy.java 
b/core/src/main/java/org/apache/calcite/rel/EmptyRowTypePolicy.java
new file mode 100644
index 0000000000..e0eda708fb
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/rel/EmptyRowTypePolicy.java
@@ -0,0 +1,67 @@
+/*
+ * 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.calcite.rel;
+
+/**
+ * Policy that specifies whether a {@link RelNode} is allowed to have an
+ * empty row type, that is, a row type that contains zero fields.
+ *
+ * <p>For example, {@code SELECT * FROM t GROUP BY ()} with no aggregate
+ * functions returns one row with zero columns, and can be represented as
+ * a {@link org.apache.calcite.rel.core.Values} whose row type is empty.
+ *
+ * <p>The policy is configured via
+ * {@link org.apache.calcite.tools.RelBuilder.Config#emptyRowTypePolicy()} and
+ * via the planner's
+ * {@link org.apache.calcite.plan.Context}; both default to
+ * {@link #DISCOURAGED}. For a few releases, tests should be run in all three
+ * modes, and all rules must run in all modes.
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/CALCITE-4597";>
+ * CALCITE-4597: Allow RelNodes to have an empty row type (zero fields)</a>
+ */
+public enum EmptyRowTypePolicy {
+  /**
+   * Empty row types are forbidden: Calcite prevents creation of
+   * {@link RelNode}s whose row type is empty. For example, the planner and
+   * {@code RelBuilder} throw if they see one.
+   *
+   * <p>Rules must not produce empty row types, and can assume that they
+   * will not encounter empty row types.
+   */
+  FORBIDDEN,
+
+  /**
+   * Empty row types are discouraged: the planner and {@code RelBuilder} will
+   * not throw if they see a {@link RelNode} whose row type is empty, but
+   * they try not to create one.
+   *
+   * <p>Rules must not fail if they encounter an empty row type, and should
+   * not produce empty row types (with reasonable exceptions, such as if the
+   * input has an empty row type).
+   */
+  DISCOURAGED,
+
+  /**
+   * Empty row types are allowed: it is acceptable for a {@link RelNode} to
+   * have an empty row type.
+   *
+   * <p>All rules should handle {@link RelNode}s with empty row types, and
+   * it's OK if they generate {@link RelNode}s with empty row types.
+   */
+  ALLOWED
+}
diff --git a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java 
b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
index c035c807da..5024eff0a1 100644
--- a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
+++ b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
@@ -29,6 +29,7 @@
 import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.plan.ViewExpanders;
 import org.apache.calcite.prepare.RelOptTableImpl;
+import org.apache.calcite.rel.EmptyRowTypePolicy;
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.RelCollations;
 import org.apache.calcite.rel.RelDistribution;
@@ -141,6 +142,7 @@
 
 import java.math.BigDecimal;
 import java.util.AbstractList;
+import java.util.AbstractQueue;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -150,10 +152,12 @@
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Queue;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
@@ -195,7 +199,11 @@
 public class RelBuilder {
   protected final RelOptCluster cluster;
   protected final @Nullable RelOptSchema relOptSchema;
-  private final Deque<Frame> stack = new ArrayDeque<>();
+  /** Stack of relational expressions that are being built.
+   *
+   * <p>Checks each relational expression for an empty row type as it is
+   * pushed; see {@link FrameStack}. */
+  private final FrameStack stack = new FrameStack();
   private RexSimplify simplifier;
   private final Config config;
   private final RelOptTable.ViewExpander viewExpander;
@@ -378,6 +386,17 @@ private void replaceTop(RelNode node) {
     stack.push(new Frame(node, frame.fields));
   }
 
+  /** Throws if the row type of {@code rel} is empty and the given
+   * {@code config}'s {@link Config#emptyRowTypePolicy()} is
+   * {@link EmptyRowTypePolicy#FORBIDDEN}. */
+  private static void checkEmptyRowType(Config config, RelNode rel) {
+    if (config.emptyRowTypePolicy() == EmptyRowTypePolicy.FORBIDDEN
+        && rel.getRowType().getFieldCount() == 0) {
+      throw new IllegalArgumentException("empty row type is forbidden by "
+          + "the emptyRowTypePolicy config: " + rel);
+    }
+  }
+
   /** Pushes a collection of relational expressions. */
   public RelBuilder pushAll(Iterable<? extends RelNode> nodes) {
     for (RelNode node : nodes) {
@@ -5351,6 +5370,62 @@ List<RelDataTypeField> fields() {
     }
   }
 
+  /** Stack of {@link Frame}s.
+   *
+   * <p>Extends {@link AbstractQueue} so that it is a {@link Queue}, but also
+   * provides the {@code push}, {@code pop} and {@code peek} methods of
+   * {@link Deque}, so that callers can treat it as a stack.
+   *
+   * <p>When a frame is pushed, its relational expression is checked: an
+   * {@link IllegalArgumentException} is thrown if its row type is empty and
+   * {@link Config#emptyRowTypePolicy()} is
+   * {@link EmptyRowTypePolicy#FORBIDDEN}.
+   */
+  private class FrameStack extends AbstractQueue<Frame> {
+    final Deque<Frame> deque = new ArrayDeque<>();
+
+    @Override public Iterator<Frame> iterator() {
+      return deque.iterator();
+    }
+
+    @Override public int size() {
+      return deque.size();
+    }
+
+    @Override public boolean offer(Frame frame) {
+      checkEmptyRowType(castNonNull(config), frame.rel);
+      deque.push(frame);
+      return true;
+    }
+
+    @Override public @Nullable Frame poll() {
+      return deque.pollFirst();
+    }
+
+    /** Pushes a frame onto the top of the stack. */
+    public void push(Frame frame) {
+      offer(frame);
+    }
+
+    /** Pops the frame at the top of the stack.
+     *
+     * <p>Throws {@link java.util.NoSuchElementException} if the stack is
+     * empty. */
+    public Frame pop() {
+      return deque.pop();
+    }
+
+    /** Returns the frame at the top of the stack, or null if the stack is
+     * empty. */
+    @Override public @Nullable Frame peek() {
+      return deque.peekFirst();
+    }
+
+    @Override public void clear() {
+      deque.clear();
+    }
+  }
+
   /** Shuttle that shifts a predicate's inputs to the left, replacing early
    * ones with references to a
    * {@link RexCorrelVariable}. */
@@ -5460,6 +5535,23 @@ public interface Config {
     /** Sets {@link #preventEmptyFieldList()}. */
     Config withPreventEmptyFieldList(boolean preventEmptyFieldList);
 
+    /** The policy that determines whether relational expressions may have an
+     * empty row type (a row type with zero fields); default
+     * {@link EmptyRowTypePolicy#DISCOURAGED}.
+     *
+     * <p>If the policy is {@link EmptyRowTypePolicy#FORBIDDEN}, methods throw
+     * an {@link IllegalArgumentException} if they create or are given a
+     * relational expression whose row type is empty.
+     *
+     * @see EmptyRowTypePolicy
+     */
+    @Value.Default default EmptyRowTypePolicy emptyRowTypePolicy() {
+      return EmptyRowTypePolicy.DISCOURAGED;
+    }
+
+    /** Sets {@link #emptyRowTypePolicy()}. */
+    Config withEmptyRowTypePolicy(EmptyRowTypePolicy emptyRowTypePolicy);
+
     /** Whether to push down join conditions; default false (but
      * {@link SqlToRelConverter#config()} by default sets this to true). */
     @Value.Default default boolean pushJoinCondition() {
diff --git 
a/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java 
b/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java
index 12c5fbeba3..1cbe8e76e3 100644
--- a/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java
+++ b/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java
@@ -19,6 +19,7 @@
 import org.apache.calcite.adapter.enumerable.EnumerableConvention;
 import org.apache.calcite.adapter.enumerable.EnumerableRules;
 import org.apache.calcite.adapter.enumerable.EnumerableUnion;
+import org.apache.calcite.plan.Contexts;
 import org.apache.calcite.plan.Convention;
 import org.apache.calcite.plan.ConventionTraitDef;
 import org.apache.calcite.plan.RelOptCluster;
@@ -28,6 +29,7 @@
 import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.plan.RelRule;
 import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.rel.EmptyRowTypePolicy;
 import org.apache.calcite.rel.RelCollationTraitDef;
 import org.apache.calcite.rel.RelCollations;
 import org.apache.calcite.rel.RelNode;
@@ -37,11 +39,15 @@
 import org.apache.calcite.rel.core.RelFactories;
 import org.apache.calcite.rel.externalize.RelDotWriter;
 import org.apache.calcite.rel.logical.LogicalProject;
+import org.apache.calcite.rel.logical.LogicalValues;
 import org.apache.calcite.rel.rules.CoreRules;
+import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.sql.SqlExplainLevel;
 import org.apache.calcite.tools.RelBuilder;
 import org.apache.calcite.util.Pair;
 
+import com.google.common.collect.ImmutableList;
+
 import org.immutables.value.Value;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
@@ -69,6 +75,7 @@
 import static org.apache.calcite.plan.volcano.PlannerTests.newCluster;
 import static org.apache.calcite.test.Matchers.isLinux;
 
+import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.instanceOf;
 import static org.hamcrest.CoreMatchers.is;
@@ -170,6 +177,45 @@ class VolcanoPlannerTest {
     assertThat(result.getInput(1), is(result.getInput(0)));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-4597";>[CALCITE-4597]
+   * Allow RelNodes to have an empty row type (zero fields)</a>. */
+  @Test void testEmptyRowTypePolicy() {
+    // Under the default policy, DISCOURAGED, registering a relational
+    // expression with an empty row type succeeds
+    final VolcanoPlanner planner = new VolcanoPlanner();
+    planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
+    final RelOptCluster cluster = newCluster(planner);
+    final RelDataType emptyRowType = 
cluster.getTypeFactory().builder().build();
+    final LogicalValues emptyValues =
+        LogicalValues.create(cluster, emptyRowType,
+            ImmutableList.of(ImmutableList.of()));
+    planner.setRoot(emptyValues);
+
+    // Under the ALLOWED policy, registration also succeeds
+    final VolcanoPlanner planner2 =
+        new VolcanoPlanner(null, Contexts.of(EmptyRowTypePolicy.ALLOWED));
+    planner2.addRelTraitDef(ConventionTraitDef.INSTANCE);
+    final RelOptCluster cluster2 = newCluster(planner2);
+    final LogicalValues emptyValues2 =
+        LogicalValues.create(cluster2, emptyRowType,
+            ImmutableList.of(ImmutableList.of()));
+    planner2.setRoot(emptyValues2);
+
+    // Under the FORBIDDEN policy, registration throws
+    final VolcanoPlanner planner3 =
+        new VolcanoPlanner(null, Contexts.of(EmptyRowTypePolicy.FORBIDDEN));
+    planner3.addRelTraitDef(ConventionTraitDef.INSTANCE);
+    final RelOptCluster cluster3 = newCluster(planner3);
+    final LogicalValues emptyValues3 =
+        LogicalValues.create(cluster3, emptyRowType,
+            ImmutableList.of(ImmutableList.of()));
+    final IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> 
planner3.setRoot(emptyValues3),
+        "expected empty row type to be forbidden");
+    assertThat(e.getMessage(), containsString("empty row type"));
+  }
+
   @Test void testPlanToDot() {
     VolcanoPlanner planner = new VolcanoPlanner();
     planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
diff --git a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java 
b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
index a5c571e523..257f1dfeb5 100644
--- a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
@@ -27,6 +27,7 @@
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.plan.RelTraitDef;
 import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.rel.EmptyRowTypePolicy;
 import org.apache.calcite.rel.RelCollations;
 import org.apache.calcite.rel.RelDistributions;
 import org.apache.calcite.rel.RelFieldCollation;
@@ -44,6 +45,7 @@
 import org.apache.calcite.rel.core.TableModify;
 import org.apache.calcite.rel.core.Window;
 import org.apache.calcite.rel.hint.RelHint;
+import org.apache.calcite.rel.logical.LogicalValues;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
@@ -1471,6 +1473,70 @@ private RexNode caseCall(RelBuilder b, RexNode ref, 
RexNode... nodes) {
     assertThat(root, hasTree(expected));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-4597";>[CALCITE-4597]
+   * Allow RelNodes to have an empty row type (zero fields)</a>.
+   *
+   * <p>Under the default policy, {@link EmptyRowTypePolicy#DISCOURAGED},
+   * {@code RelBuilder} does not throw if it encounters a relational
+   * expression with an empty row type, but tries not to create one: a
+   * {@code GROUP BY ()} with no aggregate calls becomes "VALUES (true)". */
+  @Test void testEmptyRowTypePolicyDiscouraged() {
+    // Equivalent SQL:
+    //   SELECT * FROM emp GROUP BY ()
+    final RelBuilder builder = createBuilder();
+    final RelNode root =
+        builder.scan("EMP")
+            .aggregate(builder.groupKey())
+            .build();
+    assertThat(root, hasTree("LogicalValues(tuples=[[{ true }]])\n"));
+  }
+
+  /** As {@link #testEmptyRowTypePolicyDiscouraged()}, but under the
+   * {@link EmptyRowTypePolicy#ALLOWED} policy, and therefore a
+   * {@code GROUP BY ()} with no aggregate calls becomes a
+   * {@code VALUES} with an empty row type (zero fields). */
+  @Test void testEmptyRowTypePolicyAllowed() {
+    final RelBuilder builder =
+        createBuilder(c -> c.withPreventEmptyFieldList(false)
+            .withEmptyRowTypePolicy(EmptyRowTypePolicy.ALLOWED));
+    final RelNode root =
+        builder.scan("EMP")
+            .aggregate(builder.groupKey())
+            .build();
+    assertThat(root.getRowType().getFieldCount(), is(0));
+    assertThat(root, hasTree("LogicalValues(tuples=[[{  }]])\n"));
+  }
+
+  /** As {@link #testEmptyRowTypePolicyDiscouraged()}, but under the
+   * {@link EmptyRowTypePolicy#FORBIDDEN} policy: creating or pushing a
+   * relational expression with an empty row type throws. */
+  @Test void testEmptyRowTypePolicyForbidden() {
+    final RelBuilder builder =
+        createBuilder(c -> c.withPreventEmptyFieldList(false)
+            .withEmptyRowTypePolicy(EmptyRowTypePolicy.FORBIDDEN));
+
+    // Equivalent SQL:
+    //   SELECT * FROM emp GROUP BY ()
+    final RelBuilder scan = builder.scan("EMP");
+    final IllegalArgumentException e0 =
+        assertThrows(IllegalArgumentException.class,
+            () -> scan.aggregate(scan.groupKey()).build(),
+            "expected empty row type to be forbidden");
+    assertThat(e0.getMessage(), containsString("empty row type"));
+
+    // Pushing a relational expression with an empty row type is also
+    // forbidden
+    final RelDataType emptyRowType = 
builder.getTypeFactory().builder().build();
+    final RelNode emptyValues =
+        LogicalValues.create(builder.getCluster(), emptyRowType,
+            ImmutableList.of(ImmutableList.of()));
+    final IllegalArgumentException e1 =
+        assertThrows(IllegalArgumentException.class, () -> 
builder.push(emptyValues),
+        "expected empty row type to be forbidden");
+    assertThat(e1.getMessage(), containsString("empty row type"));
+  }
+
   @Test void testAggregate2() {
     // Equivalent SQL:
     //   SELECT COUNT(*) AS c, SUM(mgr + 1) AS s

Reply via email to