fix: Derive Substrait intersection nullability from every input - #25091
namanjain24-sudo wants to merge 4 commits into
Conversation
b389ca3 to
1fbc45f
Compare
The Substrait consumer derived all three intersection schemas from the primary input alone, so a field that the intersection makes required stayed nullable in the logical output schema. Narrow an intersection's nullability to `left AND right` per field. The left semi join it compiles to matches nulls with nulls, so a field is nullable in the result only when both inputs make it nullable, which reproduces the spec's rule for the multiset intersections and, because the right side is the union of the secondary inputs, for the primary intersection as well. Closes apache#25042.
1fbc45f to
ff134c9
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25091 +/- ##
==========================================
+ Coverage 82.33% 82.42% +0.08%
==========================================
Files 1137 1140 +3
Lines 432498 435639 +3141
Branches 432498 435639 +3141
==========================================
+ Hits 356116 359080 +2964
+ Misses 54843 54835 -8
- Partials 21539 21724 +185 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this. The logical nullability handling looks like it is heading in the right direction, but I think there is still a schema consistency issue between the logical and physical plans that needs to be addressed before this is ready.
I left one blocking comment on the physical projection behavior, plus one small test suggestion.
| .into_iter() | ||
| .map(Expr::Column) | ||
| .collect(); | ||
| Ok(LogicalPlan::Projection(Projection::try_new_with_schema( |
There was a problem hiding this comment.
I don't think this Projection can carry the nullability narrowing through to execution. DefaultPhysicalPlanner creates it with ProjectionExec::try_new_with_schema_metadata, and that API preserves nullability from the physical column expressions and input. The supplied schema is only used for metadata.
That means the left semi join can still expose nullable fields in the physical plan and collected RecordBatch schema, while plan.schema() says those fields are required. This leaves the logical and physical schemas inconsistent.
Could we use an execution-plan or schema adapter that deliberately rebinds the batches to the proven non-null schema while preserving the exact field attributes, or extend the physical projection path so this validated narrowing is supported there as well?
It would also be good to add a regression assertion that checks both the physical-plan schema and the collected batch schema. The current show() call exercises execution, but it does not verify that those schemas actually match the narrowed logical schema.
| // primary a? b? c? d? | ||
| // secondary a b c? d? | ||
| // secondary a b? c d? | ||
| for (file, expected) in [ |
There was a problem hiding this comment.
Could we also add a small NULLABILITY_UNSPECIFIED case, ideally on one of the secondary inputs? Since the consumer treats unspecified nullability as nullable, this would lock down that Substrait boundary in addition to the explicit REQUIRED and NULLABLE cases.
A Projection built with Projection::try_new_with_schema only narrowed the logical schema: the physical ProjectionExec takes field metadata from it but keeps the nullability of its input, so the physical plan and the collected batches still reported the left input's nullability. When the right input requires a field that the left input leaves nullable, build the intersection as an inner join (nulls equal nulls) against the distinct right rows and read that field from the right side. Both planners then derive the field as non-nullable from its source column. A field is only read from the right when its type and metadata match the left's, and the schema metadata matches too, so the result keeps the left input's attributes. The test now gives the tables rows and checks that the logical schema, the physical plan schema and every batch schema agree, and adds columns with NULLABILITY_UNSPECIFIED on a secondary input.
|
Thanks @kosiew, you were right. I added the assertion you suggested and it failed on the previous commit: the logical schema had Instead of a rebinding exec or a change to
What it does now: when the right input requires a field that the left input leaves nullable, the intersection is built as an inner join (nulls equal nulls) against the distinct right rows, and that field is read from the right side. Matched rows hold equal values, so the result rows don't change. The field is non-nullable because its source column is, and both the logical and the physical planner derive that from the input schema, so the two stay consistent without any override. Joining against distinct right rows keeps each left row at most once, the same as the semi join. A field is only taken from the right when its type and metadata match the left's (and the schema metadata matches too), so the result's attributes stay the left input's. When nothing needs narrowing, it still uses Tests:
|
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working on this. The inner-join approach looks good for the cases covered by the new tests, and the added physical-plan and batch-schema assertions are helpful. The NULLABILITY_UNSPECIFIED secondary case is also covered now.
There is still one path where the original nullability issue can come back, though. When schema or field metadata differs between the two inputs, the implementation falls back to LogicalPlanBuilder::intersect, which uses the left-semi plan and keeps the nullable left-side schema. Since ensure_schema_compatibility does not require metadata equality, these inputs can still be valid.
I think the narrowing path should continue to be used even when metadata differs, while explicitly preserving the left-side metadata on projected right-side columns. It would also be good to add a regression test with differing schema and field metadata that checks the logical schema, physical schema, and collected batch schema all match.
Once that case is handled, this should address the reported issue more completely.
| // `intersect` also reports inputs of different widths. The join would merge | ||
| // the right input's schema metadata into the result, so that must match too. | ||
| if left_fields.len() != right_fields.len() | ||
| || left.schema().metadata() != right.schema().metadata() |
There was a problem hiding this comment.
I think this fallback still leaves the original bug reachable. If the schema metadata differs, we fall back to LogicalPlanBuilder::intersect, which uses the left-semi plan and keeps the nullable left-side field. ensure_schema_compatibility only checks type and nullability compatibility, so differing metadata can still be valid here.
Could we keep using the narrowing path and explicitly preserve the left-side metadata when projecting a required column from the right? The alias API supports attaching metadata, so that should let us retain the left schema metadata without giving up the nullability fix.
The same issue can happen with field metadata: if a nullable left field and required right field differ only in metadata, that field will not be selected from the right unless some other field happens to trigger this path.
It would be useful to add a regression test with differing schema and field metadata and assert the exact logical, physical, and collected-batch schemas.
The narrowing path fell back to `LogicalPlanBuilder::intersect` when the inputs' schema metadata differed, and skipped a field whose metadata differed, so the nullable left-side schema came back for inputs that `ensure_schema_compatibility` accepts. Take the narrowing path regardless of metadata. A column read from the right is aliased with the left field's metadata, and the inner join already lets the left input's schema metadata win, so the left's metadata wins wherever the inputs disagree. `intersect_nullability` now also runs every plan over tables with differing schema and field metadata, and checks that the logical, the physical and the collected batch schemas agree. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks @kosiew, good catch. Pushed 650a5de:
Two things to flag:
Edited: an earlier version of this comment said which input's metadata each schema reports, which I had only seen in one case and which does not hold in general. Locally I ran I also updated the PR description, which still said a field is only read from the right when its metadata matches. |
kosiew
left a comment
There was a problem hiding this comment.
Thanks for working through the earlier feedback. The physical and collected schema nullability checks look good now, and the added coverage for NULLABILITY_UNSPECIFIED is helpful. The metadata fallback issue also looks addressed.
There is still one metadata preservation issue in the new right-column projection path that I think needs to be fixed before merging. I left the details inline. In particular, the narrowed intersection result should preserve the left input's schema and field metadata exactly, while only changing the nullability required by the intersection rules.
Could you also extend the regression test to compare the complete metadata maps on the logical, physical, and collected schemas? That should catch right-only metadata leaking into the result.
| .zip(&from_right) | ||
| .map(|(((field, left), right), from_right)| { | ||
| if *from_right { | ||
| Expr::Column(right.clone()).alias_qualified_with_metadata( |
There was a problem hiding this comment.
I think there is still a metadata preservation issue here. alias_qualified_with_metadata does not replace the right column's metadata. Expr::Alias::to_field starts with the right column metadata and then extends it with the supplied left metadata, so keys that exist only on the right can remain in the projected field.
The inner join has a similar effect on schema metadata: right-only keys can be merged into the join schema, and the projection preserves that merged metadata. This differs from LogicalPlanBuilder::intersect, where the left-semi join exposes only the left input's metadata.
Could we preserve the left schema and field metadata maps exactly in this narrowed result, while retaining the intentionally narrowed nullability? It would also be good to extend the regression test to compare the complete metadata maps, including checking that right-only keys are absent, for the logical plan, physical plan, and collected batches.
There was a problem hiding this comment.
Good catch — you're right that alias_qualified_with_metadata merges rather than replaces, since Expr::Alias's field derivation extends the aliased expression's own metadata with the alias's supplied metadata instead of overriding it.
Fixed in 2a8d8f5: instead of aliasing the right column directly, I route it through a Cast to an explicit target field (Cast::new_from_field) carrying the left field's exact type and metadata. Both cast_output_field (logical) and cast_with_target_field/CastExpr::new_with_target_field (physical) use an explicit target field's metadata exactly, with nothing merged in from the source - that's the documented, intended way to get "target metadata exactly" rather than Alias's merge semantics. The qualifier/name still need alias_qualified on top since a bare Cast's own field isn't renamed to the target's name.
Extended the intersect_nullability regression test to compare the complete metadata map (not just the "column" key) for the tagged case, so a leaked only_in_secondary key now fails it. Since the test already asserts physical_plan.schema() == logical_schema and batch.schema() == logical_schema, that same check covers the physical plan and collected batches too.
…wing alias_qualified_with_metadata merges the alias metadata into the right column's own, since Expr::Alias's field derivation extends rather than replaces. A key only the right input carried could therefore survive into the result alongside the left field's metadata. Route the right column through a Cast to an explicit target field instead: both the logical (cast_output_field) and physical (CastExpr::new_with_target_field) field derivation for an explicit target use its metadata exactly, with nothing merged in from the source. Extend the intersect_nullability regression test to compare the complete metadata map, not just one key, for the tagged case.
Which issue does this PR close?
Rationale for this change
The Substrait consumer derived all three intersection schemas from the primary
input alone, so a field the intersection makes required stayed nullable in the
logical output schema.
The Set Operation rules give intersections a different rule: for the multiset
intersections a field is required as soon as any input requires it, and for
INTERSECTION_PRIMARYit is nullable only when it is nullable in the primaryinput and in at least one secondary input.
from_set_relbuilds intersections withLogicalPlanBuilder::intersect, whichcompiles to a left semi join and therefore keeps the left input's nullability.
What changes are included in this PR?
Intersections now go through a small helper that gives the result
left AND rightnullability per field.The join matches nulls with nulls (
NullEquality::NullEqualsNull) on everyfield, so a left row holding a null in some field only survives when the right
input holds a null there too — a field is nullable in the result only when both
inputs make it nullable. Applied to each step, that single rule reproduces both
spec rules:
input requires it;
INTERSECTION_PRIMARYthe right side is the union of the secondaryinputs, whose field is nullable exactly when some secondary makes it nullable,
which yields "nullable in the primary and in at least one secondary".
How the narrowing is built matters, because it has to hold in the physical plan
too, not only in the logical schema. When the right input requires a field that
the left input leaves nullable, the intersection is built as an inner join
(nulls equal nulls) against the distinct right rows, and that field is read from
the right side:
the physical planner both derive that from the input schema, so the logical
schema, the physical plan and the batches agree without any schema override;
semi join does (and the left side is still made distinct unless
is_all).A field is only read from the right side when its type matches the left's; its
metadata does not matter, since metadata is no part of nullability. The result
should still describe the left input, so where the inputs' metadata disagree the
left's wins: a column read from the right is aliased with the left field's
metadata, and an inner join's schema lets the left input's schema metadata win
(in the logical and in the physical plan alike). Keys that only the right input
carries, on a field or on the schema, are merged into the result.
When nothing needs narrowing, the plan is exactly the one
LogicalPlanBuilder::intersectbuilds today, so the common all-nullable case isuntouched. Unions and the
MINUSoperations are not affected.What is the testing strategy for this PR?
New test
intersect_nullabilityindatafusion/substrait/tests/cases/logical_plans.rs,with three plans added under
tests/testdata/test_plans/. They intersect threetables carrying the same six columns (
?marks nullable,~marksNULLABILITY_UNSPECIFIED, which the consumer reads as nullable):INTERSECTION_PRIMARYa, b?, c?, d?, e?, f?INTERSECTION_MULTISETa, b, c, d?, e?, fINTERSECTION_MULTISET_ALLa, b, c, d?, e?, fColumns
eandfpin the unspecified-nullability boundary: if it were read asrequired,
ewould come out required for the multiset intersections andffor
INTERSECTION_PRIMARY.The tables are given rows, including nulls, and for each plan the test checks
that the logical schema, the physical plan's schema and the schema of every
collected batch are equal, and checks the result rows. It fails on
main(
a?whereais expected), and the physical-schema check also fails againsta narrowing done with a logical-only
Projection.Each plan also runs a second time over tables with differing schema and field
metadata: the secondary tables share their metadata, which differs from the
primary's and adds a key the primary lacks. That run checks that the primary's
metadata wins on the keys the tables disagree on, and that the logical schema,
the physical plan's schema and the collected batches' schemas agree. It fails
without the metadata handling (
a?whereais expected).The secondary tables share their metadata on purpose: a
Unionof inputs withdiffering field metadata can report different metadata in its logical and in its
physical schema. That is independent of this change (plain SQL,
SELECT a FROM t1 UNION ALL SELECT a FROM t2, shows it on a checkout withoutit), and I have not root-caused it.
With the spec's full input pattern from the issue (
R= required,N= nullable), both the logical and the physical schema now match theexpected column:
The existing
datafusion-substraitsuite passes unchanged, including theintersection roundtrip tests, and a Substrait round trip of the sqllogictest
files that use
INTERSECTgives the same output asmain.Are there any user-facing changes?
Intersections consumed from Substrait now report a narrower, spec-conforming
nullability. No public API changes.
One note for reviewers: the same narrowing would apply to SQL
INTERSECT, sinceLogicalPlanBuilder::intersectkeeps the left nullability for every caller. Ikept this change inside the Substrait consumer to match the scope of the issue
and to avoid changing SQL plans in the same PR. If you would rather see the rule
live in
LogicalPlanBuilder, I am happy to move it.