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

xiedeyantu 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 5529cdf86b [CALCITE-6451] Improve Nullability Derivation for Intersect 
and Minus
5529cdf86b is described below

commit 5529cdf86bc54b750572bcca775a87fcb61c6443
Author: Zhen Chen <[email protected]>
AuthorDate: Sat Jun 6 10:13:38 2026 +0800

    [CALCITE-6451] Improve Nullability Derivation for Intersect and Minus
    
    Co-authored-by: Victor Barua <[email protected]>
---
 .../org/apache/calcite/rel/core/Intersect.java     |  11 +
 .../java/org/apache/calcite/rel/core/Minus.java    |  10 +
 .../java/org/apache/calcite/rel/core/SetOp.java    |   4 +
 .../calcite/rel/rules/IntersectToDistinctRule.java |   7 +
 .../calcite/rel/rules/MinusToDistinctRule.java     |   4 +
 .../org/apache/calcite/sql/SqlSetOperator.java     |   4 +-
 .../calcite/sql/fun/SqlStdOperatorTable.java       |  12 +-
 .../org/apache/calcite/sql/type/ReturnTypes.java   | 105 +++++++
 .../org/apache/calcite/test/RelBuilderTest.java    | 350 +++++++++++++++++++++
 .../org/apache/calcite/test/RelOptRulesTest.xml    |  17 +-
 core/src/test/resources/sql/planner.iq             |  25 +-
 .../java/org/apache/calcite/test/Matchers.java     |  13 +
 12 files changed, 535 insertions(+), 27 deletions(-)

diff --git a/core/src/main/java/org/apache/calcite/rel/core/Intersect.java 
b/core/src/main/java/org/apache/calcite/rel/core/Intersect.java
index e6ea2f6c24..8bed0bfd0d 100644
--- a/core/src/main/java/org/apache/calcite/rel/core/Intersect.java
+++ b/core/src/main/java/org/apache/calcite/rel/core/Intersect.java
@@ -22,7 +22,10 @@
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.hint.RelHint;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.type.ReturnTypes;
+import org.apache.calcite.util.Util;
 
 import java.util.Collections;
 import java.util.List;
@@ -79,4 +82,12 @@ protected Intersect(RelInput input) {
     dRows *= 0.25;
     return dRows;
   }
+
+  @Override protected RelDataType deriveRowType() {
+    // An output column is only nullable if it is nullable in ALL the inputs.
+    return ReturnTypes.deriveNullabilityForIntersect(
+        getCluster().getTypeFactory(),
+        deriveLeastRestrictiveRowType(),
+        Util.transform(getInputs(), RelNode::getRowType));
+  }
 }
diff --git a/core/src/main/java/org/apache/calcite/rel/core/Minus.java 
b/core/src/main/java/org/apache/calcite/rel/core/Minus.java
index 3a68945acc..badf20474a 100644
--- a/core/src/main/java/org/apache/calcite/rel/core/Minus.java
+++ b/core/src/main/java/org/apache/calcite/rel/core/Minus.java
@@ -23,7 +23,9 @@
 import org.apache.calcite.rel.hint.RelHint;
 import org.apache.calcite.rel.metadata.RelMdUtil;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.type.ReturnTypes;
 
 import java.util.Collections;
 import java.util.List;
@@ -60,4 +62,12 @@ protected Minus(RelInput input) {
   @Override public double estimateRowCount(RelMetadataQuery mq) {
     return RelMdUtil.getMinusRowCount(mq, this);
   }
+
+  @Override protected RelDataType deriveRowType() {
+    // The nullability of the output columns is the same as that of the 
primary input.
+    return ReturnTypes.deriveNullabilityForExcept(
+        getCluster().getTypeFactory(),
+        deriveLeastRestrictiveRowType(),
+        getInput(0).getRowType());
+  }
 }
diff --git a/core/src/main/java/org/apache/calcite/rel/core/SetOp.java 
b/core/src/main/java/org/apache/calcite/rel/core/SetOp.java
index 04e1409e78..9af6dd2ca9 100644
--- a/core/src/main/java/org/apache/calcite/rel/core/SetOp.java
+++ b/core/src/main/java/org/apache/calcite/rel/core/SetOp.java
@@ -114,6 +114,10 @@ public abstract SetOp copy(
   }
 
   @Override protected RelDataType deriveRowType() {
+    return deriveLeastRestrictiveRowType();
+  }
+
+  protected RelDataType deriveLeastRestrictiveRowType() {
     final List<RelDataType> inputRowTypes =
         Util.transform(inputs, RelNode::getRowType);
     final RelDataType rowType =
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/IntersectToDistinctRule.java 
b/core/src/main/java/org/apache/calcite/rel/rules/IntersectToDistinctRule.java
index a8d2713e09..394f8e0885 100644
--- 
a/core/src/main/java/org/apache/calcite/rel/rules/IntersectToDistinctRule.java
+++ 
b/core/src/main/java/org/apache/calcite/rel/rules/IntersectToDistinctRule.java
@@ -140,6 +140,10 @@ public void onMatchAggregateOnUnion(RelOptRuleCall call) {
 
     // Project all but the last added field (e.g. count_i{n})
     relBuilder.project(skipLast(relBuilder.fields(), branchCount));
+
+    // ensure the nullabilities of columns in the new relation match those of 
the input relation
+    relBuilder.convert(intersect.getRowType(), false);
+
     call.transformTo(relBuilder.build());
   }
 
@@ -206,6 +210,9 @@ public void onMatchAggregatePushdown(RelOptRuleCall call) {
     // Project all but the last field
     relBuilder.project(Util.skipLast(relBuilder.fields()));
 
+    // ensure the nullabilities of columns in the new relation match those of 
the input relation
+    relBuilder.convert(intersect.getRowType(), false);
+
     // the schema for intersect distinct matches that of the relation,
     // built here with an extra last column for the count,
     // which is projected out by the final project we added
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/MinusToDistinctRule.java 
b/core/src/main/java/org/apache/calcite/rel/rules/MinusToDistinctRule.java
index 7b14f2e946..59eaf0b60c 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/MinusToDistinctRule.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/MinusToDistinctRule.java
@@ -159,6 +159,10 @@ public MinusToDistinctRule(Class<? extends Minus> 
minusClass,
 
     relBuilder.filter(filters.build());
     relBuilder.project(Util.first(relBuilder.fields(), originalFieldCnt));
+
+    // ensure the nullabilities of columns in the new relation match those of 
the minus output
+    relBuilder.convert(minus.getRowType(), false);
+
     call.transformTo(relBuilder.build());
   }
 
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlSetOperator.java 
b/core/src/main/java/org/apache/calcite/sql/SqlSetOperator.java
index 6d0f48c4f4..0ce69276f7 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlSetOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlSetOperator.java
@@ -24,6 +24,8 @@
 import org.apache.calcite.sql.validate.SqlValidator;
 import org.apache.calcite.sql.validate.SqlValidatorScope;
 
+import org.checkerframework.checker.nullness.qual.Nullable;
+
 /**
  * SqlSetOperator represents a relational set theory operator (UNION, 
INTERSECT,
  * MINUS). These are binary operators, but with an extra boolean attribute
@@ -59,7 +61,7 @@ public SqlSetOperator(
       int prec,
       boolean all,
       SqlReturnTypeInference returnTypeInference,
-      SqlOperandTypeInference operandTypeInference,
+      @Nullable SqlOperandTypeInference operandTypeInference,
       SqlOperandTypeChecker operandTypeChecker) {
     super(
         name,
diff --git 
a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java 
b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
index 494fafeccc..b1f0c0d504 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
@@ -121,16 +121,20 @@ public class SqlStdOperatorTable extends 
ReflectiveSqlOperatorTable {
       new SqlSetOperator("UNION ALL", SqlKind.UNION, 12, true);
 
   public static final SqlSetOperator EXCEPT =
-      new SqlSetOperator("EXCEPT", SqlKind.EXCEPT, 12, false);
+      new SqlSetOperator("EXCEPT", SqlKind.EXCEPT, 12, false,
+          ReturnTypes.LEAST_RESTRICTIVE_EXCEPT, null, OperandTypes.SET_OP);
 
   public static final SqlSetOperator EXCEPT_ALL =
-      new SqlSetOperator("EXCEPT ALL", SqlKind.EXCEPT, 12, true);
+      new SqlSetOperator("EXCEPT ALL", SqlKind.EXCEPT, 12, true,
+          ReturnTypes.LEAST_RESTRICTIVE_EXCEPT, null, OperandTypes.SET_OP);
 
   public static final SqlSetOperator INTERSECT =
-      new SqlSetOperator("INTERSECT", SqlKind.INTERSECT, 14, false);
+      new SqlSetOperator("INTERSECT", SqlKind.INTERSECT, 14, false,
+          ReturnTypes.LEAST_RESTRICTIVE_INTERSECT, null, OperandTypes.SET_OP);
 
   public static final SqlSetOperator INTERSECT_ALL =
-      new SqlSetOperator("INTERSECT ALL", SqlKind.INTERSECT, 14, true);
+      new SqlSetOperator("INTERSECT ALL", SqlKind.INTERSECT, 14, true,
+          ReturnTypes.LEAST_RESTRICTIVE_INTERSECT, null, OperandTypes.SET_OP);
 
   /**
    * The {@code MULTISET UNION DISTINCT} operator.
diff --git a/core/src/main/java/org/apache/calcite/sql/type/ReturnTypes.java 
b/core/src/main/java/org/apache/calcite/sql/type/ReturnTypes.java
index 27f1a6fc72..9e32e0563d 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/ReturnTypes.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/ReturnTypes.java
@@ -34,9 +34,12 @@
 import org.apache.calcite.util.Glossary;
 import org.apache.calcite.util.Util;
 
+import com.google.common.collect.ImmutableList;
+
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 import java.util.AbstractList;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.function.UnaryOperator;
@@ -608,6 +611,108 @@ public static SqlCall stripSeparator(SqlCall call) {
         .leastRestrictive(opBinding.collectOperandTypes());
   }
 
+  /**
+   * Refines the nullability of {@code base} using INTERSECT semantics: a
+   * column is NOT NULL if it is NOT NULL in all of the
+   * {@code inputTypes} ("AND" semantics across inputs).
+   *
+   * <p>For ROW (struct) types, this recursively refines nullability of nested
+   * fields, ensuring the "AND" semantics apply at every level of nesting.
+   */
+  public static RelDataType deriveNullabilityForIntersect(
+      RelDataTypeFactory typeFactory,
+      RelDataType base,
+      List<RelDataType> inputTypes) {
+    return deriveNullable(typeFactory, base, inputTypes);
+  }
+
+  /**
+   * Refines the nullability of {@code base} using EXCEPT/MINUS semantics:
+   * each column's nullability matches that of the primary (first) input.
+   * For ROW types, this is applied recursively at all nesting levels.
+   */
+  public static RelDataType deriveNullabilityForExcept(
+      RelDataTypeFactory typeFactory,
+      RelDataType base,
+      RelDataType primaryInputType) {
+    return deriveNullable(typeFactory, base,
+        ImmutableList.of(primaryInputType));
+  }
+
+  /**
+   * Returns whether all types in the list are nullable (AND semantics).
+   */
+  private static boolean allNullable(List<RelDataType> types) {
+    for (RelDataType type : types) {
+      if (!type.isNullable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Recursively refines nullability using AND semantics:
+   * a column is NOT NULL if it is NOT NULL in ALL {@code inputTypes}.
+   * For ROW types, this is applied recursively to all nested fields.
+   */
+  private static RelDataType deriveNullable(
+      RelDataTypeFactory typeFactory,
+      RelDataType type,
+      List<RelDataType> inputTypes) {
+    if (type.isStruct()) {
+      final RelDataTypeFactory.Builder builder =
+          new RelDataTypeFactory.Builder(typeFactory);
+      final List<RelDataTypeField> fields = type.getFieldList();
+      for (int i = 0; i < fields.size(); i++) {
+        final List<RelDataType> fieldInputTypes = new ArrayList<>();
+        for (RelDataType inputType : inputTypes) {
+          fieldInputTypes.add(inputType.getFieldList().get(i).getType());
+        }
+        builder.add(fields.get(i).getName(),
+                deriveNullable(typeFactory,
+                    fields.get(i).getType(),
+                    fieldInputTypes))
+            .nullable(allNullable(fieldInputTypes));
+      }
+      return builder.build();
+    }
+    return typeFactory.createTypeWithNullability(type, 
allNullable(inputTypes));
+  }
+
+  /**
+   * Type-inference strategy for INTERSECT. Computes the least restrictive row
+   * type across all inputs, then refines nullability: a column is NOT NULL if
+   * it is NOT NULL in at least one input ("AND" semantics across inputs).
+   */
+  public static final SqlReturnTypeInference LEAST_RESTRICTIVE_INTERSECT =
+      andThen(SqlTypeTransforms.FROM_MEASURE_IF::apply, opBinding -> {
+        final List<RelDataType> inputTypes = opBinding.collectOperandTypes();
+        final RelDataType base =
+            opBinding.getTypeFactory().leastRestrictive(inputTypes);
+        if (base == null) {
+          return null;
+        }
+        return deriveNullabilityForIntersect(opBinding.getTypeFactory(), base, 
inputTypes);
+      });
+
+  /**
+   * Type-inference strategy for EXCEPT/MINUS. Computes the least restrictive
+   * row type across all inputs, then refines nullability: a column's
+   * nullability matches that of the first (primary) input.
+   */
+  public static final SqlReturnTypeInference LEAST_RESTRICTIVE_EXCEPT =
+      andThen(SqlTypeTransforms.FROM_MEASURE_IF::apply, opBinding -> {
+        final List<RelDataType> inputTypes = opBinding.collectOperandTypes();
+        final RelDataType base =
+            opBinding.getTypeFactory().leastRestrictive(inputTypes);
+        if (base == null) {
+          return null;
+        }
+        return deriveNullabilityForExcept(
+            opBinding.getTypeFactory(), base, inputTypes.get(0));
+      });
+
   /**
    * Type-inference strategy for NVL2 function. It returns the least 
restrictive type
    * between the second and third operands.
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 f1a4f1c271..e53f1027f7 100644
--- a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
@@ -131,6 +131,7 @@
 import static org.apache.calcite.test.Matchers.hasExpandedTree;
 import static org.apache.calcite.test.Matchers.hasFieldNames;
 import static org.apache.calcite.test.Matchers.hasHints;
+import static org.apache.calcite.test.Matchers.hasRelDataType;
 import static org.apache.calcite.test.Matchers.hasTree;
 
 import static org.hamcrest.CoreMatchers.allOf;
@@ -2346,6 +2347,72 @@ private static RelNode groupIdRel(RelBuilder builder, 
boolean extra) {
     assertThat(root, hasTree(expected));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6451";>[CALCITE-6451]
+   * Improve Nullability Derivation for Intersect and Minus</a>. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testUnionTypeDerivation(boolean all) {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    RelDataType input1RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelDataType input2RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(false)
+            .build();
+
+    RelDataType input3RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelNode root =
+        builder
+            .values(input1RowType)
+            .values(input2RowType)
+            .values(input3RowType)
+            .union(all, 3)
+            .build();
+
+    RelDataType expectedRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+    assertThat(root.getRowType(), hasRelDataType(expectedRowType));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-1522";>[CALCITE-1522]
    * Fix error message for SetOp with incompatible args</a>. */
@@ -2550,6 +2617,72 @@ private static RelNode groupIdRel(RelBuilder builder, 
boolean extra) {
     assertThat(root, hasTree(expected));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6451";>[CALCITE-6451]
+   * Improve Nullability Derivation for Intersect and Minus</a>. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testIntersectTypeDerivation(boolean all) {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    RelDataType input1RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelDataType input2RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelDataType input3RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelNode root =
+        builder
+            .values(input1RowType)
+            .values(input2RowType)
+            .values(input3RowType)
+            .intersect(all, 3)
+            .build();
+
+    RelDataType expectedRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+    assertThat(root.getRowType(), hasRelDataType(expectedRowType));
+  }
+
   @Test void testExcept() {
     // Equivalent SQL:
     //   SELECT empno FROM emp
@@ -2577,6 +2710,223 @@ private static RelNode groupIdRel(RelBuilder builder, 
boolean extra) {
     assertThat(root, hasTree(expected));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6451";>[CALCITE-6451]
+   * Improve Nullability Derivation for Intersect and Minus</a>. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testExceptTypeDerivation(boolean all) {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    RelDataType primaryRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelDataType secondaryRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("c", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("d", SqlTypeName.BIGINT)
+            .nullable(true)
+            .build();
+
+    RelNode root =
+        builder.values(primaryRowType)
+            .values(secondaryRowType)
+            .minus(all)
+            .build();
+
+    assertThat(root.getRowType(), hasRelDataType(primaryRowType));
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6451";>[CALCITE-6451]
+   * Improve Nullability Derivation for Intersect and Minus</a>. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testExceptTypeDerivationWithRowField(boolean all) {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // a BIGINT NOT NULL, b ROW(x INT NOT NULL, y VARCHAR NULL) NOT NULL
+    RelDataType primaryRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(false)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(true)
+                .build())
+            .nullableRecord(false)
+            .build();
+
+    // a BIGINT NULL, b ROW(x INT NULL, y VARCHAR NOT NULL) NOT NULL
+    RelDataType secondaryRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(true)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(false)
+                .build())
+            .nullableRecord(false)
+            .build();
+
+    RelNode root =
+        builder.values(primaryRowType)
+            .values(secondaryRowType)
+            .minus(all)
+            .build();
+
+    // follows primary input's nullability
+    assertThat(root.getRowType(), hasRelDataType(primaryRowType));
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6451";>[CALCITE-6451]
+   * Improve Nullability Derivation for Intersect and Minus</a>. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testIntersectTypeDerivationWithRowField(boolean all) {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // a BIGINT NOT NULL, b ROW(x INT NOT NULL, y VARCHAR NULL) NOT NULL
+    RelDataType input1RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(false)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(true)
+                .build())
+            .nullable(false)
+            .build();
+
+    // a BIGINT NULL, b ROW(x INT NULL, y VARCHAR NOT NULL) NOT NULL
+    RelDataType input2RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(true)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(false)
+                .build())
+            .nullable(false)
+            .build();
+
+    RelNode root =
+        builder
+            .values(input1RowType)
+            .values(input2RowType)
+            .intersect(all)
+            .build();
+
+    // a BIGINT NOT NULL
+    // a BIGINT NULL
+    // => a BIGINT NOT NULL
+    //
+    // b ROW(x INT NOT NULL, y VARCHAR NULL) NOT NULL
+    // b ROW(x INT NULL, y VARCHAR NOT NULL) NOT NULL
+    // => b ROW(x INT NOT NULL, y VARCHAR NOT NULL) NOT NULL
+    RelDataType expectedRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(false)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(false)
+                .build())
+            .nullable(false)
+            .build();
+    assertThat(root.getRowType(), hasRelDataType(expectedRowType));
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6451";>[CALCITE-6451]
+   * Improve Nullability Derivation for Intersect and Minus</a>. */
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testUnionTypeDerivationWithRowField(boolean all) {
+    final RelBuilder builder = RelBuilder.create(config().build());
+
+    // a BIGINT NOT NULL, b ROW(x INT NOT NULL, y VARCHAR NULL) NOT NULL
+    RelDataType input1RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(false)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(true)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(true)
+                .build())
+            .nullable(true)
+            .build();
+
+    // a BIGINT NULL, b ROW(x INT NULL, y VARCHAR NOT NULL) NOT NULL
+    RelDataType input2RowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(true)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(false)
+                .build())
+            .nullable(false)
+            .build();
+
+    RelNode root =
+        builder
+            .values(input1RowType)
+            .values(input2RowType)
+            .union(all)
+            .build();
+
+    // a BIGINT NOT NULL
+    // a BIGINT NULL
+    // => a BIGINT NULL
+    //
+    // b ROW(x INT NULL, y VARCHAR NULL) NULL
+    // b ROW(x INT NULL, y VARCHAR NOT NULL) NOT NULL
+    // => b ROW(x INT NULL, y VARCHAR NULL) NULL
+    RelDataType expectedRowType =
+        new RelDataTypeFactory.Builder(builder.getTypeFactory())
+            .add("a", SqlTypeName.BIGINT)
+            .nullable(true)
+            .add("b", builder.getTypeFactory().builder()
+                .add("x", SqlTypeName.INTEGER)
+                .nullable(true)
+                .add("y", SqlTypeName.VARCHAR, 10)
+                .nullable(true)
+                .build())
+            .nullable(true)
+            .build();
+    assertThat(root.getRowType(), hasRelDataType(expectedRowType));
+  }
+
   /** Tests building a simple join. Also checks {@link RelBuilder#size()}
    * at every step. */
   @Test void testJoin() {
diff --git 
a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml 
b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
index c782bb913c..c2fb106555 100644
--- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
@@ -7319,15 +7319,14 @@ LogicalIntersect(all=[false])
 LogicalAggregate(group=[{0}])
   LogicalJoin(condition=[IS NOT DISTINCT FROM($0, $1)], joinType=[semi])
     LogicalJoin(condition=[IS NOT DISTINCT FROM($0, $1)], joinType=[semi])
-      LogicalProject(ENAME=[CAST($0):VARCHAR])
+      LogicalProject(ENAME=[CAST($0):VARCHAR NOT NULL])
         LogicalProject(ENAME=[$1])
           LogicalFilter(condition=[=($7, 10)])
             LogicalTableScan(table=[[CATALOG, SALES, EMP]])
-      LogicalProject(ENAME=[CAST($0):VARCHAR])
-        LogicalProject(DEPTNO=[CAST($7):VARCHAR NOT NULL])
-          LogicalFilter(condition=[OR(=($1, 'a'), =($1, 'b'))])
-            LogicalTableScan(table=[[CATALOG, SALES, EMP]])
-    LogicalProject(ENAME=[CAST($0):VARCHAR])
+      LogicalProject(DEPTNO=[CAST($7):VARCHAR NOT NULL])
+        LogicalFilter(condition=[OR(=($1, 'a'), =($1, 'b'))])
+          LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+    LogicalProject(ENAME=[CAST($0):VARCHAR NOT NULL])
       LogicalProject(ENAME=[$1])
         LogicalTableScan(table=[[CATALOG, SALES, EMPNULLABLES]])
 ]]>
@@ -10453,10 +10452,10 @@ LogicalMinus(all=[false])
     </Resource>
     <Resource name="planAfter">
       <![CDATA[
-LogicalProject(ENAME=[CAST($0):VARCHAR])
+LogicalProject(ENAME=[CAST($0):VARCHAR NOT NULL])
   LogicalAggregate(group=[{0}])
-    LogicalJoin(condition=[IS NOT DISTINCT FROM(CAST($0):VARCHAR, 
CAST($1):VARCHAR)], joinType=[anti])
-      LogicalJoin(condition=[=(CAST($0):VARCHAR, $1)], joinType=[anti])
+    LogicalJoin(condition=[IS NOT DISTINCT FROM(CAST($0):VARCHAR NOT NULL, 
CAST($1):VARCHAR NOT NULL)], joinType=[anti])
+      LogicalJoin(condition=[=(CAST($0):VARCHAR NOT NULL, $1)], 
joinType=[anti])
         LogicalProject(ENAME=[$1])
           LogicalFilter(condition=[=($7, 10)])
             LogicalTableScan(table=[[CATALOG, SALES, EMP]])
diff --git a/core/src/test/resources/sql/planner.iq 
b/core/src/test/resources/sql/planner.iq
index 16168b820a..141b31d714 100644
--- a/core/src/test/resources/sql/planner.iq
+++ b/core/src/test/resources/sql/planner.iq
@@ -247,13 +247,13 @@ select a from (values (1.0), (4.0), (null)) as t3 (a);
 
 EnumerableAggregate(group=[{0}])
   EnumerableHashJoin(condition=[IS NOT DISTINCT FROM($0, $1)], joinType=[semi])
-    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
A=[$t1])
+    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], A=[$t1])
       EnumerableHashJoin(condition=[IS NOT DISTINCT FROM($0, $1)], 
joinType=[semi])
-        EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
A=[$t1])
+        EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], A=[$t1])
           EnumerableValues(tuples=[[{ 1.0 }, { 2.0 }, { 3.0 }, { 4.0 }, { 5.0 
}]])
-        EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
A=[$t1])
+        EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], A=[$t1])
           EnumerableValues(tuples=[[{ 1 }, { 2 }]])
-    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
A=[$t1])
+    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], A=[$t1])
       EnumerableValues(tuples=[[{ 1.0 }, { 4.0 }, { null }]])
 !plan
 !set planner-rules original
@@ -302,16 +302,15 @@ select a from (values (1.0), (4.0), (null)) as t3 (a);
 
 !ok
 
-EnumerableCalc(expr#0..1=[{inputs}], expr#2=[CAST($t0):DECIMAL(11, 1)], 
A=[$t2])
+EnumerableAggregate(group=[{0}])
   EnumerableHashJoin(condition=[=($1, $3)], joinType=[anti])
-    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
proj#0..1=[{exprs}])
-      EnumerableAggregate(group=[{0}])
-        EnumerableNestedLoopJoin(condition=[=(CAST($0):DECIMAL(11, 1) NOT 
NULL, CAST($1):DECIMAL(11, 1) NOT NULL)], joinType=[anti])
-          EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) 
NOT NULL], A=[$t1])
-            EnumerableValues(tuples=[[{ 1.0 }, { 2.0 }, { 3.0 }, { 4.0 }, { 
5.0 }]])
-          EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) 
NOT NULL], A=[$t1])
-            EnumerableValues(tuples=[[{ 1 }, { 2 }]])
-    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
A=[$t1], A0=[$t1])
+    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], proj#0..1=[{exprs}])
+      EnumerableNestedLoopJoin(condition=[=(CAST($0):DECIMAL(11, 1) NOT NULL, 
CAST($1):DECIMAL(11, 1) NOT NULL)], joinType=[anti])
+        EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], A=[$t1])
+          EnumerableValues(tuples=[[{ 1.0 }, { 2.0 }, { 3.0 }, { 4.0 }, { 5.0 
}]])
+        EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1) NOT 
NULL], A=[$t1])
+          EnumerableValues(tuples=[[{ 1 }, { 2 }]])
+    EnumerableCalc(expr#0=[{inputs}], expr#1=[CAST($t0):DECIMAL(11, 1)], 
expr#2=[CAST($t0):DECIMAL(11, 1) NOT NULL], A=[$t1], A0=[$t2])
       EnumerableValues(tuples=[[{ 1.0 }, { 4.0 }, { null }]])
 !plan
 !set planner-rules original
diff --git a/testkit/src/main/java/org/apache/calcite/test/Matchers.java 
b/testkit/src/main/java/org/apache/calcite/test/Matchers.java
index d410affe73..0ae23f31f1 100644
--- a/testkit/src/main/java/org/apache/calcite/test/Matchers.java
+++ b/testkit/src/main/java/org/apache/calcite/test/Matchers.java
@@ -20,6 +20,7 @@
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.RelValidityChecker;
 import org.apache.calcite.rel.hint.Hintable;
+import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.sql.SqlExplainLevel;
 import org.apache.calcite.util.TestUtil;
@@ -35,6 +36,7 @@
 import org.hamcrest.Matcher;
 import org.hamcrest.TypeSafeMatcher;
 import org.hamcrest.core.Is;
+import org.hamcrest.core.IsEqual;
 import org.hamcrest.core.StringContains;
 
 import java.lang.reflect.Array;
@@ -263,6 +265,17 @@ public static Matcher<RelNode> hasFieldNames(String 
fieldNames) {
       }
     };
   }
+
+  /**
+   * Creates a Matcher that matches a {@link RelDataType} if its
+   * {@link RelDataType#getFullTypeString()} is equal to that of the given 
{@code relDataType}.
+   */
+  public static Matcher<RelDataType> hasRelDataType(RelDataType relDataType) {
+    return compose(
+        IsEqual.equalTo(relDataType.getFullTypeString()),
+        RelDataType::getFullTypeString);
+  }
+
   /**
    * Creates a Matcher that matches a {@link RelNode} if its string
    * representation, after converting Windows-style line endings ("\r\n")


Reply via email to