Honour operator precedence in IS [NOT] DISTINCT FROM - #2436
Conversation
The `Keyword::IS` arm of `parse_infix` parsed the right operand of `IS [NOT] DISTINCT FROM` with `parse_expr()`, i.e. `parse_subexpr(0)`, so the operand swallowed every following operator including `AND` and `OR`: `a IS DISTINCT FROM 1 AND b = 2` parsed as `a IS DISTINCT FROM (1 AND b = 2)`. Parse it at `precedence` instead, matching every other infix branch in the same function. For an `IS` token that is `prec_value(Precedence::Is)`, so the operand now stops at `AND` and `OR`, and at a following `IS` — making the `IS` family associate left — while still absorbing tighter operators. Environment: Datadog workspace Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IS [NOT] DISTINCT FROM right-operand precedenceIS [NOT] DISTINCT FROM
LucaCappelletti94
left a comment
There was a problem hiding this comment.
While reading through the PR, I noticed that also DIV has the identical defect. 7 DIV 2 + 1 → 7 DIV (2 + 1) = 2, while MySQL gives 4. It is only vaguely associated to the current PR in terms of precedence errors, so it should likely be a different subsequent PR, I just wanted to jot it down so as to not forget it.
--- a/src/dialect/mysql.rs
+++ b/src/dialect/mysql.rs
@@ -99,10 +99,10 @@ impl Dialect for MySqlDialect {
- _precedence: u8,
+ precedence: u8,
- let right = Box::new(match parser.parse_expr() {
+ let right = Box::new(match parser.parse_subexpr(precedence) {
--- a/src/dialect/spark.rs
+++ b/src/dialect/spark.rs
@@ -138,9 +138,9 @@ impl Dialect for SparkSqlDialect {
- _precedence: u8,
+ precedence: u8,
- let right = Box::new(match parser.parse_expr() {
+ let right = Box::new(match parser.parse_subexpr(precedence) {A red test for this could be:
#[test]
fn parse_div_precedence() {
// `DIV` has the same precedence as `*` and `/`, so `+` must end up at the root.
assert_eq!(
Expr::BinaryOp {
left: Box::new(Expr::BinaryOp {
left: Box::new(Expr::value(number("7"))),
op: BinaryOperator::MyIntegerDivide,
right: Box::new(Expr::value(number("2"))),
}),
op: BinaryOperator::Plus,
right: Box::new(Expr::value(number("1"))),
},
mysql().verified_expr("7 DIV 2 + 1")
);
}| Ok(Expr::IsNotUnknown(Box::new(expr))) | ||
| } else if self.parse_keywords(&[Keyword::DISTINCT, Keyword::FROM]) { | ||
| let expr2 = self.parse_expr()?; | ||
| let expr2 = self.parse_subexpr(precedence)?; |
There was a problem hiding this comment.
This regresses -> / @> in the non-PostgreSQL dialects, such as MySQL. I suggest you add the following red test in tests/sqlparser_mysql.rs, and proceed from there.
#[test]
fn parse_is_distinct_from_json_arrow_precedence() {
// MySQL's `->` binds tighter than `IS [NOT] DISTINCT FROM`, so the JSON
// extraction must stay inside the right operand.
assert_eq!(
Expr::IsDistinctFrom(
Box::new(Expr::Identifier(Ident::new("a"))),
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Identifier(Ident::new("b"))),
op: BinaryOperator::Arrow,
right: Box::new(Expr::Value(
Value::SingleQuotedString("k".into()).with_empty_span()
)),
}),
),
mysql().verified_expr("a IS DISTINCT FROM b -> 'k'")
);
}The issue to be clear is found in the default table, while your patch merely exposes this, but then it is a good occasion to fix it.
| verified_expr("a IS DISTINCT FROM (1 AND b)") | ||
| ); | ||
|
|
||
| // The `IS` family is left-associative. |
There was a problem hiding this comment.
| // The `IS` family is left-associative. | |
| // sqlparser resolves the IS family left-associatively, consistent with how | |
| // `a IS NULL IS NULL` already parses. Deliberately more permissive than | |
| // PostgreSQL, which declares IS as %nonassoc and rejects the chain. |
Parser::parse_infixparsed the right operand ofIS DISTINCT FROMandIS NOT DISTINCT FROMwithparse_expr, which isparse_subexpr(0), so the operand swallowed every following operator —including
ANDandOR.For instance,
a IS DISTINCT FROM 1 AND b = 2parsed asa IS DISTINCT FROM (1 AND b = 2)insteadof
(a IS DISTINCT FROM 1) AND (b = 2):PostgreSQL's operator precedence table places the
ISfamily aboveNOT,ANDandOR, soANDcannot be part of the right operand. Here is an expression that is well-typed under bothreadings and distinguishes them:
DuckDB, which follows PostgreSQL precedence here, returns
false.These two branches were ignoring the precedence their caller passed; using it fixes both. That value
is
prec_value(Precedence::Is)for anIStoken — 17 in the defaultDialectimpl,IS_PRECinthe PostgreSQL dialect. Since
parse_subexprbreaks onprecedence >= next_precedence, the operandnow stops at
AND(10) andOR(5), and at a followingIS— which is what makes the familyassociate left — while still absorbing tighter operators such as
+(30). TheISarm is notdialect-gated, so this applies to every dialect; that looks intended, since MySQL's
<=>andMSSQL's
IS [NOT] DISTINCT FROMbind the same way.This is the same root cause as #2419, in a different hook. As there, nothing errored before, and
Displayadds no parentheses, so the wrong tree reprinted as the original text — which is why around trip never caught it and the new test asserts on the tree instead.
The new
parse_is_distinct_from_precedencecovers:Each of the first six produces an
IsDistinctFromat the root before this change; the last twoguard against over-tightening. The
IS NULLcase is theIS-family left-associativity symptom ofthe same precedence-0 call.
cargo test,cargo fmtandcargo clippyall pass.