fix: correct operator precedence for IS [NOT] DISTINCT FROM - #24479
fix: correct operator precedence for IS [NOT] DISTINCT FROM#24479adriangb wants to merge 3 commits into
Conversation
`sqlparser` parses the right operand of `IS [NOT] DISTINCT FROM` with
`parse_expr()`, i.e. at the lowest possible precedence, so operators that
bind less tightly than `IS` are swallowed into the right operand:
a IS NOT DISTINCT FROM b AND c IS NOT DISTINCT FROM d
parses as `a IS NOT DISTINCT FROM (b AND (c IS NOT DISTINCT FROM d))`
instead of `(a IS NOT DISTINCT FROM b) AND (c IS NOT DISTINCT FROM d)`.
Planning then fails with
Cannot infer common argument type for logical boolean operation Int64 AND Boolean
which makes multi-column `IS NOT DISTINCT FROM` joins unusable unless
every condition is parenthesised.
Restore the expected associativity in the SQL planner: before planning an
expression, flatten its `AND`/`OR` spine, re-attach each
`IS [NOT] DISTINCT FROM` (and each `NOT`, which binds more tightly as
well) to only the first operand of its right hand side, and rebuild the
expression with `AND` binding more tightly than `OR`. The rewrite is
skipped unless the mis-parse is actually present, and its output is a
fixed point, so it cannot loop.
Closes apache#23692
`has_greedy_distinct_from` runs for every expression the planner sees, and walked the AND/OR spine recursively, putting chain depth back on the call stack in front of the stack machine that exists to keep it off (apache#1444). `recursive_protection` is not a default feature, so the attribute those helpers carried was not enough. Walk the spine with explicit work stacks in both helpers instead, and box the large variants of the two new local enums to match the neighbouring `StackEntry`. Adds test_stack_overflow_distinct_from_{1024,8192}, covering the fixup at the same spine depths the neighbouring test_stack_overflow tests use. Like those, it is a scale check rather than a proof: these frames are small enough that a recursive walk survives these depths too. The chain in that test is built from `=` terms after a single `IS NOT DISTINCT FROM` rather than from more `IS NOT DISTINCT FROM`: a chain of the latter nests in the AST instead of looping, so sqlparser overflows while parsing it, before any of this crate's code runs.
The issue's reproducer used a LEFT ANTI JOIN with two conditions, but neither the join nor the second condition is needed: a single `IS [NOT] DISTINCT FROM` followed by anything that binds less tightly is enough, so `SELECT 1 IS NOT DISTINCT FROM 1 AND true` fails the same way. Add that case next to the existing `IS DISTINCT FROM` tests in select.slt, along with the `OR` and `NOT` variants. The `NOT` and mixed `AND`/`OR` cases use values where a wrong grouping produces a different answer, since the plan display alone does not distinguish `NOT (A AND B)` from `(NOT A) AND B`.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #24479 +/- ##
==========================================
+ Coverage 81.05% 81.23% +0.18%
==========================================
Files 1107 1113 +6
Lines 381574 392649 +11075
Branches 381574 392649 +11075
==========================================
+ Hits 309281 318987 +9706
- Misses 54034 54905 +871
- Partials 18259 18757 +498 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Closing this in favour of fixing the root cause upstream. The mis-parse originates in
Fixing it there removes the need for the 271-line planner-side rewrite in this PR, which would become dead code as soon as DataFusion picks up the release containing it. I verified the upstream fix end-to-end against this branch's base (
#23692 should stay open until DataFusion bumps its |
Which issue does this PR close?
Rationale for this change
Combining two or more
IS NOT DISTINCT FROMconditions withANDfails to plan:A single condition works, and adding parentheses around each condition works, so the failure looks like a type coercion problem. It isn't.
sqlparserparses the right operand ofIS [NOT] DISTINCT FROMwithparse_expr()— that is, at the lowest possible precedence — instead of stopping at the first operator that binds less tightly thanIS. Everything after the operator is swallowed into its right operand:The
Int64 AND Booleanin the error is that innerr.a AND <bool>. PostgreSQL bindsANDless tightly thanIS, so the expected parse is(l.a IS NOT DISTINCT FROM r.a) AND (l.b IS NOT DISTINCT FROM r.b).This affects every column type and every join type, and it also affects
IS DISTINCT FROMand plainWHEREclauses. It blocks multi-column equality delete resolution in Apache Iceberg.The same greedy
parse_expr()call is still present ondatafusion-sqlparser-rsmain(src/parser/mod.rs:4074), so the fix is applied on the DataFusion side.What changes are included in this PR?
In
datafusion/sql/src/expr/mod.rs, before planning an expression the planner now restores the expected associativity:has_greedy_distinct_fromcheaply detects whether the mis-parse is present. When it isn't — the overwhelmingly common case — nothing else runs.flatten_and_orflattens theAND/ORspine into its first operand plus the remaining(operator, operand)pairs, re-attaching eachIS [NOT] DISTINCT FROMto only the first operand of its right hand side.rebuild_and_orrebuilds the expression withANDbinding more tightly thanOR, both left associative.Prefix
NOTis handled the same way, since it also binds more tightly thanAND/OR. HandlingORandNOTis required for correctness rather than completeness: a purely local rotation getsa IS NOT DISTINCT FROM 1 AND b IS NOT DISTINCT FROM 2 OR c IS NOT DISTINCT FROM 3wrong, producingA AND (B OR C)and turning a planning error into a silently wrong result.Operands are not descended into, so a parenthesised sub-expression keeps its explicit grouping and is handled when the planner recurses into it. The rewrite's output is a fixed point — it never leaves an
IS [NOT] DISTINCT FROMwhose right operand is anAND/OR— so re-entry cannot loop. Both recursive helpers carry the crate's usualrecursive_protectionattribute.Known limitation left in place
The postfix
ISfamily at the same precedence level (a IS NOT DISTINCT FROM b IS NULL) is still associated assqlparserproduces it. That behaviour is unchanged by this PR and belongs with the broader precedence discussion in #22461.Are these changes tested?
Yes.
New planner tests in
datafusion/sql/tests/sql_integration.rscovering joinONclauses,WHEREclauses, projections,IS DISTINCT FROM,AND/ORchains, and parenthesised forms as a control.New end-to-end cases in
datafusion/sqllogictest/test_files/join_is_not_distinct_from.slt, including theLEFT ANTI JOINfrom the issue, three-condition chains, and theAND/ORandNOTprecedence cases. The precedence cases are chosen so that incorrect grouping returns a different set of rows, not just a different plan — the plan display alone does not distinguishNOT (A AND B)from(NOT A) AND B.Verification run:
cargo test -p datafusion-sql— 565 + 87 + 12 doctests passcargo test -p datafusion-optimizer -p datafusion-expr— passescargo clippy -p datafusion-sql -p datafusion-optimizer --all-targets -- -D warnings— cleancargo fmt --allappliedAre there any user-facing changes?
Yes, and they are the point of the PR:
IS [NOT] DISTINCT FROMcombined withAND/OR/NOTwithout parentheses now parses the way PostgreSQL parses it, so queries that previously failed to plan now succeed.Queries that were already parenthesised are unaffected. No public API changes.
Generated by Claude Code