fix: correct operator precedence for IS [NOT] DISTINCT FROM - #68
Open
adriangb wants to merge 3 commits into
Open
fix: correct operator precedence for IS [NOT] DISTINCT FROM#68adriangb wants to merge 3 commits into
adriangb 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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Rationale for this change
The issue reports this failing:
Neither the join nor the second condition is needed. The minimal reproducer is:
ORfails the same way, and so does every join type,WHERE, and a bare projection. What they have in common is a singleIS [NOT] DISTINCT FROMwith anything following it that binds less tightly.The cause is not type coercion.
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 inner1 AND true. PostgreSQL bindsANDless tightly thanIS, so the expected parse is(1 IS NOT DISTINCT FROM 1) AND true.Parenthesising each condition is the workaround, which is why the bug went unnoticed: every existing
IS NOT DISTINCT FROMtest in the suite is parenthesised.The greedy
parse_expr()call is still present ondatafusion-sqlparser-rsmain(src/parser/mod.rs:4074), so the fix is applied on the DataFusion side. Fixing it upstream in the parser would be the cleaner home for it, and this workaround could then be dropped.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 operands and the operators separating them, 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.Both helpers walk the spine iteratively, with explicit work stacks, for the same reason
sql_expr_to_logical_expruses a stack machine (apache#1444): they run in front of it, on every expression the planner sees, and deepAND/ORchains are common.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.Known limitations left in place
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 Operator precedence is inconsistent with modern PG (and PG 7.2) apache/datafusion#22461.IS NOT DISTINCT FROMterms (~1024+) overflows the stack insidesqlparser's own recursive parse, before any DataFusion code runs — the same greedyparse_expr()makes each term nest in the AST instead of looping. Also not fixable from the planner side.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/select.sltfor the minimal reproducer and theAND/OR/NOTcases around it, and indatafusion/sqllogictest/test_files/join_is_not_distinct_from.sltfor theLEFT ANTI JOINfrom the issue, three-condition chains, and the join-level precedence cases. The precedence cases use values where an incorrect grouping returns a different answer, not just a different plan — the plan display alone does not distinguishNOT (A AND B)from(NOT A) AND B.New
test_stack_overflow_distinct_from_{1024,8192}indatafusion/sql/src/expr/mod.rs, covering the fixup at the same spine depths the neighbouringtest_stack_overflowtests use. As documented on the test, it is a scale check rather than a proof: these frames are small enough that a recursive walk would survive these depths too.Verified locally:
cargo test -p datafusion-sql— 89 + 564 + 12 doctests passcargo test -p datafusion-optimizer -p datafusion-expr— passescargo clippy -p datafusion-sql -p datafusion-optimizer --all-targets --all-features -- -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.