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