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

commit 33294714aa0b2831b399ce5307f06261d2580a37
Author: Mihai Budiu <[email protected]>
AuthorDate: Sat Aug 1 21:47:17 2026 -0700

    [CALCITE-7669] Uncollect should support the Trino semantics of UNNEST
    
    Signed-off-by: Mihai Budiu <[email protected]>
---
 .../adapter/enumerable/EnumerableUncollect.java    |  38 ++++-
 .../enumerable/EnumerableUncollectRule.java        |   2 +-
 .../org/apache/calcite/rel/core/Uncollect.java     | 120 ++++++++++---
 .../calcite/rel/logical/ToLogicalConverter.java    |   5 +-
 .../apache/calcite/rel/mutable/MutableRels.java    |   5 +-
 .../calcite/rel/mutable/MutableUncollect.java      |  37 +++-
 .../org/apache/calcite/runtime/SqlFunctions.java   |  49 +++++-
 .../org/apache/calcite/sql/SqlUnnestOperator.java  |  13 +-
 .../apache/calcite/sql2rel/SqlToRelConverter.java  |   4 +-
 .../org/apache/calcite/test/CoreQuidemTest.java    |   7 +
 .../org/apache/calcite/test/SqlFunctionsTest.java  |  82 +++++++++
 .../apache/calcite/test/SqlToRelConverterTest.xml  |  14 +-
 core/src/test/resources/sql/unnest.iq              | 189 +++++++++++++++++++++
 13 files changed, 508 insertions(+), 57 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 de167cd08e..8f193a4ace 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,7 +51,16 @@ 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) {
-    super(cluster, traitSet, child, withOrdinality, Collections.emptyList());
+    this(cluster, traitSet, child, withOrdinality, true);
+  }
+
+  /** Creates an EnumerableUncollect.
+   *
+   * <p>Use {@link #create} unless you know what you're doing. */
+  public EnumerableUncollect(RelOptCluster cluster, RelTraitSet traitSet,
+      RelNode child, boolean withOrdinality, boolean expandStructFields) {
+    super(cluster, traitSet, child, withOrdinality, Collections.emptyList(),
+        expandStructFields);
     assert getConvention() instanceof EnumerableConvention;
     assert getConvention() == child.getConvention();
   }
@@ -72,10 +81,27 @@ public static EnumerableUncollect create(RelTraitSet 
traitSet, RelNode input,
     return new EnumerableUncollect(cluster, traitSet, input, withOrdinality);
   }
 
+  /**
+   * Creates an EnumerableUncollect.
+   *
+   * @param traitSet           Trait set
+   * @param input              Input relational expression
+   * @param withOrdinality     Whether output should contain an 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
+   */
+  public static EnumerableUncollect create(RelTraitSet traitSet, RelNode input,
+      boolean withOrdinality, boolean expandStructFields) {
+    final RelOptCluster cluster = input.getCluster();
+    return new EnumerableUncollect(cluster, traitSet, input, withOrdinality,
+        expandStructFields);
+  }
+
   @Override public EnumerableUncollect copy(RelTraitSet traitSet,
       RelNode newInput) {
     return new EnumerableUncollect(getCluster(), traitSet, newInput,
-        withOrdinality);
+        withOrdinality, expandStructFields);
   }
 
   @Override public Result implement(EnumerableRelImplementor implementor, 
Prefer pref) {
@@ -105,7 +131,7 @@ public static EnumerableUncollect create(RelTraitSet 
traitSet, RelNode input,
         inputTypes.add(FlatProductInputType.MAP);
       } else {
         final RelDataType elementType = getComponentTypeOrThrow(type);
-        if (elementType.isStruct()) {
+        if (elementType.isStruct() && expandStructFields) {
           if (elementType.getFieldCount() == 1 && 
child.getRowType().getFieldList().size() == 1
               && !withOrdinality) {
             // Solves CALCITE-4063: if we are processing a single field, which 
is a struct with a
@@ -116,6 +142,12 @@ public static EnumerableUncollect create(RelTraitSet 
traitSet, RelNode input,
             fieldCounts.add(elementType.getFieldCount());
             inputTypes.add(FlatProductInputType.LIST);
           }
+        } else if (elementType.isStruct()) {
+          // A struct element kept whole occupies a single output column,
+          // like a scalar element, but its row value must be converted from
+          // the collection's internal list representation to Object[].
+          fieldCounts.add(-1);
+          inputTypes.add(FlatProductInputType.STRUCT);
         } else {
           fieldCounts.add(-1);
           inputTypes.add(FlatProductInputType.SCALAR);
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 9079964897..95a9237c22 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,6 @@ protected EnumerableUncollectRule(Config config) {
         convert(input,
             input.getTraitSet().replace(EnumerableConvention.INSTANCE));
     return EnumerableUncollect.create(traitSet, newInput,
-        uncollect.withOrdinality);
+        uncollect.withOrdinality, uncollect.expandStructFields);
   }
 }
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 2d4c3620a7..e607509ddc 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
@@ -50,10 +50,21 @@
  * <p>Like its inverse operation {@link Collect}, Uncollect is generally
  * invoked in a nested loop, driven by
  * {@link org.apache.calcite.rel.logical.LogicalCorrelate} or similar.
+ *
+ * <p>{@code expandStructFields} controls the shape of the element columns:
+ * if {@code true} a collection whose element type is a struct produces one
+ * 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.
  */
 public class Uncollect extends SingleRel {
   public final boolean withOrdinality;
 
+  /** 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. */
+  public final boolean expandStructFields;
+
   // To alias the items in Uncollect list,
   // i.e., "UNNEST(a, b, c) as T(d, e, f)"
   // outputs as row type Record(d, e, f) where the field "d" has element type 
of "a",
@@ -74,12 +85,30 @@ public Uncollect(RelOptCluster cluster, RelTraitSet 
traitSet,
   /** Creates an Uncollect.
    *
    * <p>Use {@link #create} unless you know what you're doing. */
-  @SuppressWarnings("method.invocation.invalid")
   public Uncollect(RelOptCluster cluster, RelTraitSet traitSet, RelNode input,
       boolean withOrdinality, List<String> itemAliases) {
+    // 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());
+  }
+
+  /** Creates an Uncollect.
+   *
+   * @param input              Input relational expression
+   * @param withOrdinality     Whether output should contain an ORDINALITY 
column
+   * @param itemAliases        Aliases for the operand items
+   * @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
+   */
+  @SuppressWarnings("method.invocation.invalid")
+  public Uncollect(RelOptCluster cluster, RelTraitSet traitSet, RelNode input,
+      boolean withOrdinality, List<String> itemAliases, boolean 
expandStructFields) {
     super(cluster, traitSet, input);
     this.withOrdinality = withOrdinality;
     this.itemAliases = ImmutableList.copyOf(itemAliases);
+    this.expandStructFields = expandStructFields;
     requireNonNull(deriveRowType(), "invalid child rowType");
   }
 
@@ -88,7 +117,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("withOrdinality", false), Collections.emptyList(),
+        input.getBoolean("expandStructFields", true));
   }
 
   /**
@@ -111,6 +141,28 @@ public static Uncollect create(
     return new Uncollect(cluster, traitSet, input, withOrdinality, 
itemAliases);
   }
 
+  /**
+   * Creates an Uncollect.
+   *
+   * @param traitSet           Trait set
+   * @param input              Input relational expression
+   * @param withOrdinality     Whether output should contain an ORDINALITY 
column
+   * @param itemAliases        Aliases for the operand items
+   * @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
+   */
+  public static Uncollect create(
+      RelTraitSet traitSet,
+      RelNode input,
+      boolean withOrdinality,
+      List<String> itemAliases,
+      boolean expandStructFields) {
+    final RelOptCluster cluster = input.getCluster();
+    return new Uncollect(cluster, traitSet, input, withOrdinality, itemAliases,
+        expandStructFields);
+  }
+
   //~ Methods ----------------------------------------------------------------
 
   @Override public RelNode accept(RelShuttle shuttle) {
@@ -119,7 +171,8 @@ public static Uncollect create(
 
   @Override public RelWriter explainTerms(RelWriter pw) {
     return super.explainTerms(pw)
-        .itemIf("withOrdinality", withOrdinality, withOrdinality);
+        .itemIf("withOrdinality", withOrdinality, withOrdinality)
+        .itemIf("expandStructFields", expandStructFields, !expandStructFields);
   }
 
   @Override public final RelNode copy(RelTraitSet traitSet,
@@ -129,34 +182,47 @@ public static Uncollect create(
 
   public RelNode copy(RelTraitSet traitSet, RelNode input) {
     assert traitSet.containsIfApplicable(Convention.NONE);
-    return new Uncollect(getCluster(), traitSet, input, withOrdinality, 
itemAliases);
-  }
-
-  @Override protected RelDataType deriveRowType() {
-    return deriveUncollectRowType(input, withOrdinality, itemAliases);
+    return new Uncollect(getCluster(), traitSet, input, withOrdinality, 
itemAliases,
+        expandStructFields);
   }
 
   /**
    * Returns the row type returned by applying the 'UNNEST' operation to a
    * relational expression.
    *
-   * <p>Each column in the relational expression must be a multiset of
-   * structs or an array. The return type is the combination of expanding
-   * element types from each column, plus an ORDINALITY column if {@code
-   * withOrdinality}. If {@code itemAliases} is not empty, the element types
-   * would not expand, each column element outputs as a whole (the return
-   * type has same column types as input type).
+   * @deprecated Construct an {@link Uncollect} and call
+   * {@link #getRowType()} instead.
    */
+  @Deprecated // to be removed before 2.0
   public static RelDataType deriveUncollectRowType(RelNode rel,
       boolean withOrdinality, List<String> itemAliases) {
-    RelDataType inputType = rel.getRowType();
+    return new Uncollect(rel.getCluster(), rel.getTraitSet(), rel,
+        withOrdinality, itemAliases).getRowType();
+  }
+
+  /**
+   * Returns the row type of the 'UNNEST' operation.
+   *
+   * <p>Each column in the input relational expression must be a multiset of
+   * structs or an array. The return type is the combination of expanding
+   * element types from each column, plus an ORDINALITY column if {@code
+   * withOrdinality}.
+   *
+   * <p>{@code expandStructFields} controls the expansion of struct element
+   * types: if {@code true}, one output column per struct field; if {@code
+   * false}, a single column typed as the whole element. Maps always expand
+   * into a key and a value column. {@code itemAliases}, when not empty,
+   * names the non-expanded element columns.
+   */
+  @Override protected RelDataType deriveRowType() {
+    RelDataType inputType = input.getRowType();
     assert inputType.isStruct() : inputType + " is not a struct";
 
     boolean requireAlias = !itemAliases.isEmpty();
     assert !requireAlias || itemAliases.size() == inputType.getFieldCount();
 
     final List<RelDataTypeField> fields = inputType.getFieldList();
-    final RelDataTypeFactory typeFactory = rel.getCluster().getTypeFactory();
+    final RelDataTypeFactory typeFactory = getCluster().getTypeFactory();
     final RelDataTypeFactory.Builder builder = typeFactory.builder();
 
     if (fields.size() == 1
@@ -192,12 +258,7 @@ public static RelDataType deriveUncollectRowType(RelNode 
rel,
           throw RESOURCE.unnestArgument().ex();
         }
         boolean isNullable = componentType.isNullable() || padNullable;
-        if (requireAlias) {
-          RelDataType colType = padNullable
-              ? typeFactory.enforceTypeWithNullability(componentType, true)
-              : componentType;
-          builder.add(itemAliases.get(i), colType);
-        } else if (componentType.isStruct()) {
+        if (expandStructFields && componentType.isStruct()) {
           for (RelDataTypeField fieldInfo : componentType.getFieldList()) {
             RelDataType fieldType = fieldInfo.getType();
             if (isNullable) {
@@ -206,11 +267,18 @@ public static RelDataType deriveUncollectRowType(RelNode 
rel,
             builder.add(fieldInfo.getName(), fieldType);
           }
         } else {
-          // Element type is not a record, use the field name of the element 
directly
-          RelDataType colType = padNullable
-              ? typeFactory.enforceTypeWithNullability(componentType, true)
+          // A single column typed as the whole element, named by the item
+          // alias when present, otherwise by the collection field's name.
+          RelDataType elementType = componentType.isStruct()
+              ? typeFactory.builder().kind(componentType.getStructKind())
+                  .addAll(componentType.getFieldList()).build()
               : componentType;
-          builder.add(field.getName(), colType);
+          // A NULL collection element becomes a NULL value in this column, so
+          // the column is nullable whenever the element type is.
+          RelDataType colType = isNullable
+              ? typeFactory.enforceTypeWithNullability(elementType, true)
+              : elementType;
+          builder.add(requireAlias ? itemAliases.get(i) : field.getName(), 
colType);
         }
       }
     }
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 d7cee2dae7..4ff564f1fd 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
@@ -41,8 +41,6 @@
 import org.apache.calcite.rel.core.Window;
 import org.apache.calcite.tools.RelBuilder;
 
-import java.util.Collections;
-
 /**
  * Shuttle to convert any rel plan to a plan with all logical nodes.
  */
@@ -191,7 +189,8 @@ public ToLogicalConverter(RelBuilder relBuilder) {
       final Uncollect uncollect = (Uncollect) relNode;
       final RelNode input = visit(uncollect.getInput());
       return Uncollect.create(input.getTraitSet(), input,
-          uncollect.withOrdinality, Collections.emptyList());
+          uncollect.withOrdinality, uncollect.getItemAliases(),
+          uncollect.expandStructFields);
     }
 
     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 ed509b6d60..176be5cfec 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());
+          Collections.emptyList(), uncollect.expandStructFields);
     }
     case WINDOW: {
       final MutableWindow window = (MutableWindow) node;
@@ -378,7 +378,8 @@ public static MutableRel toMutable(RelNode rel) {
     if (rel instanceof Uncollect) {
       final Uncollect uncollect = (Uncollect) rel;
       final MutableRel input = toMutable(uncollect.getInput());
-      return MutableUncollect.of(uncollect.getRowType(), input, 
uncollect.withOrdinality);
+      return MutableUncollect.of(uncollect.getRowType(), input,
+          uncollect.withOrdinality, uncollect.expandStructFields);
     }
     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 594d109b5d..bae3854f69 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
@@ -25,15 +25,17 @@
 /** Mutable equivalent of {@link org.apache.calcite.rel.core.Uncollect}. */
 public class MutableUncollect extends MutableSingleRel {
   public final boolean withOrdinality;
+  public final boolean expandStructFields;
 
   private MutableUncollect(RelDataType rowType,
-      MutableRel input, boolean withOrdinality) {
+      MutableRel input, boolean withOrdinality, boolean expandStructFields) {
     super(MutableRelType.UNCOLLECT, rowType, input);
     this.withOrdinality = withOrdinality;
+    this.expandStructFields = expandStructFields;
   }
 
   /**
-   * Creates a MutableUncollect.
+   * Creates a MutableUncollect that expands struct elements.
    *
    * @param rowType         Row type
    * @param input           Input relational expression
@@ -42,26 +44,47 @@ private MutableUncollect(RelDataType rowType,
    */
   public static MutableUncollect of(RelDataType rowType,
       MutableRel input, boolean withOrdinality) {
-    return new MutableUncollect(rowType, input, withOrdinality);
+    return of(rowType, input, withOrdinality, true);
+  }
+
+  /**
+   * 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
+   */
+  public static MutableUncollect of(RelDataType rowType,
+      MutableRel input, boolean withOrdinality, boolean expandStructFields) {
+    return new MutableUncollect(rowType, input, withOrdinality,
+        expandStructFields);
   }
 
   @Override public boolean equals(@Nullable Object obj) {
     return obj == this
         || obj instanceof MutableUncollect
         && withOrdinality == ((MutableUncollect) obj).withOrdinality
+        && expandStructFields == ((MutableUncollect) obj).expandStructFields
         && input.equals(((MutableUncollect) obj).input);
   }
 
   @Override public int hashCode() {
-    return Objects.hash(input, withOrdinality);
+    return Objects.hash(input, withOrdinality, expandStructFields);
   }
 
   @Override public StringBuilder digest(StringBuilder buf) {
-    return buf.append("Uncollect(withOrdinality: ")
-        .append(withOrdinality).append(")");
+    return buf.append("Uncollect(withOrdinality: ").append(withOrdinality)
+        .append(", expandStructFields: ").append(expandStructFields)
+        .append(")");
   }
 
   @Override public MutableRel clone() {
-    return MutableUncollect.of(rowType, input.clone(), withOrdinality);
+    return MutableUncollect.of(rowType, input.clone(), withOrdinality,
+        expandStructFields);
   }
 }
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 56e6bdb422..4b9b48041f 100644
--- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
+++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java
@@ -209,6 +209,23 @@ public class SqlFunctions {
   private static final Function1<List<Object>, Enumerable<Object>> 
LIST_AS_ENUMERABLE =
       a0 -> a0 == null ? Linq4j.emptyEnumerable() : Linq4j.asEnumerable(a0);
 
+  /** Like {@link #LIST_AS_ENUMERABLE}, for a collection whose struct elements
+   * are kept whole: each element is converted to an Object[] struct value. */
+  private static final Function1<List<Object>, Enumerable<@Nullable Object>>
+      STRUCT_LIST_AS_ENUMERABLE =
+          a0 -> a0 == null ? Linq4j.emptyEnumerable()
+              : 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")
+  private static @Nullable Object structValue(@Nullable Object element) {
+    if (element == null || element instanceof Object[]) {
+      return element;
+    }
+    return ((List) element).toArray();
+  }
+
   @SuppressWarnings("unused")
   private static final Function1<Object[], Enumerable<@Nullable Object[]>> 
ARRAY_CARTESIAN_PRODUCT =
       SqlFunctions::arrayCartesianProduct;
@@ -7590,8 +7607,15 @@ public static Function1<Object, 
Enumerable<ComparableList<Comparable>>> flatZip(
         // Simple unnest without ordinality
         //noinspection unchecked
         return (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;
       } else {
-        // unnest with ordinality for a single scalar column
+        // unnest with ordinality for a single column
         return row -> z2(new Object[] { row }, fieldCounts, withOrdinality, 
inputTypes);
       }
     }
@@ -7604,9 +7628,10 @@ public static Function1<Object, 
Enumerable<ComparableList<Comparable>>> flatZip(
    * padding shorter collections with {@code NULL}.
    *
    * @param lists        one element per collection (scalar list, struct list, 
or map)
-   * @param fieldCounts  output column count for each collection (-1 for a 
collection of scalars)
+   * @param fieldCounts  output column count for each collection (-1 for a 
collection
+   *                     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, or 
MAP)
+   * @param inputTypes   type of elements in each collection (SCALAR, LIST, 
STRUCT, or MAP)
    */
   @SuppressWarnings("rawtypes")
   private static Enumerable<FlatLists.ComparableList<Comparable>> z2(
@@ -7626,6 +7651,17 @@ private static 
Enumerable<FlatLists.ComparableList<Comparable>> z2(
         enumerators.add(Linq4j.transform(Linq4j.enumerator(list), 
FlatLists::of));
         widths[i] = 1;
         break;
+      case STRUCT:
+        // A struct element kept whole occupies a single output column, like a
+        // scalar element, but its value must be converted to Object[].
+        @SuppressWarnings("unchecked") List<Object> structList =
+            (List<Object>) inputObject;
+        @SuppressWarnings("unchecked") Enumerator<List<Comparable>> 
structEnumerator =
+            (Enumerator) Linq4j.transform(Linq4j.enumerator(structList),
+                (Object e) -> FlatLists.ofSingle(structValue(e)));
+        enumerators.add(structEnumerator);
+        widths[i] = 1;
+        break;
       case LIST:
         @SuppressWarnings("unchecked") List<List<Comparable>> listList =
             (List<List<Comparable>>) inputObject;
@@ -7766,7 +7802,10 @@ private static class ZipPaddedEnumerator
         int width = widths[i];
         if (!endOfCollection[i]) {
           final Object elemRow = enumerators.get(i).current();
-          if (elemRow instanceof Object[]) {
+          if (elemRow == null) {
+            // A NULL struct element expands to a row of NULLs, one per field.
+            Arrays.fill(flatElements, column, column + width, null);
+          } else if (elemRow instanceof Object[]) {
             final Object[] arr = (Object[]) elemRow;
             for (int p = 0; p < width; p++) {
               flatElements[column + p] = p < arr.length ? arr[p] : null;
@@ -7900,7 +7939,7 @@ public enum JsonScope {
 
   /** Type of argument passed into {@link #flatZip}. */
   public enum FlatProductInputType {
-    SCALAR, LIST, MAP
+    SCALAR, LIST, MAP, STRUCT
   }
 
   /** Type of part to extract passed into {@link ParseUrlFunction#parseUrl}. */
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlUnnestOperator.java 
b/core/src/main/java/org/apache/calcite/sql/SqlUnnestOperator.java
index af1753f8e8..61ada97823 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlUnnestOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlUnnestOperator.java
@@ -99,6 +99,9 @@ public SqlUnnestOperator(boolean withOrdinality) {
       } else {
         RelDataType componentType = requireNonNull(type.getComponentType(), 
"componentType");
         boolean isNullable = componentType.isNullable() || padNullable;
+        // Whether a struct element expands into one column per field depends
+        // on the SQL conformance; allowAliasUnnestItems describes how
+        // collections of ROW values are expanded.
         if (!allowAliasUnnestItems(opBinding) && componentType.isStruct()) {
           for (RelDataTypeField field : componentType.getFieldList()) {
             RelDataType fieldType = field.getType();
@@ -108,9 +111,15 @@ public SqlUnnestOperator(boolean withOrdinality) {
             builder.add(field.getName(), fieldType);
           }
         } else {
-          RelDataType colType = padNullable
-              ? typeFactory.enforceTypeWithNullability(componentType, true)
+          RelDataType elementType = componentType.isStruct()
+              ? typeFactory.builder().kind(componentType.getStructKind())
+                  .addAll(componentType.getFieldList()).build()
               : componentType;
+          // A NULL collection element becomes a NULL value in this column, so
+          // the column is nullable whenever the element type is.
+          RelDataType colType = isNullable
+              ? typeFactory.enforceTypeWithNullability(elementType, true)
+              : elementType;
           builder.add(SqlUtil.deriveAliasFromOrdinal(operand), colType);
         }
       }
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 182ed7a254..3ca7ae44f8 100644
--- a/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java
+++ b/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java
@@ -2888,7 +2888,8 @@ private void convertUnnest(Blackboard bb, SqlCall call, 
@Nullable List<String> f
         // so Uncollect's row type stays aligned with the validator.
         List<String> itemAliases;
         if (fieldNames != null) {
-          itemAliases = fieldNames;
+          // do not include the ordinality column name
+          itemAliases = fieldNames.subList(0, nodes.size());
         } else {
           itemAliases = new ArrayList<>(nodes.size());
           for (int i = 0; i < nodes.size(); i++) {
@@ -2899,6 +2900,7 @@ private void convertUnnest(Blackboard bb, SqlCall call, 
@Nullable List<String> f
             .push(child)
             .project(exprs)
             .uncollect(itemAliases, operator.withOrdinality)
+            .let(r -> fieldNames == null ? r : r.rename(fieldNames))
             .build();
       } else {
         // REVIEW danny 2020-04-26: should we unify the normal field aliases 
and
diff --git a/core/src/test/java/org/apache/calcite/test/CoreQuidemTest.java 
b/core/src/test/java/org/apache/calcite/test/CoreQuidemTest.java
index b878bfb085..bbb547b858 100644
--- a/core/src/test/java/org/apache/calcite/test/CoreQuidemTest.java
+++ b/core/src/test/java/org/apache/calcite/test/CoreQuidemTest.java
@@ -185,6 +185,13 @@ protected Collection<String> data() {
               .with(CalciteAssert.SchemaSpec.STEELWHEELS)
               .with(Lex.BIG_QUERY))
               .connect();
+        case "hr-presto":
+          // Same as "hr", but uses PRESTO conformance, under which
+          // UNNEST(array) AS alias does not expand struct elements.
+          return customize(CalciteAssert.hr()
+              .with(CalciteConnectionProperty.CONFORMANCE,
+                  SqlConformanceEnum.PRESTO))
+              .connect();
         default:
           return super.connect(name, reference);
         }
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 9231437cf8..e3827c1253 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
@@ -46,6 +46,7 @@
 import static 
org.apache.calcite.avatica.util.DateTimeUtils.timestampStringToUnixDate;
 import static 
org.apache.calcite.runtime.SqlFunctions.FlatProductInputType.LIST;
 import static 
org.apache.calcite.runtime.SqlFunctions.FlatProductInputType.SCALAR;
+import static 
org.apache.calcite.runtime.SqlFunctions.FlatProductInputType.STRUCT;
 import static org.apache.calcite.runtime.SqlFunctions.arraysOverlap;
 import static org.apache.calcite.runtime.SqlFunctions.charLength;
 import static org.apache.calcite.runtime.SqlFunctions.concat;
@@ -2238,4 +2239,85 @@ private static List<List<Object>> zipScalars(
     assertThat(rows.get(0), is(list(1, 2, 10, 20)));
     assertThat(rows.get(1), is(Arrays.asList(3, 4, null, null)));
   }
+
+  /** The runtime representation of {@code ARRAY[ROW(1, 'x'), ROW(2, 'y')]}:
+   * a list whose elements are the field lists of each ROW. */
+  private static List<List<Comparable>> rowArray() {
+    return Arrays.asList(FlatLists.of(1, "x"), FlatLists.of(2, "y"));
+  }
+
+  @Test void testZipPaddedWholeStructElements() {
+    // Models the Trino semantics of
+    //   UNNEST(ARRAY[ROW(1, 'x'), ROW(2, 'y')], ARRAY[10, 20]) AS t(s, i):
+    // the STRUCT collection keeps each ROW element whole, so column s holds
+    // the element as an Object[]; the scalar column i zips alongside.
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(
+            new int[]{-1, -1}, // one output column per collection
+            false,             // no ordinality
+            new SqlFunctions.FlatProductInputType[]{STRUCT, SCALAR});
+
+    final List<List<Object>> rows = new ArrayList<>();
+    for (FlatLists.ComparableList<Comparable> row
+        : fn.apply(new Object[]{rowArray(), Arrays.asList(10, 20)})) {
+      rows.add(new ArrayList<>(row));
+    }
+
+    // Expected rows: ({1, 'x'}, 10) and ({2, 'y'}, 20).
+    assertThat(rows, hasSize(2));
+    assertArrayEquals(new Object[]{1, "x"}, (Object[]) rows.get(0).get(0));
+    assertThat(rows.get(0).get(1), is(10));
+    assertArrayEquals(new Object[]{2, "y"}, (Object[]) rows.get(1).get(0));
+    assertThat(rows.get(1).get(1), is(20));
+  }
+
+  @Test void testZipPaddedNullStructElement() {
+    // A null element of an expanded ROW ARRAY is a null List, which
+    // must be expanded to one null per ROW field rather than dereferencing 
the list.
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(
+            new int[]{2, -1}, // two columns from the struct, one scalar column
+            false,            // no ordinality
+            new SqlFunctions.FlatProductInputType[]{LIST, SCALAR});
+
+    final List<List<Object>> rows = new ArrayList<>();
+    for (FlatLists.ComparableList<Comparable> row
+        : fn.apply(new Object[]{
+            Arrays.asList(FlatLists.of(1, "x"), null),
+            Arrays.asList(10, 20)})) {
+      rows.add(new ArrayList<>(row));
+    }
+
+    assertThat(rows, hasSize(2));
+    assertThat(rows.get(0), is(Arrays.asList(1, "x", 10)));
+    assertThat(rows.get(1), is(Arrays.asList(null, null, 20)));
+  }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  @Test void testFlatZipSingleWholeStructCollection() {
+    // Models the Trino semantics of
+    //   UNNEST(ARRAY[ROW(1, 'x'), ROW(2, 'y')]) AS t(s):
+    // the output has the single ROW-typed column s, which PhysTypeImpl stores
+    // in SCALAR row format, so each output row is the bare Object[] struct
+    // value rather than a singleton list.
+    final Function1<Object, Enumerable<FlatLists.ComparableList<Comparable>>> 
fn =
+        SqlFunctions.flatZip(
+            new int[]{-1}, false,
+            new SqlFunctions.FlatProductInputType[]{STRUCT});
+
+    final List<Object> rows = new ArrayList<>();
+    for (Object row : (Enumerable) fn.apply(rowArray())) {
+      rows.add(row);
+    }
+
+    // Expected rows: {1, 'x'} and {2, 'y'}.
+    assertThat(rows, hasSize(2));
+    assertArrayEquals(new Object[]{1, "x"}, (Object[]) rows.get(0));
+    assertArrayEquals(new Object[]{2, "y"}, (Object[]) rows.get(1));
+
+    // UNNEST of a null array yields no rows.
+    assertThat(((Enumerable) fn.apply(null)).any(), is(false));
+  }
 }
diff --git 
a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml 
b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
index aafc9c11ef..217b2bbc03 100644
--- a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
@@ -336,7 +336,7 @@ from UNNEST(ARRAY[1, 2, 3]) as t]]>
     <Resource name="plan">
       <![CDATA[
 LogicalProject(T=[$0])
-  Uncollect
+  Uncollect(expandStructFields=[false])
     LogicalProject(EXPR$0=[ARRAY(1, 2, 3)])
       LogicalValues(tuples=[[{ 0 }]])
 ]]>
@@ -348,7 +348,7 @@ LogicalProject(T=[$0])
 LogicalProject(DEPTNO=[$0], E=[$5], EMPNO=[$6.EMPNO])
   LogicalCorrelate(correlation=[$cor0], joinType=[inner], requiredColumns=[{2, 
3}])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT_NESTED_EXPANDED]])
-    Uncollect
+    Uncollect(expandStructFields=[false])
       LogicalProject(ADMINS=[$cor0.ADMINS], EMPLOYEES=[$cor0.EMPLOYEES])
         LogicalValues(tuples=[[{ 0 }]])
 ]]>
@@ -365,7 +365,7 @@ from dept_nested_expanded as d CROSS JOIN
 LogicalProject(DEPTNO=[$0], E=[$5], EMPNO=[$6.EMPNO])
   LogicalCorrelate(correlation=[$cor0], joinType=[inner], requiredColumns=[{2, 
3}])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT_NESTED_EXPANDED]])
-    Uncollect
+    Uncollect(expandStructFields=[false])
       LogicalProject(ADMINS=[$cor0.ADMINS], EMPLOYEES=[$cor0.EMPLOYEES])
         LogicalValues(tuples=[[{ 0 }]])
 ]]>
@@ -382,7 +382,7 @@ from dept_nested_expanded as d CROSS JOIN
 LogicalProject(DEPTNO=[$0], EMPNO=[$5.EMPNO])
   LogicalCorrelate(correlation=[$cor0], joinType=[inner], 
requiredColumns=[{2}])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT_NESTED_EXPANDED]])
-    Uncollect
+    Uncollect(expandStructFields=[false])
       LogicalProject(EMPLOYEES=[$cor0.EMPLOYEES])
         LogicalValues(tuples=[[{ 0 }]])
 ]]>
@@ -399,7 +399,7 @@ from dept_nested_expanded as d,
 LogicalProject(DEPTNO=[$0], EMPNO=[$5.EMPNO])
   LogicalCorrelate(correlation=[$cor0], joinType=[inner], 
requiredColumns=[{2}])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT_NESTED_EXPANDED]])
-    Uncollect
+    Uncollect(expandStructFields=[false])
       LogicalProject(EMPLOYEES=[$cor0.EMPLOYEES])
         LogicalValues(tuples=[[{ 0 }]])
 ]]>
@@ -421,7 +421,7 @@ from dept_nested_expanded as d,
 LogicalProject(DEPTNO=[$0], EMPNO=[$5.EMPNO])
   LogicalCorrelate(correlation=[$cor0], joinType=[inner], 
requiredColumns=[{2}])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT_NESTED_EXPANDED]])
-    Uncollect
+    Uncollect(expandStructFields=[false])
       LogicalProject(EMPLOYEES=[$cor0.EMPLOYEES])
         LogicalValues(tuples=[[{ 0 }]])
 ]]>
@@ -438,7 +438,7 @@ from dept_nested_expanded as d,
 LogicalProject(DEPTNO=[$0], A=[$5])
   LogicalCorrelate(correlation=[$cor0], joinType=[inner], 
requiredColumns=[{3}])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT_NESTED_EXPANDED]])
-    Uncollect
+    Uncollect(expandStructFields=[false])
       LogicalProject(ADMINS=[$cor0.ADMINS])
         LogicalValues(tuples=[[{ 0 }]])
 ]]>
diff --git a/core/src/test/resources/sql/unnest.iq 
b/core/src/test/resources/sql/unnest.iq
index 8defd3b01c..054234fcbe 100644
--- a/core/src/test/resources/sql/unnest.iq
+++ b/core/src/test/resources/sql/unnest.iq
@@ -626,4 +626,193 @@ WHERE (
 
 !ok
 
+!use scott
+
+# Standard UNNEST semantics: struct elements expand into one column per field, 
so a
+# NULL element yields a row of NULLs.
+SELECT * FROM UNNEST(ARRAY[
+    ROW(1, 'x'),
+    CAST(NULL AS ROW(a INTEGER, b CHAR(1)))]) AS t(a, b);
++---+---+
+| A | B |
++---+---+
+| 1 | x |
+|   |   |
++---+---+
+(2 rows)
+
+!ok
+
+# Same as previous WITH ORDINALITY
+SELECT * FROM UNNEST(ARRAY[
+    ROW(1, 'x'),
+    CAST(NULL AS ROW(a INTEGER, b CHAR(1)))]) WITH ORDINALITY AS t(a, b, o);
++---+---+---+
+| A | B | O |
++---+---+---+
+| 1 | x | 1 |
+|   |   | 2 |
++---+---+---+
+(2 rows)
+
+!ok
+
+# A result column is nullable whenever the ROW is nullable or the field is.
+# Here no element is NULL, so the ROW is NOT NULL and each column keeps its
+# own field nullability.
+SELECT * FROM UNNEST(ARRAY[ROW(1, CAST(NULL AS INTEGER))]) AS t(a, b);
++---+---+
+| A | B |
++---+---+
+| 1 |   |
++---+---+
+(1 row)
+
+!ok
+A INTEGER(10) NOT NULL
+B INTEGER(10)
+!type
+
+# Same fields, but a NULL element makes the ROW nullable, so column A is also 
nullable
+SELECT * FROM UNNEST(ARRAY[
+    ROW(1, CAST(NULL AS INTEGER)),
+    CAST(NULL AS ROW(a INTEGER, b INTEGER))]) AS t(a, b);
++---+---+
+| A | B |
++---+---+
+| 1 |   |
+|   |   |
++---+---+
+(2 rows)
+
+!ok
+A INTEGER(10)
+B INTEGER(10)
+!type
+
+# Tests for [CALCITE-7669] Uncollect should support the Trino semantics of 
UNNEST
+# PRESTO conformance: UNNEST(array) AS t(col) does not expand a struct
+# element into its fields; col holds the whole struct, accessed by dot
+# notation.
+!use hr-presto
+
+# INNER comma-join: Marketing (0 employees) is dropped.
+select d."name" as dept, e.emp."name" as ename, e.emp."empid" as empid
+from "hr"."depts" as d,
+UNNEST(d."employees") as e(emp);
++-------+-----------+-------+
+| DEPT  | ENAME     | EMPID |
++-------+-----------+-------+
+| HR    | Eric      |   200 |
+| Sales | Bill      |   100 |
+| Sales | Sebastian |   150 |
++-------+-----------+-------+
+(3 rows)
+
+!ok
+
+# WITH ORDINALITY: the struct column stays whole; ordinality still expands.
+select e.emp."name" as ename, e.rn
+from "hr"."depts" as d,
+UNNEST(d."employees") WITH ORDINALITY as e(emp, rn);
++-----------+----+
+| ENAME     | RN |
++-----------+----+
+| Eric      |  1 |
+| Bill      |  1 |
+| Sebastian |  2 |
++-----------+----+
+(3 rows)
+
+!ok
+
+# Output a whole struct column
+select d."name" as dept, e.emp as emp
+from "hr"."depts" as d,
+UNNEST(d."employees") as e(emp);
++-------+------------------------------------+
+| DEPT  | EMP                                |
++-------+------------------------------------+
+| HR    | {200, 20, Eric, 8000.0, 500}       |
+| Sales | {100, 10, Bill, 10000.0, 1000}     |
+| Sales | {150, 10, Sebastian, 7000.0, null} |
++-------+------------------------------------+
+(3 rows)
+
+!ok
+
+WITH data AS (
+    SELECT ARRAY[
+        ROW(1, 'Alice'),
+        ROW(2, 'Bob'),
+        ROW(3, 'Carol'),
+        ROW(NULL, 'Dan'),
+        NULL
+    ] AS people
+)
+SELECT p.*
+FROM data, UNNEST(people) AS p(p);
++-------------+
+| P           |
++-------------+
+| {1, Alice}  |
+| {2, Bob}    |
+| {3, Carol}  |
+| {null, Dan} |
+|             |
++-------------+
+(5 rows)
+
+!ok
+
+# Nested records: a NULL nested-record field, and a bare NULL array element.
+SELECT * FROM UNNEST(ARRAY[
+    ROW(ROW(1, 'a'), 10),
+    ROW(ROW(NULL, 'b'), 20),
+    ROW(NULL, 30),
+    NULL]) AS p(p);
++-----------------+
+| P               |
++-----------------+
+| {null, 30}      |
+| {{1, a}, 10}    |
+| {{null, b}, 20} |
+|                 |
++-----------------+
+(4 rows)
+
+!ok
+
+# Only one value is emitted per element, so a NULL element is a single NULL
+# rather than a row of NULLs.
+SELECT * FROM UNNEST(ARRAY[
+    ROW(1, 'x'),
+    CAST(NULL AS ROW(a INTEGER, b CHAR(1)))]) AS t(r);
++--------+
+| R      |
++--------+
+| {1, x} |
+|        |
++--------+
+(2 rows)
+
+!ok
+# The column is nullable: a NULL element yields NULL here.
+R STRUCT
+!type
+
+# Same, WITH ORDINALITY: exercises z2 rather than the single-collection path.
+SELECT * FROM UNNEST(ARRAY[
+    ROW(1, 'x'),
+    CAST(NULL AS ROW(a INTEGER, b CHAR(1)))]) WITH ORDINALITY AS t(r, o);
++--------+---+
+| R      | O |
++--------+---+
+| {1, x} | 1 |
+|        | 2 |
++--------+---+
+(2 rows)
+
+!ok
+
 # End unnest.iq

Reply via email to