Skip to content

Conversation

vegarsti
Copy link
Contributor

@vegarsti vegarsti commented Jul 30, 2025

Which issue does this PR close?

What changes are included in this PR?

  • In UnnestExec's compute_properties we now construct itsEquivalenceProperties using what we can from the input plan, so that we preserve sort ordering of unrelated columns (and avoid unnecessary sorts further up in the plan).

Are these changes tested?

  • Adds test cases to the sqllogictests for UnnestExec in unnest.slt

Are there any user-facing changes?

No

Explanation

Given a struct or array value col, unnest(col) takes the N elements of col and "spreads" these onto N rows, where all other columns in the statement are preserved. Said another way, when we unnest a column we are inserting a lateral cross-join against its elements, which by construction:

  • Duplicates every other column once for each array/map element
  • Replaces the original collection column with one (or more) “element” columns
  • Expands one input row into zero (if empty) or many output rows

E.g. (from unnest.slt):

query III
select unnest(column1) c1, unnest(column2) c2, column3 c3 from unnest_table group by c1, c2, c3 order by c1, c2, c3;
----
1 7 1
2 NULL 1
3 NULL 1
4 8 2
5 9 2
6 11 3
12 NULL NULL
NULL 10 2
NULL 12 3
NULL 42 NULL
NULL NULL NULL

The EquivalenceProperties struct has three types of properties:

  1. equivalence groups (expressions with the same value)
  2. ordering equivalence classes (expressions that define the same ordering)
  3. table constraints - a set of columns that form a primary key or a unique key

In this PR we construct the UnnestExec node's EquivalenceProperties by using the input plan's equivalence properties for the columns that are not transformed - except for table constraints, which we discard entirely. The reasoning for discarding constraints is that because we're duplicating the other columns across rows, we are invalidating any uniqueness or primary-key constraint. We also need to some twiddling with the mapping of the projection (indices change due to the unnesting).

@github-actions github-actions bot added physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Jul 30, 2025
@vegarsti vegarsti force-pushed the unnest-equivalence branch from 7527f16 to 5838f45 Compare July 30, 2025 17:49
@vegarsti vegarsti changed the title Preserve the equivalence properties of the input plan in unnest fix: Preserve equivalence properties of the input plan in unnest Jul 30, 2025
@vegarsti vegarsti changed the title fix: Preserve equivalence properties of the input plan in unnest fix: Preserve equivalence properties of input plan in unnest Jul 30, 2025
@vegarsti
Copy link
Contributor Author

Tagging @alamb, maybe you can trigger CI? 🙏🏻

@vegarsti vegarsti force-pushed the unnest-equivalence branch from 5838f45 to 751a8ba Compare July 31, 2025 05:49
Comment on lines -54 to +58
/// For list unnesting, each rows is vertically transformed into multiple rows
/// For struct unnesting, each columns is horizontally transformed into multiple columns,
/// For list unnesting, each row is vertically transformed into multiple rows
/// For struct unnesting, each column is horizontally transformed into multiple columns,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Grammar fix

Copy link
Contributor

@asubiotto asubiotto left a comment

Choose a reason for hiding this comment

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

Nice!

@vegarsti vegarsti force-pushed the unnest-equivalence branch 4 times, most recently from 523eefd to 80567ec Compare August 3, 2025 07:23
@github-actions github-actions bot removed the physical-expr Changes to the physical-expr crates label Aug 3, 2025
@vegarsti vegarsti changed the title fix: Preserve equivalence properties of input plan in unnest fix: UnnestExec preserves possible equivalence properties of input plan Aug 3, 2025
@vegarsti vegarsti changed the title fix: UnnestExec preserves possible equivalence properties of input plan fix: UnnestExec preserves possible equivalence properties of inpu Aug 3, 2025
@vegarsti vegarsti changed the title fix: UnnestExec preserves possible equivalence properties of inpu fix: UnnestExec preserves possible equivalence properties of input Aug 3, 2025
@vegarsti
Copy link
Contributor Author

vegarsti commented Aug 3, 2025

I discovered EquivalenceProperties.project, which seems to do what we need: We can get the unnest plan's equivalence properties by doing input_eq_properties.project(unnested_columns, schema), i.e. discarding the properties of the columns that are being unnested.

I have updated the PR doing that.

Equivalence properties are

  1. equivalence groups (expressions with the same value)
  2. ordering equivalence classes (expressions that define the same ordering)
  3. table constraints - these can be primary key or unique

I am pretty sure that this takes care of 1 and 2, since we now have no equivalence properties for the columns. I am not yet sure about 3, though - if the original expression uses a column that is a primary key, after the unnest we will have multiple rows with the same column. Does that mean we need to remove that constraint from the eq properties? It kinda sounds like yes, but I need to see exactly what it's being used for.

@vegarsti vegarsti force-pushed the unnest-equivalence branch from 80567ec to a17ec47 Compare August 3, 2025 14:17
@vegarsti
Copy link
Contributor Author

vegarsti commented Aug 3, 2025

I discovered EquivalenceProperties.project, which seems to do what we need: We can get the unnest plan's equivalence properties by doing input_eq_properties.project(unnested_columns, schema), i.e. discarding the properties of the columns that are being unnested.

I have updated the PR doing that.

Equivalence properties are

  1. equivalence groups (expressions with the same value)
  2. ordering equivalence classes (expressions that define the same ordering)
  3. table constraints - these can be primary key or unique

I am pretty sure that this takes care of 1 and 2, since we now have no equivalence properties for the columns. I am not yet sure about 3, though - if the original expression uses a column that is a primary key, after the unnest we will have multiple rows with the same column. Does that mean we need to remove that constraint from the eq properties? It kinda sounds like yes, but I need to see exactly what it's being used for.

After reading some more I have now updated it so that we remove any constraint from the properties. I've updated the PR description.

I think this is semantically sound now.

FYI @alamb and @asubiotto

@vegarsti vegarsti changed the title fix: UnnestExec preserves possible equivalence properties of input fix: UnnestExec preserves relevant equivalence properties of input Aug 3, 2025
Copy link
Contributor

@asubiotto asubiotto left a comment

Choose a reason for hiding this comment

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

Nice work! This LGTM, I'll leave it to @alamb for a final review and CI kickoff.

@vegarsti vegarsti force-pushed the unnest-equivalence branch 2 times, most recently from 79ec7e6 to f1e889d Compare August 4, 2025 08:58
@alamb
Copy link
Contributor

alamb commented Aug 9, 2025

Sorry to bug you again @alamb, do you have time to review this today or tomorrow? 👀 Do let me know if there's anything that I can do to make it easier to review.

Hi @vegarsti - sorry I didn't see this earlier. I will try and review it over the next day or two

Maybe @berkaysynnada or @suremarc has some time to review as well

@vegarsti
Copy link
Contributor Author

Friendly ping @alamb 😄

Copy link
Contributor

@alamb alamb left a comment

Choose a reason for hiding this comment

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

Thank you for this contribution @vegarsti

I am sorry for the delayed review -- I am always trying to encourage others to review PRs, but indeed I often function as the reviewer of last resort. Anything you can do to help (like help review PRs yourself) would be most appreciated!

This is definitely the right direction, but when I did some testing of this PR some of the behavior didn't make sense to me

Could you look at the test I provided, as well as add additional cases:

  1. That unnest a struct (the one I provided unnests a list)
  2. has multiple list/structs unnested (as the code seems to handle such a case)

@vegarsti
Copy link
Contributor Author

Thank you for this contribution @vegarsti

I am sorry for the delayed review -- I am always trying to encourage others to review PRs, but indeed I often function as the reviewer of last resort. Anything you can do to help (like help review PRs yourself) would be most appreciated!

This is definitely the right direction, but when I did some testing of this PR some of the behavior didn't make sense to me

Could you look at the test I provided, as well as add additional cases:

  1. That unnest a struct (the one I provided unnests a list)

  2. has multiple list/structs unnested (as the code seems to handle such a case)

Thank you so much for the detailed and gracious review. Thanks for catching the weird behavior, I will address this.

And I am happy to start reviewing PRs!

@alamb alamb marked this pull request as draft September 4, 2025 10:57
@alamb
Copy link
Contributor

alamb commented Sep 4, 2025

Marking as draft as I think this PR is no longer waiting on feedback and I am trying to make it easier to find PRs in need of review. Please mark it as ready for review when it is ready for another look

@vegarsti
Copy link
Contributor Author

vegarsti commented Sep 4, 2025

Marking as draft as I think this PR is no longer waiting on feedback and I am trying to make it easier to find PRs in need of review. Please mark it as ready for review when it is ready for another look

Indeed, thanks!

@vegarsti
Copy link
Contributor Author

vegarsti commented Sep 13, 2025

Figured out why the test @alamb added failed -- the way I was creating the projection mapping was too simplistic, causing indexes to not match. Will add the two requested test cases as well.

@vegarsti
Copy link
Contributor Author

vegarsti commented Sep 13, 2025

Figured out why the test @alamb added failed -- the way I was creating the projection mapping was too simplistic, causing indexes to not match. Will add the two requested test cases as well.

Added two similar test cases:

  1. with struct
  2. with nested array, array, and struct
# cargo test --test sqllogictests -- unnest
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.19s
     Running bin/sqllogictests.rs (target/debug/deps/sqllogictests-8ad6b462cb0c808e)
Completed 1 test files in 0 seconds

@vegarsti vegarsti marked this pull request as ready for review September 13, 2025 19:44
@vegarsti vegarsti force-pushed the unnest-equivalence branch 4 times, most recently from 0cf176b to 95cdb26 Compare September 19, 2025 10:14
@vegarsti
Copy link
Contributor Author

Since CI ran on this one, I'll leave it here without updating the branch until this gets reviewed again 👍🏻

@vegarsti
Copy link
Contributor Author

vegarsti commented Oct 2, 2025

@berkaysynnada @suremarc @alamb Gentle ping for a review!

Copy link
Contributor

@tobixdev tobixdev left a comment

Choose a reason for hiding this comment

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

To me, the changes and tests make sense. Thanks!

CAVEAT: I am by no means a DataFusion pro. Just trying to learn more while providing some feedback. :)

.iter()
.enumerate()
.filter(|(idx, _)| {
!list_column_indices.contains(idx) && !struct_column_indices.contains(idx)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we have had multiple issues with quadratic planning time for a large amount of columns. I think we could get the same problem here as the contains is another linear scan, thus creating a quadratic runtime depending on the number of columns. I could also be wrong and this doesn't cause an issue.

Maybe we could build a buffer and then simply index into the buffer on whether this is unnested (not tested):

        let input_schema = input.schema();
        let mut unnested_indices = BooleanBufferBuilder::new(input.len());
        unnested_indices.append_n(input.len(), false);
        for list_unnest in list_column_indices {
            unnested_indices.set_bit(list_unnest.index_in_input_schema, true);
        }
        for list_unnest in struct_column_indices {
            unnested_indices.set_bit(*list_unnest, true)
        }
        let unnested_indices = unnested_indices.finish();

        let non_unnested_indices: Vec<usize> = (0..input_schema.fields().len())
            .filter(|idx| !unnested_indices.value(*idx))
            .collect();

Otherwise, I think changing the iterator to (0..input_schema.fields().len()) would help with readability as you don't seem to be using the actual field.

Copy link
Contributor Author

@vegarsti vegarsti Oct 13, 2025

Choose a reason for hiding this comment

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

Otherwise, I think changing the iterator to (0..input_schema.fields().len()) would help with readability as you don't seem to be using the actual field.

Definitely doing this! Thank you.

Good idea to build a buffer and index into it. I'll give that a shot and see how it turns out!

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I'd only change that if its easy to do with similar complexity. I think the quadratic behavior only makes a problem if we have many many columns and most of them use unnest.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This worked very well! Added in c42c8c1. I think the buffer approach you gave is more readable as well, so it's win win! Thanks a lot

@tobixdev
Copy link
Contributor

Maybe one additional note: I think the resulting sort properties can be improved for unnesting structs if we know that the struct columns themselves are ordered.

If that makes sense we could also somehow expand the LexSort entry for the struct column.

But as this is already an improvement I think that tracking this in a separate issue is fine.

@vegarsti
Copy link
Contributor Author

Thank you so much @tobixdev!

@vegarsti
Copy link
Contributor Author

Maybe one additional note: I think the resulting sort properties can be improved for unnesting structs if we know that the struct columns themselves are ordered.

If that makes sense we could also somehow expand the LexSort entry for the struct column.

But as this is already an improvement I think that tracking this in a separate issue is fine.

Great idea!

@adriangb
Copy link
Contributor

I took a look and it seems all good to me but given there's already been a lot of review on it I think the existing reviewers need to approve for it to be mergeable, so I will defer to them. Consider this my token ✅

@alamb
Copy link
Contributor

alamb commented Oct 14, 2025

Looks like there are some outstanding comments from @tobixdev -- please ping me @vegarsti when you have addressed them @vegarsti and are ready for a final review / stamp

@vegarsti
Copy link
Contributor Author

vegarsti commented Oct 15, 2025

Thanks a lot everyone! @alamb ready for the stamp now ;)

Copy link
Contributor

@alamb alamb left a comment

Choose a reason for hiding this comment

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

Thank you for this contribution @vegarsti

I think this is very close. I think it should have:

  1. Some additional tests / comments cleanup (see comments)
  2. Avoid unwrap / expect to minimize the severity of symptoms

physical_plan
01)ProjectionExec: expr=[array_agg(unnested.ar)@1 as array_agg(unnested.ar)]
02)--AggregateExec: mode=FinalPartitioned, gby=[generated_id@0 as generated_id], aggr=[array_agg(unnested.ar)], ordering_mode=Sorted
03)----SortExec: expr=[generated_id@0 ASC NULLS LAST], preserve_partitioning=[true]
Copy link
Contributor

Choose a reason for hiding this comment

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

this plan shows the data being sorted, but the comment suggests it should not be 🤔

Could you please explain in more detail what you expect this explain plan to be showing? Given there is no ORDER BY in the query (or in the OVER clause) it is not clear why this is testing ordering

3 400
1 400

# Explain should not have a SortExec
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you also please add two additional tests:

  1. a negative test case here. order by the output of the unnest and verify that it is in fact sorted correctly
  2. A case with the ordering column as the first index (e.g. tuples like (100, [3,2,1], 'a') and then order by 100

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

Labels

physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

unnest should preserve the input's equivalence properties for uninvolved columns

5 participants