Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/source/contributor-guide/expression-audits/generator_funcs.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,16 @@

- Same `CometExplodeExec` path as `posexplode`. Compatible for array inputs; empty and NULL arrays both emit one row with null `pos` and null `value` per Spark's `outer` semantics, which the planner requests as DataFusion's `NullHandling::PreserveAndExpandEmpty`.

## inline

- Same `CometExplodeExec` path as `explode`, with the unnested struct flattened into one column per field. Spark 3.4.3, 3.5.8, 4.0.1, and 4.1.1 share one implementation: a NULL struct element yields a row of NULLs, which the native flattening reproduces by folding the struct's validity into each field. Every generated column is reported nullable.

## inline_outer

- Same path as `inline`. Empty and NULL arrays both emit one row of NULLs, which the planner requests as DataFusion's `NullHandling::PreserveAndExpandEmpty`.

## stack

- Planned as `inline` over an array holding one struct per output row, so `stack(n, v1, ..., vk)` evaluates each argument once per input row, as Spark does, and pads the last row with NULLs. Spark 3.4.3, 3.5.8, 4.0.1, and 4.1.1 share one implementation. An argument that cannot run natively falls back with that argument's own reason.

[Spark Expression Support]: ../../user-guide/latest/expressions.md
13 changes: 7 additions & 6 deletions docs/source/user-guide/latest/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,20 +336,21 @@ The type-name conversion functions (`bigint`, `binary`, `boolean`, `date`, `deci

## generator_funcs

`explode`, `explode_outer`, `posexplode`, and `posexplode_outer` are supported via
`CometExplodeExec` (operator-level, not expression-level) for array input; map input falls back
to Spark ([#2837](https://github.com/apache/datafusion-comet/issues/2837)). Enabled by default via
`explode`, `explode_outer`, `posexplode`, `posexplode_outer`, `inline`, `inline_outer`, and
`stack` are supported via `CometExplodeExec` (operator-level, not expression-level). `explode` and
`posexplode` support array input; map input falls back to Spark
([#2837](https://github.com/apache/datafusion-comet/issues/2837)). Enabled by default via
`spark.comet.exec.explode.enabled`.

| Function | Status | Implementation | Notes |
| --- | --- | --- | --- |
| `explode` | ✅ | — | via `CometExplodeExec` |
| `explode_outer` | ✅ | — | via `CometExplodeExec` ([audit](../../contributor-guide/expression-audits/generator_funcs.md#explode_outer)) |
| `inline` | 🔜 | — | Operator-level generator (like `explode`) |
| `inline_outer` | 🔜 | — | Operator-level generator (like `explode`) |
| `inline` | ✅ | — | via `CometExplodeExec` ([audit](../../contributor-guide/expression-audits/generator_funcs.md#inline)) |
| `inline_outer` | ✅ | — | via `CometExplodeExec` ([audit](../../contributor-guide/expression-audits/generator_funcs.md#inline_outer)) |
| `posexplode` | ✅ | — | via `CometExplodeExec` |
| `posexplode_outer` | ✅ | — | via `CometExplodeExec` ([audit](../../contributor-guide/expression-audits/generator_funcs.md#posexplode_outer)) |
| `stack` | 🔜 | — | Operator-level generator |
| `stack` | ✅ | — | via `CometExplodeExec`, planned as `inline` ([audit](../../contributor-guide/expression-audits/generator_funcs.md#stack)) |

---

Expand Down
12 changes: 6 additions & 6 deletions docs/source/user-guide/latest/operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,12 @@ combined with `OR`, which Spark plans as an existence `BroadcastNestedLoopJoinEx

## Generators and set operations

| Operator | Status | Notes |
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
| `GenerateExec` | ✅ | Supports `explode`, `explode_outer`, `posexplode`, `posexplode_outer` over arrays. `inline` / `stack` fall back. |
| `ExpandExec` | ✅ | |
| `UnionExec` | ✅ | |
| `CoalesceExec` | ✅ | |
| Operator | Status | Notes |
| -------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GenerateExec` | ✅ | Supports `explode`, `explode_outer`, `posexplode`, `posexplode_outer` over arrays, `inline`, `inline_outer`, and `stack`. Other generators fall back. |
| `ExpandExec` | ✅ | |
| `UnionExec` | ✅ | |
| `CoalesceExec` | ✅ | |

## Writes

Expand Down
44 changes: 22 additions & 22 deletions docs/source/user-guide/latest/understanding-comet-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,28 +310,28 @@ consecutively in a plan, they execute as a single fused block. The write operato
(`CometWriteFiles`, `CometNativeWrite` and `CometIcebergWrite`) are the exception: each runs as its
own native plan over its child's output.

| Node | Spark equivalent |
| ------------------------------ | --------------------------------------------------------------------------------- |
| `CometProject` | `ProjectExec` |
| `CometFilter` | `FilterExec` |
| `CometSort` | `SortExec` |
| `CometLocalLimit` | `LocalLimitExec` |
| `CometGlobalLimit` | `GlobalLimitExec` |
| `CometExpand` | `ExpandExec` |
| `CometExplode` | `GenerateExec` (for `explode`, `explode_outer`, `posexplode`, `posexplode_outer`) |
| `CometHashAggregate` | `HashAggregateExec`, `ObjectHashAggregateExec` |
| `CometHashJoin` | `ShuffledHashJoinExec` |
| `CometBroadcastHashJoin` | `BroadcastHashJoinExec` |
| `CometBroadcastNestedLoopJoin` | `BroadcastNestedLoopJoinExec` |
| `CometSortMergeJoin` | `SortMergeJoinExec` |
| `CometMergeRows` | `MergeRowsExec` (Spark 3.5+; opt-in) |
| `CometWindowExec` | `WindowExec` |
| `CometWindowGroupLimitExec` | `WindowGroupLimitExec` (Spark 3.5 and later) |
| `CometSample` | `SampleExec` (sampling without replacement) |
| `CometTakeOrderedAndProject` | `TakeOrderedAndProjectExec` |
| `CometWriteFiles` | `WriteFilesExec` (Spark 4.0 and later, experimental native Parquet writes) |
| `CometNativeWrite` | `DataWritingCommandExec` (Spark 3.x, experimental native Parquet writes) |
| `CometIcebergWrite` | `IcebergWrite` (native Iceberg data-file writes, opt-in) |
| Node | Spark equivalent |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `CometProject` | `ProjectExec` |
| `CometFilter` | `FilterExec` |
| `CometSort` | `SortExec` |
| `CometLocalLimit` | `LocalLimitExec` |
| `CometGlobalLimit` | `GlobalLimitExec` |
| `CometExpand` | `ExpandExec` |
| `CometExplode` | `GenerateExec` (for `explode`, `explode_outer`, `posexplode`, `posexplode_outer`, `inline`, `inline_outer`, `stack`) |
| `CometHashAggregate` | `HashAggregateExec`, `ObjectHashAggregateExec` |
| `CometHashJoin` | `ShuffledHashJoinExec` |
| `CometBroadcastHashJoin` | `BroadcastHashJoinExec` |
| `CometBroadcastNestedLoopJoin` | `BroadcastNestedLoopJoinExec` |
| `CometSortMergeJoin` | `SortMergeJoinExec` |
| `CometMergeRows` | `MergeRowsExec` (Spark 3.5+; opt-in) |
| `CometWindowExec` | `WindowExec` |
| `CometWindowGroupLimitExec` | `WindowGroupLimitExec` (Spark 3.5 and later) |
| `CometSample` | `SampleExec` (sampling without replacement) |
| `CometTakeOrderedAndProject` | `TakeOrderedAndProjectExec` |
| `CometWriteFiles` | `WriteFilesExec` (Spark 4.0 and later, experimental native Parquet writes) |
| `CometNativeWrite` | `DataWritingCommandExec` (Spark 3.x, experimental native Parquet writes) |
| `CometIcebergWrite` | `IcebergWrite` (native Iceberg data-file writes, opt-in) |

### JVM-Side Operators

Expand Down
1 change: 1 addition & 0 deletions native/core/src/execution/planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5088,6 +5088,7 @@ mod tests {
outer,
position,
project_list: vec![bound],
inline: false,
})),
..Default::default()
};
Expand Down
36 changes: 28 additions & 8 deletions native/core/src/execution/planner/explode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
// specific language governing permissions and limitations
// under the License.

//! Builder for the native `Explode` operator, which plans Spark's `explode`, `posexplode`, and
//! their `_outer` variants as a projection followed by Comet's `ExplodeExec`.
//! Builder for the native `Explode` operator, which plans Spark's `explode`, `posexplode`,
//! `inline`, and their `_outer` variants as a projection followed by Comet's `ExplodeExec`.
//! Spark's `stack` arrives here as `inline` over an array of structs built by the JVM serde.

use std::sync::Arc;

Expand Down Expand Up @@ -162,11 +163,30 @@ impl OperatorBuilder for ExplodeBuilder {
}
};

output_fields.push(Field::new(
array_field.name(),
element_type,
true, // Element is nullable after unnesting
));
// `inline` flattens each struct element into one column per field. Every field is
// nullable after unnesting: a null struct element, or the row `inline_outer` emits for a
// NULL or empty array, yields a row of nulls.
let struct_column_indices = if explode.inline {
let DataType::Struct(struct_fields) = &element_type else {
return Err(ExecutionError::GeneralError(format!(
"Expected List of Struct for inline, got {:?}",
array_field.data_type()
)));
};
output_fields.extend(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass row counts to RecordBatchOptions; inline(array(_partition,_partition)) fails on unpartitioned Iceberg tables when no columns are carried.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the native failure at f5cfc51e29 with the extracted flatten_struct_cols function and Arrow 59.3.1 from this PR's lockfile. A StructArray::new_empty_fields(2, None) with no carried columns fails with Invalid argument error: must either specify a row count or at least one column. The control using RecordBatchOptions::with_row_count(Some(2)) returns the expected two rows.

The Iceberg case is reachable: CometScanRule explicitly exempts metadata columns such as empty _partition structs from schema validation, and the attribute/array serdes accept that type. Could we preserve the unnested row count when constructing the batch and add a zero-field inline/inline_outer regression? I verified the Arrow failure and traced the serde path, but did not run an Iceberg integration test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd lean toward falling back here rather than threading a row count through flatten_struct_cols. Even with a row count, a zero-column batch still has to leave the native plan and be exported to the JVM, and I'm not sure that path handles a batch with no columns. Returning Unsupported from getSupportLevel when the inline element struct has no fields keeps the previous Spark behavior and costs nothing, since array<struct<>> carries no data. stack cannot hit this because it always has at least one column. Would a plain SELECT inline(array(struct())) FROM t also reach it? Spark accepts struct(), so if Comet converts the zero-field CreateNamedStruct natively, that would make a simpler regression test than the Iceberg case.

struct_fields
.iter()
.map(|f| f.as_ref().clone().with_nullable(true)),
);
vec![array_input_index]
} else {
output_fields.push(Field::new(
array_field.name(),
element_type,
true, // Element is nullable after unnesting
));
vec![]
};

let output_schema = Arc::new(Schema::new(output_fields));

Expand Down Expand Up @@ -196,7 +216,7 @@ impl OperatorBuilder for ExplodeBuilder {
let unnest_exec = Arc::new(ExplodeExec::new(
project_exec,
list_unnests,
vec![], // No struct columns to unnest
struct_column_indices,
output_schema,
unnest_options,
)?);
Expand Down
47 changes: 45 additions & 2 deletions native/operators/src/explode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
//! * dropping upstream's `ListUnnest` declaration in favor of importing the public one;
//! * `find_longest_length` applies the empty-list bump once after the row-wise maximum rather
//! than once per array, which is equivalent because `max` is associative;
//! * `list_output_lens` and the contiguous-run fast path described above.
//! * `list_output_lens` and the contiguous-run fast path described above;
//! * `flatten_struct_cols` folds a struct's null mask into each field it flattens, since the
//! field values under a null struct are unspecified and Spark's `inline` emits nulls there.
//!
//! `ExplodeExec` and `ExplodeStream` were always Comet's own.

Expand Down Expand Up @@ -82,6 +84,7 @@ use datafusion::physical_plan::metrics::{
SplitMetrics,
};
use datafusion::physical_plan::stream::BatchSplitStream;
use datafusion_comet_common::children_with_parent_nulls;
// `ListUnnest` is the one item the copied region below does NOT need to duplicate: unlike the
// kernels, upstream exports it publicly.
use datafusion::physical_plan::unnest::ListUnnest;
Expand Down Expand Up @@ -573,7 +576,9 @@ fn flatten_struct_cols(
Some(_) => match column_data.data_type() {
DataType::Struct(_) => {
let struct_arr = column_data.as_any().downcast_ref::<StructArray>().unwrap();
columns_expanded.extend_from_slice(struct_arr.columns());
// Arrow leaves the field values under a null struct unspecified, so they
// must not leak out once the struct is flattened.
columns_expanded.extend(children_with_parent_nulls(struct_arr)?);
}
data_type => {
return internal_err!(
Expand Down Expand Up @@ -1872,4 +1877,42 @@ mod tests {
);
assert_output_lens(with_nulls.slice(1, 3), [&[0, 0, 3], &[1, 0, 3], &[1, 1, 3]]);
}

#[test]
fn flatten_struct_cols_nulls_fields_under_a_null_struct() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second commit dropped the sliced-input case and the non-nullable field from this test, but the PR description still says it covers a sliced input. I think the slice case is worth keeping. The contiguous fast path in unnest_list_array returns values.slice(first, capacity), so for every chunk after the first, flatten_struct_cols receives a StructArray with a non-zero offset. That is the normal production input for inline. The SQL tests fit in one batch and never reach it, and struct_nulls.rs has no slice case either. Could you restore the structs.slice(..) assertion and the non-nullable field?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored the sliced-input case and the non-nullable field in f5cfc51.

// Row 1's struct is null but its fields hold values, which Arrow allows. Spark's `inline`
// turns a null struct into a row of nulls, so none of those values may surface.
let a: ArrayRef = Arc::new(Int32Array::from(vec![Some(1), Some(2), None, Some(4)]));
let b: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 30, 40]));
let fields = vec![
Arc::new(Field::new("a", DataType::Int32, true)),
Arc::new(Field::new("b", DataType::Int32, false)),
];
let structs: ArrayRef = Arc::new(StructArray::new(
fields.clone().into(),
vec![a, b],
Some(NullBuffer::from(vec![true, false, true, true])),
));
let schema = Arc::new(Schema::new(vec![
Field::new("a", DataType::Int32, true),
Field::new("b", DataType::Int32, true),
]));
let indices = HashSet::from([0]);

let batch = flatten_struct_cols(&[Arc::clone(&structs)], &schema, &indices).unwrap();
let a = batch.column(0).as_primitive::<Int32Type>();
let b = batch.column(1).as_primitive::<Int32Type>();
assert_eq!(a.iter().collect::<Vec<_>>(), [Some(1), None, None, Some(4)]);
assert_eq!(
b.iter().collect::<Vec<_>>(),
[Some(10), None, Some(30), Some(40)]
);

// A slice keeps the struct's mask aligned with its fields.
let batch = flatten_struct_cols(&[structs.slice(1, 2)], &schema, &indices).unwrap();
let a = batch.column(0).as_primitive::<Int32Type>();
let b = batch.column(1).as_primitive::<Int32Type>();
assert_eq!(a.iter().collect::<Vec<_>>(), [None, None]);
assert_eq!(b.iter().collect::<Vec<_>>(), [None, Some(30)]);
}
}
3 changes: 3 additions & 0 deletions native/proto/src/proto/operator.proto
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,9 @@ message Explode {
repeated spark.spark_expression.Expr project_list = 3;
// Whether to emit a position column alongside the exploded values (posexplode)
bool position = 4;
// Whether the array holds structs whose fields become separate output columns (inline). A
// null struct element yields a row of nulls.
bool inline = 5;
}

message HashJoin {
Expand Down
Loading
Loading