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

tjbanghart 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 9c82eb4b68 [CALCITE-1440] Add Combine RelNode for converting multiple 
SQL statements to unified RelNode Tree
9c82eb4b68 is described below

commit 9c82eb4b68f2ae2dc4e410749f7419fcdabdea96
Author: TJ Banghart <[email protected]>
AuthorDate: Mon Sep 15 16:05:38 2025 -0700

    [CALCITE-1440] Add Combine RelNode for converting multiple SQL statements 
to unified RelNode Tree
---
 .../java/org/apache/calcite/rel/core/Combine.java  | 105 +++++++
 .../org/apache/calcite/rel/core/RelFactories.java  |  34 ++-
 .../rel/metadata/RelMdPercentageOriginalRows.java  |  48 +++
 .../apache/calcite/rel/metadata/RelMdRowCount.java |  15 +
 .../java/org/apache/calcite/tools/RelBuilder.java  |  43 ++-
 .../org/apache/calcite/test/RelBuilderTest.java    | 338 +++++++++++++++++++++
 .../GeneratedMetadata_CumulativeCostHandler.java   |   2 +
 ...atedMetadata_PercentageOriginalRowsHandler.java |   2 +
 .../janino/GeneratedMetadata_RowCountHandler.java  |   2 +
 9 files changed, 577 insertions(+), 12 deletions(-)

diff --git a/core/src/main/java/org/apache/calcite/rel/core/Combine.java 
b/core/src/main/java/org/apache/calcite/rel/core/Combine.java
new file mode 100644
index 0000000000..286facf2c8
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/rel/core/Combine.java
@@ -0,0 +1,105 @@
+/*
+ * 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.core;
+
+import org.apache.calcite.linq4j.Ord;
+import org.apache.calcite.plan.RelOptCluster;
+import org.apache.calcite.plan.RelOptCost;
+import org.apache.calcite.plan.RelOptPlanner;
+import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.rel.AbstractRelNode;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelWriter;
+import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.List;
+
+/**
+ * A relational operator that combines multiple relational expressions into a 
single root.
+ * This is used for multi-root optimization in the VolcanoPlanner.
+ */
+public class Combine extends AbstractRelNode {
+  protected final ImmutableList<RelNode> inputs;
+
+  /** Creates a Combine. */
+  public static Combine create(RelOptCluster cluster, RelTraitSet traitSet, 
List<RelNode> inputs) {
+    return new Combine(cluster, traitSet, inputs);
+  }
+
+
+  public Combine(RelOptCluster cluster, RelTraitSet traitSet, List<RelNode> 
inputs) {
+    super(cluster, traitSet);
+    this.inputs = ImmutableList.copyOf(inputs);
+  }
+
+  @Override public List<RelNode> getInputs() {
+    return inputs;
+  }
+
+  @Override public RelWriter explainTerms(RelWriter pw) {
+    super.explainTerms(pw);
+    for (Ord<RelNode> ord : Ord.zip(inputs)) {
+      pw.input("input#" + ord.i, ord.e);
+    }
+    return pw;
+  }
+
+  @Override protected RelDataType deriveRowType() {
+    // Combine represents multiple independent result sets that are not merged.
+    // Each input maintains its own row type and is accessed independently.
+    //
+    // We use a struct type where each field represents one of the input 
queries.
+    // This allows metadata and optimization rules to understand the structure
+    // while making it clear that results are not unified into a single stream.
+
+    RelDataTypeFactory typeFactory = getCluster().getTypeFactory();
+    RelDataTypeFactory.Builder builder = typeFactory.builder();
+
+    for (int i = 0; i < inputs.size(); i++) {
+      RelNode input = inputs.get(i);
+      // Create a field for each input with its row type
+      // Field names are "QUERY_0", "QUERY_1", etc.
+      builder.add("QUERY_" + i, input.getRowType());
+    }
+
+    return builder.build();
+  }
+
+  @Override public RelOptCost computeSelfCost(RelOptPlanner planner, 
RelMetadataQuery mq) {
+    // The self cost of Combine is minimal - it's just a structural operator
+    // that binds multiple queries together for optimization purposes.
+    // The real cost comes from executing all the inputs (handled by 
getCumulativeCost).
+
+    // We add a tiny cost to represent the overhead of managing multiple 
result sets
+    double rowCount = 0;
+    for (RelNode input : inputs) {
+      Double inputRows = mq.getRowCount(input);
+      rowCount += inputRows;
+    }
+
+    // Very small CPU cost for result set management
+    // No I/O cost since Combine doesn't read data itself
+    return planner.getCostFactory().makeCost(
+        rowCount,
+        rowCount * 0.01, // minimal CPU cost
+        0); // no I/O
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/rel/core/RelFactories.java 
b/core/src/main/java/org/apache/calcite/rel/core/RelFactories.java
index a8e53d9b74..9844219ecb 100644
--- a/core/src/main/java/org/apache/calcite/rel/core/RelFactories.java
+++ b/core/src/main/java/org/apache/calcite/rel/core/RelFactories.java
@@ -130,6 +130,9 @@ public class RelFactories {
   public static final RepeatUnionFactory DEFAULT_REPEAT_UNION_FACTORY =
       new RepeatUnionFactoryImpl();
 
+  public static final CombineFactory DEFAULT_COMBINE_FACTORY =
+      new CombineFactoryImpl();
+
   public static final Struct DEFAULT_STRUCT =
       new Struct(DEFAULT_FILTER_FACTORY,
           DEFAULT_PROJECT_FACTORY,
@@ -148,7 +151,8 @@ public class RelFactories {
           DEFAULT_SAMPLE_FACTORY,
           DEFAULT_MATCH_FACTORY,
           DEFAULT_SPOOL_FACTORY,
-          DEFAULT_REPEAT_UNION_FACTORY);
+          DEFAULT_REPEAT_UNION_FACTORY,
+          DEFAULT_COMBINE_FACTORY);
 
   /** A {@link RelBuilderFactory} that creates a {@link RelBuilder} that will
    * create logical relational expressions for everything. */
@@ -704,6 +708,25 @@ private static class RepeatUnionFactoryImpl implements 
RepeatUnionFactory {
     }
   }
 
+  /**
+   * Can create a {@link Combine} of the appropriate type for a rule's calling
+   * convention.
+   */
+  public interface CombineFactory {
+    /** Creates a {@link Combine}. */
+    RelNode createCombine(RelOptCluster cluster, List<RelNode> inputs);
+  }
+
+  /**
+   * Implementation of {@link CombineFactory} that returns a
+   * {@link Combine}.
+   */
+  private static class CombineFactoryImpl implements CombineFactory {
+    @Override public RelNode createCombine(RelOptCluster cluster, 
List<RelNode> inputs) {
+      return Combine.create(cluster, cluster.traitSet(), inputs);
+    }
+  }
+
   /** Immutable record that contains an instance of each factory. */
   public static class Struct {
     public final FilterFactory filterFactory;
@@ -724,6 +747,7 @@ public static class Struct {
     public final SampleFactory sampleFactory;
     public final SpoolFactory spoolFactory;
     public final RepeatUnionFactory repeatUnionFactory;
+    public final CombineFactory combineFactory;
 
     private Struct(FilterFactory filterFactory,
         ProjectFactory projectFactory,
@@ -742,7 +766,8 @@ private Struct(FilterFactory filterFactory,
         SampleFactory sampleFactory,
         MatchFactory matchFactory,
         SpoolFactory spoolFactory,
-        RepeatUnionFactory repeatUnionFactory) {
+        RepeatUnionFactory repeatUnionFactory,
+        CombineFactory combineFactory) {
       this.filterFactory = requireNonNull(filterFactory, "filterFactory");
       this.projectFactory = requireNonNull(projectFactory, "projectFactory");
       this.aggregateFactory = requireNonNull(aggregateFactory, 
"aggregateFactory");
@@ -762,6 +787,7 @@ private Struct(FilterFactory filterFactory,
       this.matchFactory = requireNonNull(matchFactory, "matchFactory");
       this.spoolFactory = requireNonNull(spoolFactory, "spoolFactory");
       this.repeatUnionFactory = requireNonNull(repeatUnionFactory, 
"repeatUnionFactory");
+      this.combineFactory = requireNonNull(combineFactory, "combineFactory");
     }
 
     public static Struct fromContext(Context context) {
@@ -805,7 +831,9 @@ public static Struct fromContext(Context context) {
           context.maybeUnwrap(SpoolFactory.class)
               .orElse(DEFAULT_SPOOL_FACTORY),
           context.maybeUnwrap(RepeatUnionFactory.class)
-              .orElse(DEFAULT_REPEAT_UNION_FACTORY));
+              .orElse(DEFAULT_REPEAT_UNION_FACTORY),
+          context.maybeUnwrap(CombineFactory.class)
+              .orElse(DEFAULT_COMBINE_FACTORY));
     }
   }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdPercentageOriginalRows.java
 
b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdPercentageOriginalRows.java
index 4e530f8809..e2ec6d94b8 100644
--- 
a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdPercentageOriginalRows.java
+++ 
b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdPercentageOriginalRows.java
@@ -20,6 +20,7 @@
 import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Aggregate;
+import org.apache.calcite.rel.core.Combine;
 import org.apache.calcite.rel.core.Join;
 import org.apache.calcite.rel.core.TableScan;
 import org.apache.calcite.rel.core.Union;
@@ -123,6 +124,32 @@ public Double getPercentageOriginalRows(Union rel, 
RelMetadataQuery mq) {
     return left * right;
   }
 
+  public @Nullable Double getPercentageOriginalRows(Combine rel, 
RelMetadataQuery mq) {
+    // For Combine, we return the weighted average of the percentage
+    // original rows across all inputs, weighted by row count
+    double totalRows = 0;
+    double weightedPercentage = 0;
+
+    for (RelNode input : rel.getInputs()) {
+      Double rowCount = mq.getRowCount(input);
+      if (rowCount == null) {
+        return null;
+      }
+      Double percentage = mq.getPercentageOriginalRows(input);
+      if (percentage == null) {
+        return null;
+      }
+      totalRows += rowCount;
+      weightedPercentage += rowCount * percentage;
+    }
+
+    if (totalRows == 0) {
+      return 1.0; // No rows, so 100% original
+    }
+
+    return weightedPercentage / totalRows;
+  }
+
   // Catch-all rule when none of the others apply.
   public @Nullable Double getPercentageOriginalRows(RelNode rel, 
RelMetadataQuery mq) {
     if (rel.getInputs().size() > 1) {
@@ -182,6 +209,27 @@ public Double getPercentageOriginalRows(Union rel, 
RelMetadataQuery mq) {
     return mq.getNonCumulativeCost(rel);
   }
 
+  public @Nullable RelOptCost getCumulativeCost(Combine rel, RelMetadataQuery 
mq) {
+    // For Combine, the cumulative cost is the sum of all input costs
+    // since all inputs need to be executed (unlike Union where rows flow 
through)
+    RelOptCost cost = mq.getNonCumulativeCost(rel);
+    if (cost == null) {
+      return null;
+    }
+
+    // Add the cumulative cost of each input
+    // All inputs contribute to the total cost since they all must be executed
+    for (RelNode input : rel.getInputs()) {
+      RelOptCost inputCost = mq.getCumulativeCost(input);
+      if (inputCost == null) {
+        return null;
+      }
+      cost = cost.plus(inputCost);
+    }
+
+    return cost;
+  }
+
   // Ditto for getNonCumulativeCost
   public @Nullable RelOptCost getNonCumulativeCost(RelNode rel, 
RelMetadataQuery mq) {
     return rel.computeSelfCost(rel.getCluster().getPlanner(), mq);
diff --git 
a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdRowCount.java 
b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdRowCount.java
index eeb8cc9adc..637e8777cc 100644
--- a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdRowCount.java
+++ b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdRowCount.java
@@ -23,6 +23,7 @@
 import org.apache.calcite.rel.SingleRel;
 import org.apache.calcite.rel.core.Aggregate;
 import org.apache.calcite.rel.core.Calc;
+import org.apache.calcite.rel.core.Combine;
 import org.apache.calcite.rel.core.Exchange;
 import org.apache.calcite.rel.core.Filter;
 import org.apache.calcite.rel.core.Intersect;
@@ -87,6 +88,20 @@ public class RelMdRowCount
     return Util.first(v, 1e6d); // if set is empty, estimate large
   }
 
+  public @Nullable Double getRowCount(Combine rel, RelMetadataQuery mq) {
+    // For Combine, the row count is the sum of all input row counts
+    // since each input represents a separate query that will be executed
+    double totalRowCount = 0.0;
+    for (RelNode input : rel.getInputs()) {
+      Double inputRowCount = mq.getRowCount(input);
+      if (inputRowCount == null) {
+        return null;
+      }
+      totalRowCount += inputRowCount;
+    }
+    return totalRowCount;
+  }
+
   public @Nullable Double getRowCount(Union rel, RelMetadataQuery mq) {
     double rowCount = 0.0;
     for (RelNode input : rel.getInputs()) {
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 90df447ebc..2aca3fc58a 100644
--- a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
+++ b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
@@ -36,6 +36,7 @@
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Aggregate;
 import org.apache.calcite.rel.core.AggregateCall;
+import org.apache.calcite.rel.core.Combine;
 import org.apache.calcite.rel.core.Correlate;
 import org.apache.calcite.rel.core.CorrelationId;
 import org.apache.calcite.rel.core.Filter;
@@ -1185,8 +1186,8 @@ public RexNode cast(RexNode expr, SqlTypeName typeName, 
int precision) {
     return cast(SqlParserPos.ZERO, expr, typeName, precision);
   }
 
-    /** Creates an expression that casts an expression to a type with a given 
name
-     * and precision or length. */
+  /** Creates an expression that casts an expression to a type with a given 
name
+   * and precision or length. */
   public RexNode cast(SqlParserPos pos, RexNode expr, SqlTypeName typeName, 
int precision) {
     final RelDataType type =
         cluster.getTypeFactory().createSqlType(typeName, precision);
@@ -2360,11 +2361,11 @@ public RelBuilder uncollect(List<String> itemAliases, 
boolean withOrdinality) {
     stack.push(
         new Frame(
           new Uncollect(
-            cluster,
-            cluster.traitSetOf(Convention.NONE),
-            frame.rel,
-            withOrdinality,
-            requireNonNull(itemAliases, "itemAliases"))));
+              cluster,
+              cluster.traitSetOf(Convention.NONE),
+              frame.rel,
+              withOrdinality,
+              requireNonNull(itemAliases, "itemAliases"))));
     return this;
   }
 
@@ -2840,8 +2841,7 @@ private RelBuilder rewriteAggregateWithDuplicateGroupSets(
     for (Multiset.Entry<ImmutableBitSet> entry : groupSets.entrySet()) {
       int groupId = entry.getCount() - 1;
       for (int i = 0; i <= groupId; i++) {
-        groupIdToGroupSets.computeIfAbsent(i,
-            k -> Sets.newTreeSet(ImmutableBitSet.COMPARATOR))
+        groupIdToGroupSets.computeIfAbsent(i, k -> 
Sets.newTreeSet(ImmutableBitSet.COMPARATOR))
             .add(entry.getElement());
       }
     }
@@ -5398,4 +5398,29 @@ private interface RegisterAgg {
     RexInputRef registerAgg(SqlAggFunction op, List<RexNode> operands,
         RelDataType type, @Nullable String name);
   }
+
+  /** Creates a {@link Combine} of the top {@code n} relational expressions
+   * on the stack. */
+  public RelBuilder combine(int n) {
+    final List<RelNode> inputs = new ArrayList<>();
+    for (int i = 0; i < n; i++) {
+      inputs.add(0, peek(i));
+    }
+    return push(struct.combineFactory.createCombine(cluster, inputs));
+  }
+
+  /** Creates a {@link Combine} of all relational expressions on the stack. */
+  public RelBuilder combine() {
+    return combine(size());
+  }
+
+  /** Creates a {@link Combine} of the given relational expressions. */
+  public RelBuilder combine(RelNode... inputs) {
+    return push(struct.combineFactory.createCombine(cluster, 
Arrays.asList(inputs)));
+  }
+
+  /** Creates a {@link Combine} of the given relational expressions. */
+  public RelBuilder combine(Iterable<? extends RelNode> inputs) {
+    return push(struct.combineFactory.createCombine(cluster, 
ImmutableList.copyOf(inputs)));
+  }
 }
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 eb4f719991..f1dc0baac9 100644
--- a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
@@ -23,6 +23,7 @@
 import org.apache.calcite.jdbc.CalciteConnection;
 import org.apache.calcite.plan.Contexts;
 import org.apache.calcite.plan.Convention;
+import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.plan.RelTraitDef;
 import org.apache.calcite.plan.RelTraitSet;
@@ -31,9 +32,11 @@
 import org.apache.calcite.rel.RelFieldCollation;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.AggregateCall;
+import org.apache.calcite.rel.core.Combine;
 import org.apache.calcite.rel.core.Correlate;
 import org.apache.calcite.rel.core.CorrelationId;
 import org.apache.calcite.rel.core.Exchange;
+import org.apache.calcite.rel.core.Filter;
 import org.apache.calcite.rel.core.JoinRelType;
 import org.apache.calcite.rel.core.Project;
 import org.apache.calcite.rel.core.Sort;
@@ -124,6 +127,7 @@
 import java.util.function.UnaryOperator;
 import java.util.stream.Collectors;
 
+import static org.apache.calcite.test.Matchers.containsStringLinux;
 import static org.apache.calcite.test.Matchers.hasExpandedTree;
 import static org.apache.calcite.test.Matchers.hasFieldNames;
 import static org.apache.calcite.test.Matchers.hasHints;
@@ -136,6 +140,8 @@
 import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.CoreMatchers.nullValue;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.hasToString;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -5585,6 +5591,289 @@ private static RelNode 
buildCorrelateWithJoin(JoinRelType type, RelBuilder build
     assertThat(root, hasTree(expected));
   }
 
+
+  @Test void testCombine() {
+    // Create two separate queries
+    final RelBuilder builder = RelBuilder.create(config().build());
+    RelNode scan1 = builder.scan("EMP")
+        .filter(builder.equals(builder.field("DEPTNO"), builder.literal(10)))
+        .project(builder.field("ENAME"))
+        .build();
+    RelNode scan2 = builder.scan("EMP")
+        .filter(builder.equals(builder.field("DEPTNO"), builder.literal(20)))
+        .project(builder.field("SAL"))
+        .build();
+
+    // Test combine with varargs
+    RelNode combine1 = builder.combine(scan1, scan2).build();
+    assertThat(combine1, instanceOf(Combine.class));
+    assertThat(combine1.getInputs(), hasSize(2));
+
+    // Test combine with Iterable
+    RelNode combine2 = builder.combine(Arrays.asList(scan1, scan2)).build();
+    assertThat(combine2, instanceOf(Combine.class));
+    assertThat(combine2.getInputs(), hasSize(2));
+
+    // Test combine with stack
+    builder.push(scan1)
+        .push(scan2);
+    RelNode combine3 = builder.combine(2).build();
+    assertThat(combine3, instanceOf(Combine.class));
+    assertThat(((Combine) combine3).getInputs(), hasSize(2));
+
+    // Test combine with all stack
+    builder.clear();
+    builder.push(scan1)
+        .push(scan2);
+    RelNode combine4 = builder.combine().build();
+    assertThat(combine4, instanceOf(Combine.class));
+    assertThat(combine4.getInputs(), hasSize(2));
+  }
+
+  @Test void testCombineWithSharedSubexpression() {
+    // Test that demonstrates how Combine can optimize queries with shared 
subexpressions
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Create a common subexpression - employees with salary > 1000
+    builder.scan("EMP")
+        .filter(
+            builder.call(SqlStdOperatorTable.GREATER_THAN,
+            builder.field("SAL"), builder.literal(1000)));
+    RelNode commonSubexpr = builder.build();
+
+    // Query 1: Count high earners by department
+    RelNode query1 = builder.push(commonSubexpr)
+        .aggregate(builder.groupKey("DEPTNO"), builder.count())
+        .build();
+
+    // Query 2: Get names of high earners
+    RelNode query2 = builder.push(commonSubexpr)
+        .project(builder.field("ENAME"), builder.field("SAL"))
+        .build();
+
+    // Combine both queries - planner can recognize shared subexpression
+    RelNode combined = builder.combine(query1, query2).build();
+
+    assertThat(combined, instanceOf(Combine.class));
+    assertThat(combined.getInputs(), hasSize(2));
+    // Both queries share the same filter as their base
+    assertThat(combined.getInputs().get(0).getInput(0), 
instanceOf(Filter.class));
+    assertThat(combined.getInputs().get(1).getInput(0), 
instanceOf(Filter.class));
+  }
+
+  @Test void testCombineDifferentRowTypes() {
+    // Test that Combine doesn't require inputs to have the same row type.
+    // Contrast to Union which requires row types to unify to the same row 
type, element-wise.
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Query 1: Returns (ENAME: VARCHAR)
+    RelNode query1 = builder.scan("EMP")
+        .project(builder.field("ENAME"))
+        .build();
+
+    // Query 2: Returns (DEPTNO: INTEGER, AVG_SAL: DECIMAL)
+    RelNode query2 = builder.scan("EMP")
+        .aggregate(builder.groupKey("DEPTNO"),
+            builder.avg(false, "AVG_SAL", builder.field("SAL")))
+        .build();
+
+    // Query 3: Returns just count (COUNT: BIGINT)
+    RelNode query3 = builder.scan("DEPT")
+        .aggregate(builder.groupKey(), builder.count())
+        .build();
+
+    // Combine can handle different row types
+    RelNode combined = builder.combine(query1, query2, query3).build();
+
+    assertThat(combined, instanceOf(Combine.class));
+    assertThat(combined.getInputs(), hasSize(3));
+    // Verify each has different row type
+    assertThat(combined.getInputs().get(0).getRowType().getFieldCount(), 
is(1));
+    assertThat(combined.getInputs().get(1).getRowType().getFieldCount(), 
is(2));
+    assertThat(combined.getInputs().get(2).getRowType().getFieldCount(), 
is(1));
+  }
+
+  @Test void testCombineMetadata() {
+    // Test metadata methods for Combine operator
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Create queries with known characteristics
+    RelNode query1 = builder.scan("EMP")
+        .filter(builder.equals(builder.field("DEPTNO"), builder.literal(10)))
+        .build();
+
+    RelNode query2 = builder.scan("DEPT")
+        .project(builder.field("DNAME"))
+        .build();
+
+    RelNode combined = builder.combine(query1, query2).build();
+
+    // Test that Combine has proper metadata
+    assertThat(combined, instanceOf(Combine.class));
+
+    RelMetadataQuery mq = combined.getCluster().getMetadataQuery();
+
+    // Test row count - should be sum of inputs
+    Double rowCount = mq.getRowCount(combined);
+    assertThat(rowCount, notNullValue());
+
+    // Test cost computation
+    RelOptCost selfCost =
+        combined.computeSelfCost(combined.getCluster().getPlanner(), mq);
+    assertThat(selfCost, notNullValue());
+    assertThat(selfCost.getCpu(), greaterThan(0.0));
+
+    // Test cumulative cost includes all inputs
+    RelOptCost cumulativeCost = mq.getCumulativeCost(combined);
+    assertThat(cumulativeCost, notNullValue());
+
+    // Cumulative cost should be greater than self cost
+    assertThat(cumulativeCost.isLt(selfCost), is(false));
+  }
+
+  @Test void testCombineCumulativeCost() {
+    // Comprehensive test for getCumulativeCost with Combine operator
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Create three queries with different complexities
+    // Query 1: Simple scan
+    RelNode scan = builder.scan("EMP").build();
+
+    // Query 2: Scan with filter
+    RelNode filtered = builder.scan("EMP")
+        .filter(
+            builder.call(SqlStdOperatorTable.GREATER_THAN,
+            builder.field("SAL"), builder.literal(1000)))
+        .build();
+
+    // Query 3: Scan with aggregation
+    RelNode aggregated = builder.scan("EMP")
+        .aggregate(builder.groupKey("DEPTNO"),
+            builder.count(false, "CNT"),
+            builder.sum(false, "SUM_SAL", builder.field("SAL")))
+        .build();
+
+    // Create Combine with all three queries
+    RelNode combined = builder.combine(scan, filtered, aggregated).build();
+
+    RelMetadataQuery mq = combined.getCluster().getMetadataQuery();
+
+    // Get individual cumulative costs
+    RelOptCost scanCost = mq.getCumulativeCost(scan);
+    RelOptCost filteredCost = mq.getCumulativeCost(filtered);
+    RelOptCost aggregatedCost = mq.getCumulativeCost(aggregated);
+
+    assertThat(scanCost, notNullValue());
+    assertThat(filteredCost, notNullValue());
+    assertThat(aggregatedCost, notNullValue());
+
+    // Get Combine's costs
+    RelOptCost combineSelfCost =
+        combined.computeSelfCost(combined.getCluster().getPlanner(), mq);
+    RelOptCost combineCumulativeCost = mq.getCumulativeCost(combined);
+
+    assertThat(combineSelfCost, notNullValue());
+    assertThat(combineCumulativeCost, notNullValue());
+
+    // Verify cumulative cost is sum of all input costs plus self cost
+    RelOptCost expectedTotal = combineSelfCost
+        .plus(scanCost)
+        .plus(filteredCost)
+        .plus(aggregatedCost);
+
+    // The cumulative cost should equal the sum
+    assertThat(combineCumulativeCost.getRows(),
+        is(expectedTotal.getRows()));
+    assertThat(combineCumulativeCost.getCpu(),
+        is(expectedTotal.getCpu()));
+    assertThat(combineCumulativeCost.getIo(),
+        is(expectedTotal.getIo()));
+
+    // Verify relationships between costs
+    // Filtered should cost more than simple scan
+    assertThat(filteredCost.isLt(scanCost), is(false));
+
+    // Aggregated should cost more than simple scan
+    assertThat(aggregatedCost.isLt(scanCost), is(false));
+
+    // Combined cumulative cost should be greater than any individual cost
+    assertThat(combineCumulativeCost.isLt(scanCost), is(false));
+    assertThat(combineCumulativeCost.isLt(filteredCost), is(false));
+    assertThat(combineCumulativeCost.isLt(aggregatedCost), is(false));
+  }
+
+  @Test void testCombineCumulativeCostWithSharedInputs() {
+    // Test cumulative cost when Combine has shared subexpressions
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Create a base scan that will be shared
+    RelNode baseScan = builder.scan("EMP").build();
+    RelOptCost baseScanCost =
+        baseScan.computeSelfCost(baseScan.getCluster().getPlanner(),
+            baseScan.getCluster().getMetadataQuery());
+    assertThat(baseScanCost, notNullValue());
+
+    // Create two queries that use the same base scan
+    RelNode query1 = builder.push(baseScan)
+        .filter(builder.equals(builder.field("DEPTNO"), builder.literal(10)))
+        .project(builder.field("ENAME"))
+        .build();
+
+    RelNode query2 = builder.push(baseScan)
+        .filter(builder.equals(builder.field("DEPTNO"), builder.literal(20)))
+        .aggregate(builder.groupKey(), builder.count())
+        .build();
+
+    // Combine the queries
+    RelNode combined = builder.combine(query1, query2).build();
+
+    RelMetadataQuery mq = combined.getCluster().getMetadataQuery();
+
+    // Get costs
+    RelOptCost query1Cost = mq.getCumulativeCost(query1).minus(baseScanCost);
+    RelOptCost query2Cost = mq.getCumulativeCost(query2).minus(baseScanCost);
+    RelOptCost combinedCost = mq.getCumulativeCost(combined);
+
+    assertThat(query1Cost, notNullValue());
+    assertThat(query2Cost, notNullValue());
+    assertThat(combinedCost, notNullValue());
+
+    // Even with shared base scan, Combine's cumulative cost
+    // should include full cost of each branch
+    RelOptCost combineSelfCost =
+        combined.computeSelfCost(combined.getCluster().getPlanner(), mq);
+    RelOptCost expectedTotal = combineSelfCost
+        .plus(baseScanCost.multiplyBy(2)) // base scan counted twice since 
used in both queries
+        .plus(query1Cost)
+        .plus(query2Cost);
+
+    assertThat(combinedCost.getRows(), is(expectedTotal.getRows()));
+    assertThat(combinedCost.getCpu(), is(expectedTotal.getCpu()));
+    assertThat(combinedCost.getIo(), is(expectedTotal.getIo()));
+  }
+
+  @Test void testCombineCumulativeCostEmpty() {
+    // Test edge case with empty inputs
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Create an empty values relation
+    RelNode emptyValues = builder.values(new String[]{"x"}, 0).build();
+
+    // Create a normal query
+    RelNode normalQuery = builder.scan("DEPT").build();
+
+    // Combine empty and normal
+    RelNode combined = builder.combine(emptyValues, normalQuery).build();
+
+    RelMetadataQuery mq = combined.getCluster().getMetadataQuery();
+
+    // Should still compute costs correctly even with empty input
+    RelOptCost combinedCost = mq.getCumulativeCost(combined);
+    assertThat(combinedCost, notNullValue());
+    assertThat(combinedCost.getRows(), greaterThan(-1.0));
+    assertThat(combinedCost.getCpu(), greaterThan(-1.0));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-4415";>[CALCITE-4415]
    * SqlStdOperatorTable.NOT_LIKE has a wrong implementor</a>. */
@@ -5609,6 +5898,55 @@ private static RelNode 
buildCorrelateWithJoin(JoinRelType type, RelBuilder build
             "empid=150; name=Sebastian");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-1440";>[CALCITE-1440]
+   * Combine operator - combines multiple SQL queries into a single plan</a>. 
*/
+  @Test void testCombineExplain() {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // Query 1: Simple filter on department 10
+    RelNode query1 = builder
+        .scan("EMP")
+        .filter(builder.equals(builder.field("DEPTNO"), builder.literal(10)))
+        .project(builder.field("ENAME"), builder.field("SAL"))
+        .build();
+
+    // Query 2: Aggregate to get average salary by department
+    RelNode query2 = builder
+        .scan("EMP")
+        .aggregate(builder.groupKey("DEPTNO"),
+            builder.avg(false, "AVG_SAL", builder.field("SAL")))
+        .build();
+
+    // Query 3: Find all departments
+    RelNode query3 = builder
+        .scan("DEPT")
+        .project(builder.field("DEPTNO"), builder.field("DNAME"))
+        .build();
+
+    // Combine all three queries
+    RelNode combined = builder.combine(query1, query2, query3).build();
+
+    // Test that the combine node is of the correct type
+    assertThat(combined, instanceOf(Combine.class));
+    Combine combineNode = (Combine) combined;
+    assertThat(combineNode.getInputs(), hasSize(3));
+
+    String expectedPlan =
+          "Combine\n"
+        + "  LogicalProject(ENAME=[$1], SAL=[$5])\n"
+        + "    LogicalFilter(condition=[=($7, 10)])\n"
+        + "      LogicalTableScan(table=[[scott, EMP]])\n"
+        + "  LogicalAggregate(group=[{7}], AVG_SAL=[AVG($5)])\n"
+        + "    LogicalTableScan(table=[[scott, EMP]])\n"
+        + "  LogicalProject(DEPTNO=[$0], DNAME=[$1])\n"
+        + "    LogicalTableScan(table=[[scott, DEPT]])\n";
+
+    // Test that explain() works correctly for Combine
+    String explanation = combined.explain();
+    assertThat(explanation, containsStringLinux(expectedPlan));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6688";>[CALCITE-6688]
    * Allow operators of SqlKind.SYMMETRICAL to be reversed</a>. */
diff --git 
a/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_CumulativeCostHandler.java
 
b/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_CumulativeCostHandler.java
index 0f76154ec7..d77b827cb9 100644
--- 
a/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_CumulativeCostHandler.java
+++ 
b/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_CumulativeCostHandler.java
@@ -62,6 +62,8 @@ private org.apache.calcite.plan.RelOptCost getCumulativeCost_(
       org.apache.calcite.rel.metadata.RelMetadataQuery mq) {
     if (r instanceof 
org.apache.calcite.adapter.enumerable.EnumerableInterpreter) {
       return 
provider0.getCumulativeCost((org.apache.calcite.adapter.enumerable.EnumerableInterpreter)
 r, mq);
+    } else if (r instanceof org.apache.calcite.rel.core.Combine) {
+      return provider0.getCumulativeCost((org.apache.calcite.rel.core.Combine) 
r, mq);
     } else if (r instanceof org.apache.calcite.rel.RelNode) {
       return provider0.getCumulativeCost((org.apache.calcite.rel.RelNode) r, 
mq);
     } else {
diff --git 
a/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_PercentageOriginalRowsHandler.java
 
b/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_PercentageOriginalRowsHandler.java
index d83e952a92..d52c75d355 100644
--- 
a/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_PercentageOriginalRowsHandler.java
+++ 
b/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_PercentageOriginalRowsHandler.java
@@ -62,6 +62,8 @@ private java.lang.Double getPercentageOriginalRows_(
       org.apache.calcite.rel.metadata.RelMetadataQuery mq) {
     if (r instanceof org.apache.calcite.rel.core.Aggregate) {
       return 
provider0.getPercentageOriginalRows((org.apache.calcite.rel.core.Aggregate) r, 
mq);
+    } else if (r instanceof org.apache.calcite.rel.core.Combine) {
+      return 
provider0.getPercentageOriginalRows((org.apache.calcite.rel.core.Combine) r, 
mq);
     } else if (r instanceof org.apache.calcite.rel.core.Join) {
       return 
provider0.getPercentageOriginalRows((org.apache.calcite.rel.core.Join) r, mq);
     } else if (r instanceof org.apache.calcite.rel.core.TableScan) {
diff --git 
a/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_RowCountHandler.java
 
b/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_RowCountHandler.java
index cef8019f14..9e6cc7205c 100644
--- 
a/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_RowCountHandler.java
+++ 
b/core/src/test/resources/org/apache/calcite/rel/metadata/janino/GeneratedMetadata_RowCountHandler.java
@@ -68,6 +68,8 @@ private java.lang.Double getRowCount_(
       return provider0.getRowCount((org.apache.calcite.rel.core.Aggregate) r, 
mq);
     } else if (r instanceof org.apache.calcite.rel.core.Calc) {
       return provider0.getRowCount((org.apache.calcite.rel.core.Calc) r, mq);
+    } else if (r instanceof org.apache.calcite.rel.core.Combine) {
+      return provider0.getRowCount((org.apache.calcite.rel.core.Combine) r, 
mq);
     } else if (r instanceof org.apache.calcite.rel.core.Exchange) {
       return provider0.getRowCount((org.apache.calcite.rel.core.Exchange) r, 
mq);
     } else if (r instanceof org.apache.calcite.rel.core.Filter) {


Reply via email to