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

mihaibudiu 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 d9af5c187f [CALCITE-7670] Uncollect should support LEFT JOIN UNNEST
d9af5c187f is described below

commit d9af5c187f338739d7243bf91fc730796347b8a3
Author: Mihai Budiu <[email protected]>
AuthorDate: Tue Aug 4 15:44:49 2026 -0700

    [CALCITE-7670] Uncollect should support LEFT JOIN UNNEST
    
    Signed-off-by: Mihai Budiu <[email protected]>
---
 .../adapter/enumerable/EnumerableUncollect.java    |  30 +-
 .../enumerable/EnumerableUncollectRule.java        |   3 +-
 .../apache/calcite/interpreter/UncollectNode.java  |  15 +
 .../org/apache/calcite/rel/core/Uncollect.java     |  47 ++-
 .../calcite/rel/logical/ToLogicalConverter.java    |   2 +-
 .../apache/calcite/rel/mutable/MutableRels.java    |   4 +-
 .../calcite/rel/mutable/MutableUncollect.java      |  32 ++-
 .../org/apache/calcite/rel/rules/CoreRules.java    |   6 +
 .../rel/rules/CorrelateUncollectOuterRule.java     | 105 +++++++
 .../calcite/rel/rules/UnnestDecorrelateRule.java   |  16 +-
 .../org/apache/calcite/runtime/SqlFunctions.java   | 131 ++++++++-
 .../apache/calcite/sql2rel/SqlToRelConverter.java  |   4 +-
 .../java/org/apache/calcite/tools/RelBuilder.java  |  30 +-
 .../org/apache/calcite/util/BuiltInMethod.java     |   3 +-
 .../calcite/sql2rel/RelFieldTrimmerTest.java       |   2 +-
 .../org/apache/calcite/test/SqlFunctionsTest.java  | 124 +++++++-
 core/src/test/resources/sql/unnest.iq              | 316 +++++++++++++++++++++
 17 files changed, 822 insertions(+), 48 deletions(-)

diff --git 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollect.java
 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollect.java
index 8f193a4ace..143cabad99 100644
--- 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollect.java
+++ 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollect.java
@@ -51,16 +51,20 @@ public EnumerableUncollect(RelOptCluster cluster, 
RelTraitSet traitSet,
    * <p>Use {@link #create} unless you know what you're doing. */
   public EnumerableUncollect(RelOptCluster cluster, RelTraitSet traitSet,
       RelNode child, boolean withOrdinality) {
-    this(cluster, traitSet, child, withOrdinality, true);
+    this(cluster, traitSet, child, withOrdinality, true, false);
   }
 
   /** Creates an EnumerableUncollect.
    *
-   * <p>Use {@link #create} unless you know what you're doing. */
+   * <p>Use {@link #create} unless you know what you're doing.
+   *
+   * @param isOuter If true, an empty or NULL collection yields one row of
+   *                NULLs (LEFT JOIN); if false, it yields no rows (INNER) */
   public EnumerableUncollect(RelOptCluster cluster, RelTraitSet traitSet,
-      RelNode child, boolean withOrdinality, boolean expandStructFields) {
+      RelNode child, boolean withOrdinality, boolean expandStructFields,
+      boolean isOuter) {
     super(cluster, traitSet, child, withOrdinality, Collections.emptyList(),
-        expandStructFields);
+        expandStructFields, isOuter);
     assert getConvention() instanceof EnumerableConvention;
     assert getConvention() == child.getConvention();
   }
@@ -90,18 +94,20 @@ public static EnumerableUncollect create(RelTraitSet 
traitSet, RelNode input,
    * @param expandStructFields If true, a collection whose element type is a 
struct
    *                           produces one output column per struct field; if 
false,
    *                           a single column typed as the whole element
+   * @param isOuter            If true, an empty or NULL collection yields one 
row of
+   *                           NULLs (LEFT JOIN); if false, it yields no rows 
(INNER)
    */
   public static EnumerableUncollect create(RelTraitSet traitSet, RelNode input,
-      boolean withOrdinality, boolean expandStructFields) {
+      boolean withOrdinality, boolean expandStructFields, boolean isOuter) {
     final RelOptCluster cluster = input.getCluster();
     return new EnumerableUncollect(cluster, traitSet, input, withOrdinality,
-        expandStructFields);
+        expandStructFields, isOuter);
   }
 
   @Override public EnumerableUncollect copy(RelTraitSet traitSet,
       RelNode newInput) {
     return new EnumerableUncollect(getCluster(), traitSet, newInput,
-        withOrdinality, expandStructFields);
+        withOrdinality, expandStructFields, isOuter);
   }
 
   @Override public Result implement(EnumerableRelImplementor implementor, 
Prefer pref) {
@@ -136,8 +142,11 @@ public static EnumerableUncollect create(RelTraitSet 
traitSet, RelNode input,
               && !withOrdinality) {
             // Solves CALCITE-4063: if we are processing a single field, which 
is a struct with a
             // single item inside, and no ordinality; the result must be a 
scalar, hence use a
-            // special lambda that does not return lists, but the (single) 
items within those lists
-            lambdaForStructWithSingleItem = 
Expressions.call(BuiltInMethod.FLAT_LIST.method);
+            // special lambda that does not return lists, but the (single) 
items within those
+            // lists. The outer variant returns one NULL scalar for an empty 
or NULL collection.
+            lambdaForStructWithSingleItem =
+                Expressions.call(isOuter ? BuiltInMethod.FLAT_LIST_OUTER.method
+                    : BuiltInMethod.FLAT_LIST.method);
           } else {
             fieldCounts.add(elementType.getFieldCount());
             inputTypes.add(FlatProductInputType.LIST);
@@ -161,7 +170,8 @@ public static EnumerableUncollect create(RelTraitSet 
traitSet, RelNode input,
             Expressions.constant(Ints.toArray(fieldCounts)),
             Expressions.constant(withOrdinality),
             Expressions.constant(
-                inputTypes.toArray(new FlatProductInputType[0])));
+                inputTypes.toArray(new FlatProductInputType[0])),
+            Expressions.constant(isOuter));
     builder.add(
         Expressions.return_(null,
             Expressions.call(child_,
diff --git 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollectRule.java
 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollectRule.java
index 95a9237c22..906404c3ab 100644
--- 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollectRule.java
+++ 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableUncollectRule.java
@@ -49,6 +49,7 @@ protected EnumerableUncollectRule(Config config) {
         convert(input,
             input.getTraitSet().replace(EnumerableConvention.INSTANCE));
     return EnumerableUncollect.create(traitSet, newInput,
-        uncollect.withOrdinality, uncollect.expandStructFields);
+        uncollect.withOrdinality, uncollect.expandStructFields,
+        uncollect.isOuter);
   }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/interpreter/UncollectNode.java 
b/core/src/main/java/org/apache/calcite/interpreter/UncollectNode.java
index 01f4d578f4..725f577c4a 100644
--- a/core/src/main/java/org/apache/calcite/interpreter/UncollectNode.java
+++ b/core/src/main/java/org/apache/calcite/interpreter/UncollectNode.java
@@ -33,15 +33,26 @@ public UncollectNode(Compiler compiler, Uncollect 
uncollect) {
   }
 
   @Override public void run() throws InterruptedException {
+    // Under isOuter an empty or NULL collection still produces one row, with
+    // every column NULL.
+    final int width = rel.getRowType().getFieldCount();
     Row row = null;
     while ((row = source.receive()) != null) {
       for (Object value : row.getValues()) {
         if (value == null) {
+          if (rel.isOuter) {
+            sink.send(Row.of(new Object[width]));
+            continue;
+          }
           throw new NullPointerException("NULL value for unnest.");
         }
         int i = 1;
         if (value instanceof List) {
           List list = (List) value;
+          if (list.isEmpty() && rel.isOuter) {
+            sink.send(Row.of(new Object[width]));
+            continue;
+          }
           for (Object o : list) {
             if (rel.withOrdinality) {
               sink.send(Row.of(o, i++));
@@ -51,6 +62,10 @@ public UncollectNode(Compiler compiler, Uncollect uncollect) 
{
           }
         } else if (value instanceof Map) {
           Map map = (Map) value;
+          if (map.isEmpty() && rel.isOuter) {
+            sink.send(Row.of(new Object[width]));
+            continue;
+          }
           for (Object key : map.keySet()) {
             if (rel.withOrdinality) {
               sink.send(Row.of(key, map.get(key), i++));
diff --git a/core/src/main/java/org/apache/calcite/rel/core/Uncollect.java 
b/core/src/main/java/org/apache/calcite/rel/core/Uncollect.java
index e607509ddc..039a17efbf 100644
--- a/core/src/main/java/org/apache/calcite/rel/core/Uncollect.java
+++ b/core/src/main/java/org/apache/calcite/rel/core/Uncollect.java
@@ -56,10 +56,20 @@
  * output column per struct field; if {@code false} it produces a single
  * column typed as the whole element (Trino semantics). Maps always expand
  * into a key and a value column, regardless of this flag.
+ *
+ * <p>{@code isOuter} controls what happens to an empty or {@code NULL}
+ * collection: if {@code true} (LEFT JOIN semantics) one row is emitted with
+ * every element column set to {@code NULL}; if {@code false} (INNER
+ * semantics) no row is emitted. Every element column is therefore nullable
+ * when {@code isOuter}.
  */
 public class Uncollect extends SingleRel {
   public final boolean withOrdinality;
 
+  /** If true, an empty or NULL collection yields a single row whose element
+   * columns are all NULL, rather than no rows at all. */
+  public final boolean isOuter;
+
   /** If true, a collection whose element type is a struct expands into one
    * output column per struct field; if false, it produces a single column
    * typed as the whole element. */
@@ -90,7 +100,8 @@ public Uncollect(RelOptCluster cluster, RelTraitSet 
traitSet, RelNode input,
     // Non-empty item aliases historically implied that struct elements are not
     // expanded (Presto dialect), so this constructor derives
     // {@code expandStructFields} from their absence.
-    this(cluster, traitSet, input, withOrdinality, itemAliases, 
itemAliases.isEmpty());
+    this(cluster, traitSet, input, withOrdinality, itemAliases, 
itemAliases.isEmpty(),
+        false);
   }
 
   /** Creates an Uncollect.
@@ -101,14 +112,18 @@ public Uncollect(RelOptCluster cluster, RelTraitSet 
traitSet, RelNode input,
    * @param expandStructFields If true, a collection whose element type is a 
struct
    *                           produces one output column per struct field; if 
false,
    *                           a single column typed as the whole element
+   * @param isOuter            If true, an empty or NULL collection yields one 
row of
+   *                           NULLs (LEFT JOIN); if false, it yields no rows 
(INNER)
    */
   @SuppressWarnings("method.invocation.invalid")
   public Uncollect(RelOptCluster cluster, RelTraitSet traitSet, RelNode input,
-      boolean withOrdinality, List<String> itemAliases, boolean 
expandStructFields) {
+      boolean withOrdinality, List<String> itemAliases, boolean 
expandStructFields,
+      boolean isOuter) {
     super(cluster, traitSet, input);
     this.withOrdinality = withOrdinality;
     this.itemAliases = ImmutableList.copyOf(itemAliases);
     this.expandStructFields = expandStructFields;
+    this.isOuter = isOuter;
     requireNonNull(deriveRowType(), "invalid child rowType");
   }
 
@@ -118,7 +133,8 @@ public Uncollect(RelOptCluster cluster, RelTraitSet 
traitSet, RelNode input,
   public Uncollect(RelInput input) {
     this(input.getCluster(), input.getTraitSet(), input.getInput(),
         input.getBoolean("withOrdinality", false), Collections.emptyList(),
-        input.getBoolean("expandStructFields", true));
+        input.getBoolean("expandStructFields", true),
+        input.getBoolean("isOuter", false));
   }
 
   /**
@@ -151,16 +167,19 @@ public static Uncollect create(
    * @param expandStructFields If true, a collection whose element type is a 
struct
    *                           produces one output column per struct field; if 
false,
    *                           a single column typed as the whole element
+   * @param isOuter            If true, an empty or NULL collection yields one 
row of
+   *                           NULLs (LEFT JOIN); if false, it yields no rows 
(INNER)
    */
   public static Uncollect create(
       RelTraitSet traitSet,
       RelNode input,
       boolean withOrdinality,
       List<String> itemAliases,
-      boolean expandStructFields) {
+      boolean expandStructFields,
+      boolean isOuter) {
     final RelOptCluster cluster = input.getCluster();
     return new Uncollect(cluster, traitSet, input, withOrdinality, itemAliases,
-        expandStructFields);
+        expandStructFields, isOuter);
   }
 
   //~ Methods ----------------------------------------------------------------
@@ -172,7 +191,8 @@ public static Uncollect create(
   @Override public RelWriter explainTerms(RelWriter pw) {
     return super.explainTerms(pw)
         .itemIf("withOrdinality", withOrdinality, withOrdinality)
-        .itemIf("expandStructFields", expandStructFields, !expandStructFields);
+        .itemIf("expandStructFields", expandStructFields, !expandStructFields)
+        .itemIf("isOuter", isOuter, isOuter);
   }
 
   @Override public final RelNode copy(RelTraitSet traitSet,
@@ -183,7 +203,7 @@ public static Uncollect create(
   public RelNode copy(RelTraitSet traitSet, RelNode input) {
     assert traitSet.containsIfApplicable(Convention.NONE);
     return new Uncollect(getCluster(), traitSet, input, withOrdinality, 
itemAliases,
-        expandStructFields);
+        expandStructFields, isOuter);
   }
 
   /**
@@ -287,7 +307,18 @@ public static RelDataType deriveUncollectRowType(RelNode 
rel,
       builder.add(SqlUnnestOperator.ORDINALITY_COLUMN_NAME,
           SqlTypeName.INTEGER);
     }
-    return builder.build();
+    final RelDataType rowType = builder.build();
+    if (!isOuter) {
+      return rowType;
+    }
+    // Under isOuter an empty or NULL collection yields a row of NULLs, so
+    // every output column is nullable, including the ordinality column.
+    final RelDataTypeFactory.Builder outerBuilder = typeFactory.builder();
+    for (RelDataTypeField field : rowType.getFieldList()) {
+      outerBuilder.add(field.getName(),
+          typeFactory.createTypeWithNullability(field.getType(), true));
+    }
+    return outerBuilder.build();
   }
 
   /** Gets the aliases for the unnest items. */
diff --git 
a/core/src/main/java/org/apache/calcite/rel/logical/ToLogicalConverter.java 
b/core/src/main/java/org/apache/calcite/rel/logical/ToLogicalConverter.java
index 4ff564f1fd..b15f06b6ea 100644
--- a/core/src/main/java/org/apache/calcite/rel/logical/ToLogicalConverter.java
+++ b/core/src/main/java/org/apache/calcite/rel/logical/ToLogicalConverter.java
@@ -190,7 +190,7 @@ public ToLogicalConverter(RelBuilder relBuilder) {
       final RelNode input = visit(uncollect.getInput());
       return Uncollect.create(input.getTraitSet(), input,
           uncollect.withOrdinality, uncollect.getItemAliases(),
-          uncollect.expandStructFields);
+          uncollect.expandStructFields, uncollect.isOuter);
     }
 
     throw new AssertionError("Need to implement logical converter for "
diff --git a/core/src/main/java/org/apache/calcite/rel/mutable/MutableRels.java 
b/core/src/main/java/org/apache/calcite/rel/mutable/MutableRels.java
index 176be5cfec..092d45c8fe 100644
--- a/core/src/main/java/org/apache/calcite/rel/mutable/MutableRels.java
+++ b/core/src/main/java/org/apache/calcite/rel/mutable/MutableRels.java
@@ -257,7 +257,7 @@ public static RelNode fromMutable(MutableRel node, 
RelBuilder relBuilder) {
       final MutableUncollect uncollect = (MutableUncollect) node;
       final RelNode child = fromMutable(uncollect.getInput(), relBuilder);
       return Uncollect.create(child.getTraitSet(), child, 
uncollect.withOrdinality,
-          Collections.emptyList(), uncollect.expandStructFields);
+          Collections.emptyList(), uncollect.expandStructFields, 
uncollect.isOuter);
     }
     case WINDOW: {
       final MutableWindow window = (MutableWindow) node;
@@ -379,7 +379,7 @@ public static MutableRel toMutable(RelNode rel) {
       final Uncollect uncollect = (Uncollect) rel;
       final MutableRel input = toMutable(uncollect.getInput());
       return MutableUncollect.of(uncollect.getRowType(), input,
-          uncollect.withOrdinality, uncollect.expandStructFields);
+          uncollect.withOrdinality, uncollect.expandStructFields, 
uncollect.isOuter);
     }
     if (rel instanceof Window) {
       final Window window = (Window) rel;
diff --git 
a/core/src/main/java/org/apache/calcite/rel/mutable/MutableUncollect.java 
b/core/src/main/java/org/apache/calcite/rel/mutable/MutableUncollect.java
index bae3854f69..0dc09b2a00 100644
--- a/core/src/main/java/org/apache/calcite/rel/mutable/MutableUncollect.java
+++ b/core/src/main/java/org/apache/calcite/rel/mutable/MutableUncollect.java
@@ -26,12 +26,15 @@
 public class MutableUncollect extends MutableSingleRel {
   public final boolean withOrdinality;
   public final boolean expandStructFields;
+  public final boolean isOuter;
 
   private MutableUncollect(RelDataType rowType,
-      MutableRel input, boolean withOrdinality, boolean expandStructFields) {
+      MutableRel input, boolean withOrdinality, boolean expandStructFields,
+      boolean isOuter) {
     super(MutableRelType.UNCOLLECT, rowType, input);
     this.withOrdinality = withOrdinality;
     this.expandStructFields = expandStructFields;
+    this.isOuter = isOuter;
   }
 
   /**
@@ -44,7 +47,7 @@ private MutableUncollect(RelDataType rowType,
    */
   public static MutableUncollect of(RelDataType rowType,
       MutableRel input, boolean withOrdinality) {
-    return of(rowType, input, withOrdinality, true);
+    return of(rowType, input, withOrdinality, true, false);
   }
 
   /**
@@ -59,10 +62,25 @@ public static MutableUncollect of(RelDataType rowType,
    *                           struct field; if false, a single column
    *                           typed as the whole element
    */
+  /**
+   * Creates a MutableUncollect.
+   *
+   * @param rowType            Row type
+   * @param input              Input relational expression
+   * @param withOrdinality     Whether the output contains an extra
+   *                           {@code ORDINALITY} column
+   * @param expandStructFields If true, a collection whose element type
+   *                           is a struct produces one output column per
+   *                           struct field; if false, a single column
+   *                           typed as the whole element
+   * @param isOuter            If true, an empty or NULL collection yields one
+   *                           row of NULLs; if false, it yields no rows
+   */
   public static MutableUncollect of(RelDataType rowType,
-      MutableRel input, boolean withOrdinality, boolean expandStructFields) {
+      MutableRel input, boolean withOrdinality, boolean expandStructFields,
+      boolean isOuter) {
     return new MutableUncollect(rowType, input, withOrdinality,
-        expandStructFields);
+        expandStructFields, isOuter);
   }
 
   @Override public boolean equals(@Nullable Object obj) {
@@ -70,21 +88,23 @@ public static MutableUncollect of(RelDataType rowType,
         || obj instanceof MutableUncollect
         && withOrdinality == ((MutableUncollect) obj).withOrdinality
         && expandStructFields == ((MutableUncollect) obj).expandStructFields
+        && isOuter == ((MutableUncollect) obj).isOuter
         && input.equals(((MutableUncollect) obj).input);
   }
 
   @Override public int hashCode() {
-    return Objects.hash(input, withOrdinality, expandStructFields);
+    return Objects.hash(input, withOrdinality, expandStructFields, isOuter);
   }
 
   @Override public StringBuilder digest(StringBuilder buf) {
     return buf.append("Uncollect(withOrdinality: ").append(withOrdinality)
         .append(", expandStructFields: ").append(expandStructFields)
+        .append(", isOuter: ").append(isOuter)
         .append(")");
   }
 
   @Override public MutableRel clone() {
     return MutableUncollect.of(rowType, input.clone(), withOrdinality,
-        expandStructFields);
+        expandStructFields, isOuter);
   }
 }
diff --git a/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java 
b/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
index 98cbeaae21..f827843cc6 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
@@ -1008,6 +1008,12 @@ private CoreRules() {}
   public static final AggregateRemoveLiteralAggRule 
AGGREGATE_REMOVE_LITERAL_AGG =
       AggregateRemoveLiteralAggRule.Config.DEFAULT.toRule();
 
+  /** Rule that moves the outer join semantics of a {@link Correlate} over an
+   * {@link Uncollect} onto the {@code Uncollect}, leaving an inner
+   * {@code Correlate} that {@link #UNNEST_DECORRELATE} may then remove. */
+  public static final CorrelateUncollectOuterRule CORRELATE_UNCOLLECT_OUTER =
+      CorrelateUncollectOuterRule.Config.DEFAULT.toRule();
+
   /** Rule that converts a {@link Correlate} after an {@link Uncollect} into a 
simple
    * Uncollect, if possible. */
   public static final RelOptRule UNNEST_DECORRELATE =
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/CorrelateUncollectOuterRule.java
 
b/core/src/main/java/org/apache/calcite/rel/rules/CorrelateUncollectOuterRule.java
new file mode 100644
index 0000000000..6cbf781c5f
--- /dev/null
+++ 
b/core/src/main/java/org/apache/calcite/rel/rules/CorrelateUncollectOuterRule.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.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Correlate;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.rel.core.Project;
+import org.apache.calcite.rel.core.Uncollect;
+import org.apache.calcite.rel.logical.LogicalValues;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that moves the outer join semantics of a {@link Correlate} over an
+ * {@link Uncollect} onto the {@code Uncollect} itself.
+ *
+ * <p>Input plan:
+ * <pre>
+ * Correlate(cor=[$cor0], joinType=[left])
+ *   left (any RelNode)
+ *   Uncollect(isOuter=[any_boolean])
+ *     Project($cor0.f, ...)
+ *       LogicalValues(tuples=[[{ 0 }]])
+ * </pre>
+ *
+ * <p>Converted to:
+ * <pre>
+ * Correlate(cor=[$cor0], joinType=[inner])
+ *   left
+ *   Uncollect(isOuter=[true])
+ *     Project($cor0.f, ...)
+ *       LogicalValues(tuples=[[{ 0 }]])
+ * </pre>
+ *
+ * @see CoreRules#CORRELATE_UNCOLLECT_OUTER
+ */
[email protected]
+public class CorrelateUncollectOuterRule
+    extends RelRule<CorrelateUncollectOuterRule.Config>
+    implements TransformationRule {
+
+  protected CorrelateUncollectOuterRule(Config config) {
+    super(config);
+  }
+
+  @Override public boolean matches(RelOptRuleCall call) {
+    final Correlate correlate = call.rel(0);
+    if (correlate.getJoinType() != JoinRelType.LEFT) {
+      return false;
+    }
+    // Expect "LogicalValues { 0 }"
+    final LogicalValues values = call.rel(4);
+    return values.getTuples().size() == 1;
+  }
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Correlate correlate = call.rel(0);
+    final Uncollect uncollect = call.rel(2);
+
+    // Note: this is correct even if uncollect(isOuter=[true]) already
+    final Uncollect outerUncollect =
+        Uncollect.create(uncollect.getTraitSet(), uncollect.getInput(),
+            uncollect.withOrdinality, uncollect.getItemAliases(),
+            uncollect.expandStructFields, true);
+    final RelNode newCorrelate =
+        correlate.copy(correlate.getTraitSet(), correlate.getLeft(),
+            outerUncollect, correlate.getCorrelationId(),
+            correlate.getRequiredColumns(), JoinRelType.INNER);
+    call.transformTo(newCorrelate);
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableCorrelateUncollectOuterRule.Config.of()
+        .withOperandSupplier(b0 ->
+            b0.operand(Correlate.class).inputs(
+                b1 -> b1.operand(RelNode.class).anyInputs(),
+                b2 -> b2.operand(Uncollect.class)
+                    .oneInput(b3 -> b3.operand(Project.class)
+                        .oneInput(b4 -> b4.operand(LogicalValues.class)
+                            .anyInputs()))));
+
+    @Override default CorrelateUncollectOuterRule toRule() {
+      return new CorrelateUncollectOuterRule(this);
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/UnnestDecorrelateRule.java 
b/core/src/main/java/org/apache/calcite/rel/rules/UnnestDecorrelateRule.java
index 71005b52fd..50dbc5e8f5 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/UnnestDecorrelateRule.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/UnnestDecorrelateRule.java
@@ -22,6 +22,7 @@
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Correlate;
 import org.apache.calcite.rel.core.CorrelationId;
+import org.apache.calcite.rel.core.JoinRelType;
 import org.apache.calcite.rel.core.Project;
 import org.apache.calcite.rel.core.Uncollect;
 import org.apache.calcite.rel.logical.LogicalValues;
@@ -44,6 +45,8 @@
 /** Convert representations of a projected Unnest that use LogicalCorrelate 
into
  * simple Unnest representations.
  *
+ * @see CorrelateUncollectOuterRule
+ *
  * <p>Original plan:
  * LogicalProject // only uses rightmost columns of correlate, outerProject
  *   LogicalCorrelate(correlation=[$cor0], joinType=[inner], 
requiredColumns=[{...}])
@@ -97,6 +100,11 @@ private boolean extractFieldReferences(
   @Override public void onMatch(RelOptRuleCall call) {
     Project outerProject = call.rel(0);
     Correlate cor = call.rel(1);
+    if (cor.getJoinType() != JoinRelType.INNER) {
+      // Removing the correlate is only sound for INNER.
+      // A LEFT correlate must first be converted by 
CorrelateUncollectOuterRule.
+      return;
+    }
     CorrelationId corId = cor.getCorrelationId();
 
     RelNode left = call.rel(2);
@@ -116,6 +124,11 @@ private boolean extractFieldReferences(
 
     Uncollect uncollect = call.rel(uncollectIndex);
     Project project = call.rel(uncollectIndex + 1);
+    // Expect "LogicalValues { 0 }"
+    LogicalValues values = call.rel(uncollectIndex + 2);
+    if (values.getTuples().size() != 1) {
+      return;
+    }
 
     List<RexNode> projects = project.getProjects();
     if (projects.size() != 1) {
@@ -143,7 +156,8 @@ private boolean extractFieldReferences(
       }
     }
     builder.project(requireNonNull(field, "field"))
-        .uncollect(uncollect.getItemAliases(), uncollect.withOrdinality);
+        .uncollect(uncollect.getItemAliases(), uncollect.withOrdinality,
+            uncollect.expandStructFields, uncollect.isOuter);
     if (innerProject != null) {
       builder.project(innerProject.getProjects());
     }
diff --git a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java 
b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
index d2bd353052..b797264a8b 100644
--- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
+++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
@@ -216,6 +216,24 @@ public class SqlFunctions {
           a0 -> a0 == null ? Linq4j.emptyEnumerable()
               : Linq4j.asEnumerable(a0).<@Nullable 
Object>select(SqlFunctions::structValue);
 
+  /** Single NULL element, the outer-mode result for an empty or NULL
+   * collection. */
+  private static final List<@Nullable Object> SINGLE_NULL =
+      Collections.singletonList(null);
+
+  /** Like {@link #LIST_AS_ENUMERABLE}, but for outer join mode: an empty or 
NULL
+   * collection yields one NULL element rather than no elements. */
+  private static final Function1<List<Object>, Enumerable<@Nullable Object>>
+      OUTER_LIST_AS_ENUMERABLE =
+          a0 -> a0 == null || a0.isEmpty() ? Linq4j.asEnumerable(SINGLE_NULL)
+              : Linq4j.asEnumerable(a0);
+
+  /** Like {@link #STRUCT_LIST_AS_ENUMERABLE}, but for outer join mode. */
+  private static final Function1<List<Object>, Enumerable<@Nullable Object>>
+      OUTER_STRUCT_LIST_AS_ENUMERABLE =
+          a0 -> a0 == null || a0.isEmpty() ? Linq4j.asEnumerable(SINGLE_NULL)
+              : Linq4j.asEnumerable(a0).<@Nullable 
Object>select(SqlFunctions::structValue);
+
   /** Converts one element of a collection of structs to its Object[] struct
    * value. Elements arrive as List or as Object[]; null elements stay null. */
   @SuppressWarnings("rawtypes")
@@ -7683,8 +7701,22 @@ public static String arrayToString(List list, String 
delimiter, @Nullable String
    * Function that, given a certain List containing single-item structs (i.e. 
arrays / lists with
    * a single item), builds an Enumerable that returns those single items 
inside the structs.
    */
-  public static Function1<List<Object>, Enumerable<Object>> flatList() {
-    return inputList -> Linq4j.asEnumerable(inputList).select(v -> 
structAccess(v, 0, null));
+  public static Function1<List<Object>, Enumerable<@Nullable Object>> 
flatList() {
+    // A NULL collection unnests to no rows, like an empty one.
+    return inputList -> inputList == null ? Linq4j.emptyEnumerable()
+        : Linq4j.asEnumerable(inputList)
+            .<@Nullable Object>select(v -> structAccess(v, 0, null));
+  }
+
+  /**
+   * Variant of {@link #flatList} for outer mode: an empty or {@code NULL}
+   * collection yields one {@code NULL} element rather than no elements.
+   */
+  public static Function1<List<Object>, Enumerable<@Nullable Object>> 
flatListOuter() {
+    return inputList -> inputList == null || inputList.isEmpty()
+        ? Linq4j.asEnumerable(SINGLE_NULL)
+        : Linq4j.asEnumerable(inputList)
+            .<@Nullable Object>select(v -> structAccess(v, 0, null));
   }
 
   /**
@@ -7695,28 +7727,34 @@ public static Function1<List<Object>, 
Enumerable<Object>> flatList() {
    * <p>This is the standard semantics for SQL {@code UNNEST(a, b, ...)}: the
    * i-th output row pairs element {@code a[i]} with element {@code b[i]}.
    * Shorter collections are padded with {@code NULL}.
+   *
+   * <p>When {@code outer}, a row whose collections are all empty or
+   * {@code NULL} still produces one output row, with every element column set
+   * to {@code NULL}. This is the LEFT JOIN semantics of {@code Uncollect}.
    */
   public static Function1<Object, Enumerable<ComparableList<Comparable>>> 
flatZip(
       final int[] fieldCounts, final boolean withOrdinality,
-      final FlatProductInputType[] inputTypes) {
+      final FlatProductInputType[] inputTypes, final boolean outer) {
     if (fieldCounts.length == 1) {
       if (!withOrdinality && inputTypes[0] == FlatProductInputType.SCALAR) {
         // Simple unnest without ordinality
         //noinspection unchecked
-        return (Function1) LIST_AS_ENUMERABLE;
+        return outer ? (Function1) OUTER_LIST_AS_ENUMERABLE
+            : (Function1) LIST_AS_ENUMERABLE;
       } else if (!withOrdinality && inputTypes[0] == 
FlatProductInputType.STRUCT) {
         // A single collection of structs kept whole, without ordinality: the
         // output row type has a single (ROW-typed) column, so PhysTypeImpl
         // optimizes the row format down to SCALAR, under which rows are bare
         // struct values rather than singleton lists.
         //noinspection unchecked
-        return (Function1) STRUCT_LIST_AS_ENUMERABLE;
+        return (Function1) (outer ? OUTER_STRUCT_LIST_AS_ENUMERABLE
+            : STRUCT_LIST_AS_ENUMERABLE);
       } else {
         // unnest with ordinality for a single column
-        return row -> z2(new Object[] { row }, fieldCounts, withOrdinality, 
inputTypes);
+        return row -> z2(new Object[] { row }, fieldCounts, withOrdinality, 
inputTypes, outer);
       }
     }
-    return lists -> z2((Object[]) lists, fieldCounts, withOrdinality, 
inputTypes);
+    return lists -> z2((Object[]) lists, fieldCounts, withOrdinality, 
inputTypes, outer);
   }
 
   /**
@@ -7729,11 +7767,13 @@ public static Function1<Object, 
Enumerable<ComparableList<Comparable>>> flatZip(
    *                     of scalars or of structs kept whole)
    * @param withOrdinality whether to append a 1-based ordinality column
    * @param inputTypes   type of elements in each collection (SCALAR, LIST, 
STRUCT, or MAP)
+   * @param outer        whether to emit one all-NULL row when every 
collection is
+   *                     empty or NULL, rather than no rows
    */
   @SuppressWarnings("rawtypes")
   private static Enumerable<FlatLists.ComparableList<Comparable>> z2(
       Object[] lists, int[] fieldCounts, boolean withOrdinality,
-      FlatProductInputType[] inputTypes) {
+      FlatProductInputType[] inputTypes, boolean outer) {
     final List<Enumerator<List<Comparable>>> enumerators = new ArrayList<>();
     final int[] widths = new int[lists.length];
     int totalFieldCount = 0;
@@ -7741,6 +7781,15 @@ private static 
Enumerable<FlatLists.ComparableList<Comparable>> z2(
       final int fieldCount = fieldCounts[i];
       final FlatProductInputType inputType = inputTypes[i];
       final Object inputObject = lists[i];
+      if (inputObject == null) {
+        // A NULL collection contributes no elements, like an empty one. Under
+        // outer mode the wrapper below turns "no elements at all" into the
+        // single NULL-padded row.
+        enumerators.add(Linq4j.emptyEnumerator());
+        widths[i] = fieldCount < 0 ? 1 : fieldCount;
+        totalFieldCount += widths[i];
+        continue;
+      }
       switch (inputType) {
       case SCALAR:
         @SuppressWarnings("unchecked") List<Comparable> list =
@@ -7782,13 +7831,77 @@ private static 
Enumerable<FlatLists.ComparableList<Comparable>> z2(
       ++totalFieldCount;
     }
     final int fieldCount = totalFieldCount;
+    if (!outer) {
+      return new AbstractEnumerable<FlatLists.ComparableList<Comparable>>() {
+        @Override public Enumerator<FlatLists.ComparableList<Comparable>> 
enumerator() {
+          return new ZipPaddedEnumerator(enumerators, widths, fieldCount, 
withOrdinality);
+        }
+      };
+    }
+    @SuppressWarnings("unchecked") final FlatLists.ComparableList<Comparable> 
nullRow =
+        (FlatLists.ComparableList) 
FlatLists.of(Collections.nCopies(fieldCount, null));
     return new AbstractEnumerable<FlatLists.ComparableList<Comparable>>() {
       @Override public Enumerator<FlatLists.ComparableList<Comparable>> 
enumerator() {
-        return new ZipPaddedEnumerator(enumerators, widths, fieldCount, 
withOrdinality);
+        return new DefaultIfEmptyEnumerator(
+            new ZipPaddedEnumerator(enumerators, widths, fieldCount, 
withOrdinality),
+            nullRow);
       }
     };
   }
 
+  /** Enumerator that yields the rows of another enumerator, or a single
+   * default row if that enumerator yields none.
+   *
+   * <p>This is the {@code defaultIfEmpty} operation of LINQ. It implements
+   * the outer (LEFT JOIN) semantics of {@code Uncollect}, where the default
+   * row is all NULL. */
+  @SuppressWarnings("rawtypes")
+  private static class DefaultIfEmptyEnumerator
+      implements Enumerator<FlatLists.ComparableList<Comparable>> {
+    private final Enumerator<FlatLists.ComparableList<Comparable>> inner;
+    private final FlatLists.ComparableList<Comparable> defaultRow;
+    /** Whether {@link #inner} has yielded at least one row. */
+    private boolean innerMoved;
+    /** Whether {@link #defaultRow} is the row currently being returned. */
+    private boolean onDefaultRow;
+
+    DefaultIfEmptyEnumerator(
+        Enumerator<FlatLists.ComparableList<Comparable>> inner,
+        FlatLists.ComparableList<Comparable> defaultRow) {
+      this.inner = inner;
+      this.defaultRow = defaultRow;
+    }
+
+    @Override public boolean moveNext() {
+      if (onDefaultRow) {
+        return false;
+      }
+      if (inner.moveNext()) {
+        innerMoved = true;
+        return true;
+      }
+      if (innerMoved) {
+        return false;
+      }
+      onDefaultRow = true;
+      return true;
+    }
+
+    @Override public FlatLists.ComparableList<Comparable> current() {
+      return onDefaultRow ? defaultRow : inner.current();
+    }
+
+    @Override public void reset() {
+      inner.reset();
+      innerMoved = false;
+      onDefaultRow = false;
+    }
+
+    @Override public void close() {
+      inner.close();
+    }
+  }
+
   public static Object[] array(Object... args) {
     return args;
   }
diff --git 
a/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java 
b/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java
index 5c524eee17..357441d316 100644
--- a/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java
+++ b/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java
@@ -2899,7 +2899,7 @@ private void convertUnnest(Blackboard bb, SqlCall call, 
@Nullable List<String> f
         uncollect = relBuilder
             .push(child)
             .project(exprs)
-            .uncollect(itemAliases, operator.withOrdinality)
+            .uncollect(itemAliases, operator.withOrdinality, 
itemAliases.isEmpty(), false)
             .let(r -> fieldNames == null ? r : r.rename(fieldNames))
             .build();
       } else {
@@ -2908,7 +2908,7 @@ private void convertUnnest(Blackboard bb, SqlCall call, 
@Nullable List<String> f
         uncollect = relBuilder
             .push(child)
             .project(exprs)
-            .uncollect(Collections.emptyList(), operator.withOrdinality)
+            .uncollect(Collections.emptyList(), operator.withOrdinality, true, 
false)
             .let(r -> fieldNames == null ? r : r.rename(fieldNames))
             .build();
       }
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 92c5a41415..7cc7437c62 100644
--- a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
+++ b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
@@ -2383,8 +2383,34 @@ public RelBuilder projectNamed(Iterable<? extends 
RexNode> nodes,
    * @param itemAliases   Operand item aliases, never null
    * @param withOrdinality If {@code withOrdinality}, the output contains an 
extra
    * {@code ORDINALITY} column
+   *
+   * @deprecated Use
+   * {@link #uncollect(List, boolean, boolean, boolean)}, which controls every
+   * flag explicitly. This overload derives {@code expandStructFields} from the
+   * item aliases, which cannot express a collection of structs that is kept
+   * whole without aliases, and it cannot create an outer {@code Uncollect}.
    */
+  @Deprecated // to be removed before 2.0
   public RelBuilder uncollect(List<String> itemAliases, boolean 
withOrdinality) {
+    return uncollect(itemAliases, withOrdinality,
+        requireNonNull(itemAliases, "itemAliases").isEmpty(), false);
+  }
+
+  /**
+   * Creates an {@link Uncollect} with given item aliases, with explicit 
control
+   * over every flag.
+   *
+   * @param itemAliases   Operand item aliases, never null
+   * @param withOrdinality If {@code withOrdinality}, the output contains an 
extra
+   * {@code ORDINALITY} column
+   * @param expandStructFields If true, a collection whose element type is a 
struct
+   * produces one output column per struct field; if false, a single column 
typed
+   * as the whole element
+   * @param isOuter If {@code isOuter}, an empty or NULL collection yields one 
row
+   * of NULLs (LEFT JOIN); otherwise it yields no rows (INNER)
+   */
+  public RelBuilder uncollect(List<String> itemAliases, boolean withOrdinality,
+      boolean expandStructFields, boolean isOuter) {
     Frame frame = stack.pop();
     stack.push(
         new Frame(
@@ -2393,7 +2419,9 @@ public RelBuilder uncollect(List<String> itemAliases, 
boolean withOrdinality) {
               cluster.traitSetOf(Convention.NONE),
               frame.rel,
               withOrdinality,
-              requireNonNull(itemAliases, "itemAliases"))));
+              requireNonNull(itemAliases, "itemAliases"),
+              expandStructFields,
+              isOuter)));
     return this;
   }
 
diff --git a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java 
b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
index 8e1e190548..bbce7d8de4 100644
--- a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
+++ b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java
@@ -334,8 +334,9 @@ public enum BuiltInMethod {
   PAIR_LIST_COPY_OF(PairList.Helper.class, "copyOf", Object.class, 
Object.class,
       Object[].class),
   FLAT_ZIP(SqlFunctions.class, "flatZip", int[].class, boolean.class,
-      FlatProductInputType[].class),
+      FlatProductInputType[].class, boolean.class),
   FLAT_LIST(SqlFunctions.class, "flatList"),
+  FLAT_LIST_OUTER(SqlFunctions.class, "flatListOuter"),
   LIST_N(FlatLists.class, "copyOf", Comparable[].class),
   LIST1(FlatLists.class, "ofSingle", Object.class),
   LIST2(FlatLists.class, "of", Object.class, Object.class),
diff --git 
a/core/src/test/java/org/apache/calcite/sql2rel/RelFieldTrimmerTest.java 
b/core/src/test/java/org/apache/calcite/sql2rel/RelFieldTrimmerTest.java
index 86623ccb0a..535dc07b16 100644
--- a/core/src/test/java/org/apache/calcite/sql2rel/RelFieldTrimmerTest.java
+++ b/core/src/test/java/org/apache/calcite/sql2rel/RelFieldTrimmerTest.java
@@ -597,7 +597,7 @@ public static Frameworks.ConfigBuilder config() {
         .project(
             builder.call(SqlStdOperatorTable.ARRAY_VALUE_CONSTRUCTOR,
             builder.field(v.get(), "DEPTNO"), builder.field(v.get(), 
"DEPTNO")))
-        .uncollect(Collections.emptyList(), false)
+        .uncollect(Collections.emptyList(), false, true, false)
         .correlate(JoinRelType.LEFT, v.get().id, builder.field(2, 0, "DEPTNO"))
         .aggregate(builder.groupKey("ENAME"), 
builder.max(builder.field("EMPNO")))
         .build();
diff --git a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java 
b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
index 508008deb0..08d684f031 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
@@ -2175,7 +2175,7 @@ private static List<List<Object>> zipScalars(
         new SqlFunctions.FlatProductInputType[n];
     Arrays.fill(types, SCALAR);
     final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
-        SqlFunctions.flatZip(fieldCounts, withOrdinality, types);
+        SqlFunctions.flatZip(fieldCounts, withOrdinality, types, false);
     final Object arg = n == 1 ? inputs[0] : inputs;
     final List<List<Object>> rows = new ArrayList<>();
     for (FlatLists.ComparableList<Comparable> row : fn.apply(arg)) {
@@ -2249,7 +2249,7 @@ private static List<List<Object>> zipScalars(
     @SuppressWarnings({"rawtypes", "unchecked"})
     final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
         SqlFunctions.flatZip(new int[]{2, 2}, false,
-            new SqlFunctions.FlatProductInputType[]{LIST, LIST});
+            new SqlFunctions.FlatProductInputType[]{LIST, LIST}, false);
     final List<List<Comparable>> col1 =
         Arrays.asList(FlatLists.of(1, 2), FlatLists.of(3, 4));
     final List<List<Comparable>> col2 =
@@ -2280,7 +2280,7 @@ private static List<List<Comparable>> rowArray() {
         SqlFunctions.flatZip(
             new int[]{-1, -1}, // one output column per collection
             false,             // no ordinality
-            new SqlFunctions.FlatProductInputType[]{STRUCT, SCALAR});
+            new SqlFunctions.FlatProductInputType[]{STRUCT, SCALAR}, false);
 
     final List<List<Object>> rows = new ArrayList<>();
     for (FlatLists.ComparableList<Comparable> row
@@ -2304,7 +2304,7 @@ private static List<List<Comparable>> rowArray() {
         SqlFunctions.flatZip(
             new int[]{2, -1}, // two columns from the struct, one scalar column
             false,            // no ordinality
-            new SqlFunctions.FlatProductInputType[]{LIST, SCALAR});
+            new SqlFunctions.FlatProductInputType[]{LIST, SCALAR}, false);
 
     final List<List<Object>> rows = new ArrayList<>();
     for (FlatLists.ComparableList<Comparable> row
@@ -2329,7 +2329,7 @@ private static List<List<Comparable>> rowArray() {
     final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
         SqlFunctions.flatZip(
             new int[]{-1}, false,
-            new SqlFunctions.FlatProductInputType[]{STRUCT});
+            new SqlFunctions.FlatProductInputType[]{STRUCT}, false);
 
     final List<Object> rows = new ArrayList<>();
     for (Object row : (Enumerable) fn.apply(rowArray())) {
@@ -2344,4 +2344,118 @@ private static List<List<Comparable>> rowArray() {
     // UNNEST of a null array yields no rows.
     assertThat(((Enumerable) fn.apply(null)).any(), is(false));
   }
+
+  // Tests for the outer mode of flatZip (Uncollect.isOuter): an empty or
+  // NULL collection produces one all-NULL output row instead of none.
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test void testFlatZipOuterScalar() {
+    // Models SELECT u.x FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(new int[]{-1}, false,
+            new SqlFunctions.FlatProductInputType[]{SCALAR}, true);
+
+    // arr = [1, 2]
+    assertThat(((Enumerable) fn.apply(Arrays.asList(1, 2))).toList(),
+        is(Arrays.asList(1, 2)));
+    // arr = []
+    assertThat(((Enumerable) fn.apply(Collections.emptyList())).toList(),
+        is(Collections.singletonList(null)));
+    // arr = NULL
+    assertThat(((Enumerable) fn.apply(null)).toList(),
+        is(Collections.singletonList(null)));
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test void testFlatZipOuterWholeStruct() {
+    // Models, under PRESTO conformance (struct elements kept whole),
+    // SELECT u.s FROM t LEFT JOIN UNNEST(t.arr) AS u(s) ON TRUE.
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(new int[]{-1}, false,
+            new SqlFunctions.FlatProductInputType[]{STRUCT}, true);
+
+    // arr = [ROW(1, 'x'), ROW(2, 'y')]
+    final List<Object> rows = ((Enumerable) fn.apply(rowArray())).toList();
+    assertThat(rows, hasSize(2));
+    assertArrayEquals(new Object[]{1, "x"}, (Object[]) rows.get(0));
+    // arr = []
+    assertThat(((Enumerable) fn.apply(Collections.emptyList())).toList(),
+        is(Collections.singletonList(null)));
+    // arr = NULL
+    assertThat(((Enumerable) fn.apply(null)).toList(),
+        is(Collections.singletonList(null)));
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test void testFlatZipOuterWithOrdinality() {
+    // Models SELECT u.x, u.o
+    //     FROM t LEFT JOIN UNNEST(t.arr) WITH ORDINALITY AS u(x, o) ON TRUE
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(new int[]{-1}, true,
+            new SqlFunctions.FlatProductInputType[]{SCALAR}, true);
+
+    // arr = [7, 8]
+    final List<List<Object>> rows = new ArrayList<>();
+    for (FlatLists.ComparableList<Comparable> row
+        : fn.apply(Arrays.asList(7, 8))) {
+      rows.add(new ArrayList<>(row));
+    }
+    assertThat(rows,
+        is(Arrays.asList(Arrays.asList(7, 1), Arrays.asList(8, 2))));
+
+    // arr = []; the padded row has a NULL ordinal
+    rows.clear();
+    for (FlatLists.ComparableList<Comparable> row
+        : fn.apply(Collections.emptyList())) {
+      rows.add(new ArrayList<>(row));
+    }
+    assertThat(rows, is(Collections.singletonList(Arrays.asList(null, null))));
+
+    // arr = NULL
+    rows.clear();
+    for (FlatLists.ComparableList<Comparable> row : fn.apply(null)) {
+      rows.add(new ArrayList<>(row));
+    }
+    assertThat(rows, is(Collections.singletonList(Arrays.asList(null, null))));
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test void testFlatZipOuterMultipleCollections() {
+    // Models SELECT u.x, u.y FROM t LEFT JOIN UNNEST(t.a, t.b) AS u(x, y) ON 
TRUE
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(new int[]{-1, -1}, false,
+            new SqlFunctions.FlatProductInputType[]{SCALAR, SCALAR}, true);
+
+    // (a, b) = ([], [7]): zip pads a
+    final List<List<Object>> rows = new ArrayList<>();
+    for (FlatLists.ComparableList<Comparable> row
+        : fn.apply(new Object[]{Collections.emptyList(), Arrays.asList(7)})) {
+      rows.add(new ArrayList<>(row));
+    }
+    assertThat(rows, is(Collections.singletonList(Arrays.asList(null, 7))));
+
+    // (a, b) = ([], NULL): one all-NULL row
+    rows.clear();
+    for (FlatLists.ComparableList<Comparable> row
+        : fn.apply(new Object[]{Collections.emptyList(), null})) {
+      rows.add(new ArrayList<>(row));
+    }
+    assertThat(rows, is(Collections.singletonList(Arrays.asList(null, null))));
+  }
+
+  @Test void testFlatListOuter() {
+    // Models SELECT u.x FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+    // for arr ROW(a INTEGER) ARRAY
+    final Function1<List<Object>, Enumerable<Object>> fn =
+        (Function1) SqlFunctions.flatListOuter();
+    // arr = [ROW(1), ROW(2)]
+    assertThat(fn.apply(Arrays.asList(FlatLists.of(1), 
FlatLists.of(2))).toList(),
+        is(Arrays.asList(1, 2)));
+    // arr = []
+    assertThat(fn.apply(Collections.emptyList()).toList(),
+        is(Collections.singletonList(null)));
+    // arr = NULL
+    assertThat(fn.apply(null).toList(),
+        is(Collections.singletonList(null)));
+  }
 }
diff --git a/core/src/test/resources/sql/unnest.iq 
b/core/src/test/resources/sql/unnest.iq
index 054234fcbe..c4650d77d3 100644
--- a/core/src/test/resources/sql/unnest.iq
+++ b/core/src/test/resources/sql/unnest.iq
@@ -815,4 +815,320 @@ SELECT * FROM UNNEST(ARRAY[
 
 !ok
 
+# Tests for [CALCITE-7670] Uncollect should support LEFT JOIN UNNEST
+!use scott
+
+# LEFT JOIN UNNEST
+# Validated on PostgreSQL 14: same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10, 20] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 2, ARRAY(SELECT 1 FROM (VALUES (0)) AS z(k) WHERE FALSE)
+    FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS INTEGER ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x
+FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+ORDER BY t.id, u.x;
++----+----+
+| ID | X  |
++----+----+
+|  1 | 10 |
+|  1 | 20 |
+|  2 |    |
+|  3 |    |
++----+----+
+(4 rows)
+
+!ok
+
+# An inner UNNEST still drops the rows.
+# Validated on PostgreSQL 14: same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10, 20] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS INTEGER ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x
+FROM t, UNNEST(t.arr) AS u(x)
+ORDER BY t.id, u.x;
++----+----+
+| ID | X  |
++----+----+
+|  1 | 10 |
+|  1 | 20 |
++----+----+
+(2 rows)
+
+!ok
+
+# WITH ORDINALITY: the NULL row from OUTER JOIN has no ordinal.
+# Validated on PostgreSQL 14: same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10, 20] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS INTEGER ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x, u.o
+FROM t LEFT JOIN UNNEST(t.arr) WITH ORDINALITY AS u(x, o) ON TRUE
+ORDER BY t.id, u.x;
++----+----+---+
+| ID | X  | O |
++----+----+---+
+|  1 | 10 | 1 |
+|  1 | 20 | 2 |
+|  3 |    |   |
++----+----+---+
+(3 rows)
+
+!ok
+
+# A filtering ON condition: the left row survives even when the condition
+# rejects every element of a non-empty collection. The padding must come
+# from the join, not from the collection being empty.
+# Validated on PostgreSQL 14: same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 2, ARRAY[20, 30] FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x
+FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON u.x > 15
+ORDER BY t.id, u.x;
++----+----+
+| ID | X  |
++----+----+
+|  1 |    |
+|  2 | 20 |
+|  2 | 30 |
++----+----+
+(3 rows)
+
+!ok
+
+# A NATURAL LEFT JOIN's derived condition compares common columns, so it can
+# reject rows. Here t.x = 1 matches no element, and the left row survives.
+# The condition becomes a Filter between the Correlate and the Uncollect,
+# which is what stops CorrelateUncollectOuterRule from matching this shape.
+# Validated on PostgreSQL 14: same result.
+SELECT *
+FROM (VALUES (1)) AS t(x)
+NATURAL LEFT JOIN UNNEST(ARRAY[2, 3]) AS u(x);
++---+
+| X |
++---+
+| 1 |
++---+
+(1 row)
+
+!ok
+
+# Multi-collection UNNEST and LEFT JOIN
+# Validated on PostgreSQL 14: same result.
+WITH t(id, a, b) AS (
+  SELECT 1, ARRAY[10, 20], ARRAY['p'] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS INTEGER ARRAY), CAST(NULL AS VARCHAR ARRAY)
+    FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x, u.y
+FROM t LEFT JOIN UNNEST(t.a, t.b) AS u(x, y) ON TRUE
+ORDER BY t.id, u.x;
++----+----+---+
+| ID | X  | Y |
++----+----+---+
+|  1 | 10 | p |
+|  1 | 20 |   |
+|  3 |    |   |
++----+----+---+
+(3 rows)
+
+!ok
+
+# MAP collection and LEFT JOIN
+# Postgres does not support MAP values, so this is not validated on Postgres.
+WITH t(id, m) AS (
+  SELECT 1, MAP['a', 10, 'b', 20] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS MAP<VARCHAR, INTEGER>) FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.k, u.v
+FROM t LEFT JOIN UNNEST(t.m) AS u(k, v) ON TRUE
+ORDER BY t.id, u.k;
++----+---+----+
+| ID | K | V  |
++----+---+----+
+|  1 | a | 10 |
+|  1 | b | 20 |
+|  3 |   |    |
++----+---+----+
+(3 rows)
+
+!ok
+
+# An array of single-field ROW values expands to a single scalar column.
+# Validated on PostgreSQL 14 using a named composite type (Postgres cannot
+# type an anonymous ROW array): same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[ROW(10), ROW(20)] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS ROW(a INTEGER) ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x
+FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+ORDER BY t.id, u.x;
++----+----+
+| ID | X  |
++----+----+
+|  1 | 10 |
+|  1 | 20 |
+|  3 |    |
++----+----+
+(3 rows)
+
+!ok
+
+# With LEFT JOIN every element column and the ordinality column must be 
nullable
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10, 20] FROM (VALUES (0)) AS z(k))
+SELECT t.id, u.x, u.o
+FROM t LEFT JOIN UNNEST(t.arr) WITH ORDINALITY AS u(x, o) ON TRUE;
+ID INTEGER(10) NOT NULL
+X INTEGER(10)
+O INTEGER(10)
+!type
+
+!use hr
+
+# Struct elements, expanded into one column per field (standard semantics):
+# dept 30 has no employees.
+# validated on PostgreSQL 14 with an equivalent composite type.
+SELECT d."deptno", e."empid", e."name"
+FROM "hr"."depts" AS d
+LEFT JOIN UNNEST(d."employees") AS e ON TRUE
+ORDER BY d."deptno", e."empid";
++--------+-------+-----------+
+| deptno | empid | name      |
++--------+-------+-----------+
+|     10 |   100 | Bill      |
+|     10 |   150 | Sebastian |
+|     30 |       |           |
+|     40 |   200 | Eric      |
++--------+-------+-----------+
+(4 rows)
+
+!ok
+
+!use hr-presto
+
+# Struct elements kept whole (Trino semantics, PRESTO conformance).
+SELECT d."deptno", e."emp"
+FROM "hr"."depts" AS d
+LEFT JOIN UNNEST(d."employees") AS e("emp") ON TRUE
+ORDER BY d."deptno";
++--------+------------------------------------+
+| deptno | emp                                |
++--------+------------------------------------+
+|     10 | {100, 10, Bill, 10000.0, 1000}     |
+|     10 | {150, 10, Sebastian, 7000.0, null} |
+|     30 |                                    |
+|     40 | {200, 20, Eric, 8000.0, 500}       |
++--------+------------------------------------+
+(4 rows)
+
+!ok
+
+# The same query with CorrelateUncollectOuterRule
+!set hep-rules "
++CoreRules.CORRELATE_UNCOLLECT_OUTER"
+SELECT d."deptno", e."emp"
+FROM "hr"."depts" AS d
+LEFT JOIN UNNEST(d."employees") AS e("emp") ON TRUE
+ORDER BY d."deptno";
++--------+------------------------------------+
+| deptno | emp                                |
++--------+------------------------------------+
+|     10 | {100, 10, Bill, 10000.0, 1000}     |
+|     10 | {150, 10, Sebastian, 7000.0, null} |
+|     30 |                                    |
+|     40 | {200, 20, Eric, 8000.0, 500}       |
++--------+------------------------------------+
+(4 rows)
+
+!ok
+
+!use scott
+
+# CorrelateUncollectOuterRule converts the LEFT correlate over an Uncollect
+# into an INNER correlate over an outer Uncollect; UnnestDecorrelateRule
+# then eliminates the correlate entirely, padding included.
+!set hep-rules "
++CoreRules.CORRELATE_UNCOLLECT_OUTER
++CoreRules.UNNEST_DECORRELATE"
+
+# Validated on PostgreSQL 14: same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10, 20] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS INTEGER ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT u.x
+FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+ORDER BY u.x;
++----+
+| X  |
++----+
+| 10 |
+| 20 |
+|    |
++----+
+(3 rows)
+
+!ok
+
+# The correlate is gone
+EnumerableSort(sort0=[$0], dir0=[ASC])
+  EnumerableUncollect(isOuter=[true])
+    EnumerableUnion(all=[true])
+      EnumerableCalc(expr#0=[{inputs}], expr#1=[10], expr#2=[20], 
expr#3=[ARRAY($t1, $t2)], EXPR$1=[$t3])
+        EnumerableValues(tuples=[[{ 0 }]])
+      EnumerableCalc(expr#0..1=[{inputs}], EXPR$1=[$t1])
+        EnumerableValues(tuples=[[{ 3, null }]])
+!plan
+
+# The same elimination for an array of single-field ROW values.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[ROW(10), ROW(20)] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS ROW(a INTEGER) ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT u.x
+FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+ORDER BY u.x;
++----+
+| X  |
++----+
+| 10 |
+| 20 |
+|    |
++----+
+(3 rows)
+
+!ok
+
+# UnnestDecorrelateRule alone must not fire on a LEFT correlate
+!set hep-rules "
++CoreRules.UNNEST_DECORRELATE"
+
+# Validated on PostgreSQL 14: same result.
+WITH t(id, arr) AS (
+  SELECT 1, ARRAY[10, 20] FROM (VALUES (0)) AS z(k)
+  UNION ALL
+  SELECT 3, CAST(NULL AS INTEGER ARRAY) FROM (VALUES (0)) AS z(k))
+SELECT u.x
+FROM t LEFT JOIN UNNEST(t.arr) AS u(x) ON TRUE
+ORDER BY u.x;
++----+
+| X  |
++----+
+| 10 |
+| 20 |
+|    |
++----+
+(3 rows)
+
+!ok
+
 # End unnest.iq

Reply via email to