From 53d4fd0461bd27125c20220ebda1d1cce54cbaa2 Mon Sep 17 00:00:00 2001 From: Laszlo Hadhazy Date: Mon, 28 Sep 2026 19:22:44 +0200 Subject: [PATCH] Bind `->` and `->>` above arithmetic in MySQL and Generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c -> '$.a'` is defined as `JSON_EXTRACT(c, '$.a')`, so the extraction has to happen before an operator can use its result. Both arrows carried `Precedence::PgOther`, which sits below `*`, `+` and the bitwise operators, so `c -> '$.a' + 1` parsed as `c -> ('$.a' + 1)` — adding a number to a JSON path — and `1 + c -> '$.a'` parsed as `(1 + c) -> '$.a'`. Following the narrowest of the two approaches in the issue. The arrows move to their own `Precedence::JsonExtraction`, which takes the same value as `PgOther` in the shared table, so no dialect changes by default and the rest of that operator family stays where it is. `MySqlDialect` then prices those two tokens above `MulDivModOp` through `get_next_precedence`, which leaves the table it shares with everyone else alone. `GenericDialect` follows MySQL rather than PostgreSQL. The issue calls this a judgment call, and the contributing guide settles it: a feature specific to one dialect should parse under that dialect and under Generic. Checked which dialects this moves by parsing `SELECT c -> 'p' + 1` under each: MySQL and Generic group it the new way; PostgreSQL, SQLite, Hive, BigQuery, Redshift and MsSQL are untouched; ClickHouse, Snowflake, Databricks and DuckDB read `->` as a lambda, where precedence never applies. The issue's table stops at arithmetic while its title does not: `&` and `^` misgrouped for the same reason, at 23 and 22. `|` was already correct, but only because `Pipe` shares 21 with `PgOther` and left associativity gives the same answer, so the test pins it against a future change to either value. Closes #2462 Signed-off-by: Laszlo Hadhazy --- src/dialect/generic.rs | 17 ++++++++ src/dialect/mod.rs | 18 ++++++-- src/dialect/mysql.rs | 17 ++++++++ src/dialect/postgresql.rs | 4 ++ tests/sqlparser_mysql.rs | 92 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 09a5518595..2b3184e4a7 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -15,7 +15,10 @@ // specific language governing permissions and limitations // under the License. +use crate::dialect::mysql::JSON_EXTRACTION_PREC; use crate::dialect::Dialect; +use crate::parser::{Parser, ParserError}; +use crate::tokenizer::Token; /// A permissive, general purpose [`Dialect`], which parses a wide variety of SQL /// statements, from many different dialects. @@ -24,6 +27,20 @@ use crate::dialect::Dialect; pub struct GenericDialect; impl Dialect for GenericDialect { + /// `->` and `->>` follow MySQL here rather than PostgreSQL. + /// + /// The two engines disagree: PostgreSQL treats the arrows as ordinary + /// operators below arithmetic, while in MySQL `c -> '$.a'` is + /// `JSON_EXTRACT(c, '$.a')` and binds before it. A permissive dialect has + /// to pick one, and the contributing guide asks that a dialect-specific + /// feature parse under both that dialect and this one. + fn get_next_precedence(&self, parser: &Parser) -> Option> { + match parser.peek_token_ref().token { + Token::Arrow | Token::LongArrow => Some(Ok(JSON_EXTRACTION_PREC)), + _ => None, + } + } + fn supports_map_typedef_with_parentheses(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 6a3d30f1c5..97a5c28518 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -980,9 +980,14 @@ pub trait Dialect: Debug + Any { // string columns. See `JsonAccess`. _ => Ok(p!(Colon)), }, - Token::Arrow - | Token::LongArrow - | Token::HashArrow + // `->` and `->>` are separated from the rest of the family + // because dialects disagree about them: in PostgreSQL they are + // ordinary operators, while in MySQL `c -> '$.a'` means + // `JSON_EXTRACT(c, '$.a')` and has to bind before arithmetic. The + // default value is the same for both, and the dialects that need + // the MySQL grouping override these two tokens. + Token::Arrow | Token::LongArrow => Ok(p!(JsonExtraction)), + Token::HashArrow | Token::HashLongArrow | Token::AtArrow | Token::ArrowAt @@ -1033,6 +1038,11 @@ pub trait Dialect: Debug + Any { Precedence::Ampersand => 23, Precedence::Caret => 22, Precedence::Pipe => 21, + // Same value as `PgOther`, which is where the arrows have always + // sat. The variant exists so that a dialect whose arrows extract + // from JSON can move them without moving the rest of that family; + // see `MySqlDialect::get_next_precedence`. + Precedence::JsonExtraction => 21, Precedence::Colon => 21, // "any other operator" -- `->`, `@>`, custom operators. PostgreSQL // places this row above `BETWEEN` / `LIKE` and below `+` / `-` @@ -1991,6 +2001,8 @@ pub enum Precedence { Pipe, /// `:` operator for json/variant access. Colon, + /// `->` and `->>`, which extract from JSON and bind before arithmetic. + JsonExtraction, /// `BETWEEN` operator. Between, /// Equality operator (`=`). diff --git a/src/dialect/mysql.rs b/src/dialect/mysql.rs index 4bfa17a664..e12b6fded3 100644 --- a/src/dialect/mysql.rs +++ b/src/dialect/mysql.rs @@ -23,6 +23,7 @@ use crate::{ dialect::Dialect, keywords::Keyword, parser::{Parser, ParserError}, + tokenizer::Token, }; use super::keywords; @@ -39,6 +40,13 @@ const RESERVED_FOR_TABLE_ALIAS_MYSQL: &[Keyword] = &[ #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MySqlDialect {} +/// Precedence of `->` and `->>` in MySQL. +/// +/// `c -> '$.a'` is defined as `JSON_EXTRACT(c, '$.a')`, so the extraction has +/// to happen before any operator can use its result. That puts it above +/// `MulDivModOp` (40) and `AtTz` (41), and below `DoubleColon` (50). +pub(crate) const JSON_EXTRACTION_PREC: u8 = 42; + impl Dialect for MySqlDialect { fn is_identifier_start(&self, ch: char) -> bool { // See https://dev.mysql.com/doc/refman/8.0/en/identifiers.html. @@ -59,6 +67,15 @@ impl Dialect for MySqlDialect { !ch.is_ascii() } + fn get_next_precedence(&self, parser: &Parser) -> Option> { + match parser.peek_token_ref().token { + // The rest of the `PgOther` family keeps the shared value: only + // these two extract from JSON in MySQL. + Token::Arrow | Token::LongArrow => Some(Ok(JSON_EXTRACTION_PREC)), + _ => None, + } + } + fn is_delimited_identifier_start(&self, ch: char) -> bool { ch == '`' } diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index 3bec6ceba3..43d1bedca8 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -200,6 +200,10 @@ impl Dialect for PostgreSqlDialect { Precedence::Eq => EQ_PREC, Precedence::Like => BETWEEN_LIKE_PREC, Precedence::Is => IS_PREC, + // PostgreSQL's `->` is an ordinary operator and belongs below + // arithmetic, which is where it already sits. The dedicated + // variant exists for dialects whose arrows extract from JSON. + Precedence::JsonExtraction => PG_OTHER_PREC, Precedence::PgOther => PG_OTHER_PREC, Precedence::UnaryNot => NOT_PREC, Precedence::And => AND_PREC, diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index e3fa77f068..37cc027d4a 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -5053,6 +5053,98 @@ fn parse_is_distinct_from_json_arrow_precedence() { ); } +#[test] +fn parse_json_arrow_arithmetic_precedence() { + let col = || Expr::Identifier(Ident::new("c")); + let path = || Expr::Value(Value::SingleQuotedString("$.a".into()).with_empty_span()); + let extract = |op: BinaryOperator| Expr::BinaryOp { + left: Box::new(col()), + op, + right: Box::new(path()), + }; + let num = |n: &str| Expr::value(number(n)); + let binop = |left: Expr, op: BinaryOperator, right: Expr| Expr::BinaryOp { + left: Box::new(left), + op, + right: Box::new(right), + }; + + // `c -> path` is defined as `JSON_EXTRACT(c, path)`, so the extraction has + // to happen before any arithmetic can use its result: `c -> '$.a' + 1` is + // `(c -> '$.a') + 1` rather than `c -> ('$.a' + 1)`, which would add a + // number to a JSON path. + assert_eq!( + binop( + extract(BinaryOperator::Arrow), + BinaryOperator::Plus, + num("1") + ), + mysql_and_generic().verified_expr("c -> '$.a' + 1") + ); + assert_eq!( + binop( + extract(BinaryOperator::LongArrow), + BinaryOperator::Plus, + num("1") + ), + mysql_and_generic().verified_expr("c ->> '$.a' + 1") + ); + assert_eq!( + binop( + extract(BinaryOperator::Arrow), + BinaryOperator::Multiply, + num("2") + ), + mysql_and_generic().verified_expr("c -> '$.a' * 2") + ); + + // The same on the other side: the arrow takes `c`, not `1 + c`. + assert_eq!( + binop( + num("1"), + BinaryOperator::Plus, + extract(BinaryOperator::Arrow) + ), + mysql_and_generic().verified_expr("1 + c -> '$.a'") + ); + + // Bitwise operators bind less tightly than the arrow too, which the + // arithmetic cases above do not cover: `&` and `^` sit above `->` in the + // precedence table while MySQL puts them below it. + assert_eq!( + binop( + extract(BinaryOperator::Arrow), + BinaryOperator::BitwiseAnd, + num("1") + ), + mysql_and_generic().verified_expr("c -> '$.a' & 1") + ); + assert_eq!( + binop( + extract(BinaryOperator::Arrow), + BinaryOperator::BitwiseXor, + num("1") + ), + mysql_and_generic().verified_expr("c -> '$.a' ^ 1") + ); + + // These two already group correctly and are kept so that a change to the + // arrow's precedence cannot break them: `=` sits below the arrow, and `|` + // shares its level, where left associativity gives the same answer. + assert_eq!( + binop(extract(BinaryOperator::Arrow), BinaryOperator::Eq, num("1")), + mysql_and_generic().verified_expr("c -> '$.a' = 1") + ); + assert_eq!( + binop( + extract(BinaryOperator::Arrow), + BinaryOperator::BitwiseOr, + num("1") + ), + mysql_and_generic().verified_expr("c -> '$.a' | 1") + ); +} + #[test] fn parse_bitstring_literal_escaping() { mysql_and_generic().verified_stmt("SELECT B''''");