Skip to content

feat: add Spark-compatible arrays_zip function#22473

Open
CuteChuanChuan wants to merge 10 commits into
apache:mainfrom
CuteChuanChuan:raymond/20888-spark-arrays-zip
Open

feat: add Spark-compatible arrays_zip function#22473
CuteChuanChuan wants to merge 10 commits into
apache:mainfrom
CuteChuanChuan:raymond/20888-spark-arrays-zip

Conversation

@CuteChuanChuan

@CuteChuanChuan CuteChuanChuan commented May 23, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #20888.

Rationale for this change

  • Spark's arrays_zip returns a list of structs whose field names follow the original SQL arguments — column refs use the column name, aliases use the alias, anything else falls back to 0-based ordinals ("0", "1", ...).
  • DataFusion's native arrays_zip uses 1-based ordinals. To support Spark compatibility without altering DataFusion's native semantics, this PR adds a SparkArraysZip wrapper plus an analyzer-time rewrite in the datafusion-spark crate.

What changes are included in this PR?

  1. SparkArraysZip — a wrapper that delegates to the native ArraysZip and renames the inner List<Struct<..>> fields. Both the planning-time DataType and the execution-time ArrayRef are renamed so the declared schema matches the produced data.

  2. SparkArraysZipRewrite — an analyzer-time FunctionRewrite that captures each argument's natural name from the original Expr tree and embeds it as a marked List<Utf8> literal first argument. Running before optimizer passes that could rename arg_fields is what keeps the names stable through planning.

Are these changes tested?

Yes:

  • SLT (datafusion/sqllogictest/test_files/spark/array/arrays_zip.slt)
  • Unit tests in arrays_zip_rewrite.rs
  • Unit tests in arrays_zip.rs

Are there any user-facing changes?

Yes — in a session built with SessionStateBuilderSpark::with_spark_features, arrays_zip now produces struct field names following Spark semantics (column name / alias / 0-based ordinal) instead of DataFusion's 1-based ordinals. DataFusion's native arrays_zip is unaffected and still reachable under its list_zip alias.

@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) spark labels May 23, 2026
@CuteChuanChuan CuteChuanChuan marked this pull request as draft May 24, 2026 08:29
@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

Working on renaming part.

@CuteChuanChuan CuteChuanChuan force-pushed the raymond/20888-spark-arrays-zip branch from 3657a23 to 5a2d338 Compare June 6, 2026 11:03
@CuteChuanChuan CuteChuanChuan marked this pull request as ready for review June 6, 2026 11:03
@CuteChuanChuan CuteChuanChuan force-pushed the raymond/20888-spark-arrays-zip branch from 5a2d338 to 43787bd Compare June 6, 2026 11:15
@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

Hi @comphead ,
could you PTAL when you have chances? Thanks! 🙏


# Mixed: column reference + literal expression. Column keeps its name; the
# literal-built array falls back to a 0-based ordinal.
query ?

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.

thats a nice test, just checked Spark does the same

scala> spark.sql("select arrays_zip(a, array(3, 4, 5)) from (select array(1, 2, 3) a, array(3, 4, 5))").printSchema
root
 |-- arrays_zip(a, array(3, 4, 5)): array (nullable = false)
 |    |-- element: struct (containsNull = false)
 |    |    |-- a: integer (nullable = true)
 |    |    |-- 1: integer (nullable = true)

# `SparkArraysZipRewrite::dedup_names`) so the struct can be written to
# formats that require unique field names.
query ?
SELECT arrays_zip(a, a, a)

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.

this is actually not how Spark works, checking 4.1.1

scala> spark.sql("select arrays_zip(a, a, a) from (select array(1, 2, 3) a, array(3, 4, 5))").printSchema
warning: 1 deprecation (since 2.13.3); for details, enable `:setting -deprecation` or `:replay -deprecation`
root
 |-- arrays_zip(a, a, a): array (nullable = false)
 |    |-- element: struct (containsNull = false)
 |    |    |-- a: integer (nullable = true)
 |    |    |-- a: integer (nullable = true)
 |    |    |-- a: integer (nullable = true)

It reminds me a spark issue, because this construction returns data, but you cannot really access it by the field name. We prob can leave it as is for now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@comphead ,
Thanks for pointing this out. I've added a note in the SLT next to that test clarifying the divergence from Spark.

@comphead

comphead commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@CuteChuanChuan speaking to downstream projects that uses array_zip directly from datafusion-spark, how naming would work in this case?

Can we try to reuse function_nested::arrayzip as much as possible and pass the column names in there?

@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

@CuteChuanChuan speaking to downstream projects that uses array_zip directly from datafusion-spark, how naming would work in this case?

Can we try to reuse function_nested::arrayzip as much as possible and pass the column names in there?

No problem. I will give it a try. Thanks you for pointing this out. 🙏

- Add SparkArraysZip wrapper that delegates to ArraysZip and renames inner struct fields to 0-based ordinals to match Spark semantics
- Add sqllogictest cases mirroring Spark's DataFrameFunctionsSuite
- Match Spark semantics: arrays_zip(a, b) returns Struct<a, b> rather than Struct<0, 1>
- Metadata marker (not type sniffing) prevents collision with user-supplied List<Utf8> first arguments
- Identity dispatch via Any downcast (not name match) avoids hijacking the inner arrays_zip / list_zip alias / third-party UDFs
- Duplicate names disambiguated with _<n> suffix
- Inject the resolved names with `ArraysZip::with_field_names` so the `List<Struct>` is built once with the correct field names.
@CuteChuanChuan CuteChuanChuan force-pushed the raymond/20888-spark-arrays-zip branch from 0e283ec to f6a0423 Compare June 19, 2026 16:33
@github-actions github-actions Bot added the functions Changes to functions implementation label Jun 19, 2026
@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

Hi @comphead ,
could you PTAL when you have a chance?

Following up on the downstream-naming question: I added an ArraysZip::with_field_names constructor to datafusion-functions-nested so the Spark wrapper injects the resolved field names instead of rebuilding the List to rename it. This lets the spark crate keep only the Spark-specific logic (name policy + the analyzer rewrite) and reuse the native implementation for everything else — the zip itself, and applying the names.

For downstream projects using arrays_zip directly: the native functions-nested function defaults to ordinal field names and now also accepts custom names via with_field_names; the Spark-faithful naming (column names 0-based ordinals) lives in the spark crate and relies on the registered rewrite rule.

@comphead

comphead commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Thanks @CuteChuanChuan I would expect something like below. Just extend existing arrays_zip_inner to accept fieldnames and in datafusion-spark, create another constructor that accepts field names.

Spark caller will create a function with necessary fields.

It can be constructor or some .with_field_names to avoid recreate function

 flowchart TB
     subgraph spark["datafusion-spark"]
         SparkUDF["SparkArraysZip (UDF)<br/>function/array/arrays_zip.rs"]
         SparkNew["new()<br/>names from arg_fields"]
         SparkCtor["with_field_names(Vec&lt;String&gt;)<br/>caller-supplied names"]
         SparkMod["function/array/mod.rs<br/>+ pub mod arrays_zip<br/>+ make_udf_function!<br/>+ export_functions!<br/>+ functions() entry"]

         SparkUDF --> SparkNew
         SparkUDF --> SparkCtor
         SparkMod -.registers.-> SparkUDF
     end

     subgraph nested["datafusion-functions-nested"]
         ArraysZipUDF["ArraysZip (UDF)<br/>names: '1','2','3'..."]
         Wrapper["arrays_zip_inner(args)<br/>(private wrapper)<br/>generates default names"]
         Pub["pub fn arrays_zip_inner_with_names<br/>(args, field_names)"]
         Perfect["try_perfect_list_zip<br/>(args, field_names)"]

         ArraysZipUDF --> Wrapper
         Wrapper --> Pub
         Pub --> Perfect
     end

     SparkNew -->|resolve names<br/>then call| Pub
     SparkCtor -->|resolve names<br/>then call| Pub
Loading

Name resolution inside SparkArraysZip

flowchart LR
    Start["SparkArraysZip.field_names"]
    Some["Some(names)"]
    None["None"]
    Validate["validate length<br/>matches args"]
    FromArgs["arg_fields[i].name()<br/>for each i"]
    Use["use names"]

    Start --> Some
    Start --> None
    Some --> Validate --> Use
    None -->|Spark default:<br/>column/alias names| FromArgs --> Use
Loading

@CuteChuanChuan

CuteChuanChuan commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @CuteChuanChuan I would expect something like below. Just extend existing arrays_zip_inner to accept fieldnames and in datafusion-spark, create another constructor that accepts field names.

Got it. Thanks for providing these details.❤️ I will give it a try.

- Add `arrays_zip_inner_with_names` and `arrays_zip_return_type` as the public naming-aware entry points
- Keep a private `arrays_zip_inner` wrapper that supplies the default 1-based ordinals
- `SparkArraysZip` resolves names (caller-supplied via `with_field_names`, else 0-based ordinals) and passes them into the native functions
- Add an `AnalyzerRule` that recovers column/alias names from the SQL arguments and pins them via `with_field_names` before optimizer passes rename `arg_fields`
@CuteChuanChuan

CuteChuanChuan commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @comphead for the detailed and well-guided review — I've reworked to match your diagram. Two deviations below.

Matches the diagram

functions-nested: pub arrays_zip_inner_with_names(args, field_names) + pub arrays_zip_return_type(arg_types, field_names), with a private arrays_zip_inner(args) wrapper supplying the default 1-based ordinals (native UDF unchanged).

spark: SparkArraysZip holds the names — with_field_names(Vec) for callers, new() for none — and resolve_names() passes them into both native fns (single naming source, no post-hoc rename). A programmatic caller uses with_field_names directly and never hits the rewrite.

Deviation 1 — None → 0-based ordinals, not arg_fields names

In the previous design, new() derives from arg_fields[i].name(); I tried that and it panics, because those names aren't stable across optimizer passes (a literal arg gets renamed to lit):

SELECT arrays_zip(a, a, a) FROM (VALUES ([1,2],[1,2],[1,2])) AS t(a, b, c);
-- optimize_projections: schema mismatch
-- declared: List<Struct<"0","1","2">>
-- recomputed: List<Struct<"a","a","a">>

So None produces positional ordinals (depend only on arg count). Spark-faithful names for SQL come from the rewrite below instead.

Deviation 2 — the SQL rewrite is now an AnalyzerRule, not a FunctionRewrite

ApplyFunctionRewrites rewrites expressions but never recomputes the plan node's schema, so the cached schema goes stale when a rewrite changes the output type (as pinning names does) — the same panic as above. An AnalyzerRule can recompute_schema() after pinning. This fixes every SLT case including a,a,a.

Duplicate names are kept as-is per Spark 4.1.1 check (arrays_zip(a,a) → struct<a,a>); I dropped the earlier de-dup. IMO mimicking Spark here brings no surprise to users.

PTAL — happy to adjust the feeback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation spark sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spark: introduce Spark arrays_zip

2 participants