You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by lu...@apache.org on 2026/10/02 18:53:39 UTC

(datafusion-sqlparser-rs) 01/01: Gate infix colon JSON access and literal/placeholder field access by dialect

This is an automated email from the ASF dual-hosted git repository.

LucaCappelletti94 pushed a commit to branch upstream/colon-access-dialect-gate
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git

commit c308be781107ed042c2f368d65519b39da2a6194
Author: LucaCappelletti94 <ca...@gmail.com>
AuthorDate: Fri Oct 2 20:52:52 2026 +0200

    Gate infix colon JSON access and literal/placeholder field access by dialect
---
 derive/src/dialect.rs       |  2 +-
 src/dialect/databricks.rs   |  4 ++++
 src/dialect/generic.rs      |  4 ++++
 src/dialect/mod.rs          | 30 ++++++++++++++++++++++--------
 src/dialect/postgresql.rs   |  8 ++++++++
 src/dialect/snowflake.rs    |  4 ++++
 src/dialect/sqlite.rs       |  9 ++++++++-
 src/parser/mod.rs           | 12 +++++++++---
 tests/sqlparser_common.rs   | 31 +++++++++++++++++++++++++------
 tests/sqlparser_postgres.rs | 29 +++++++++++++++++++++++++++++
 tests/sqlparser_sqlite.rs   | 14 +++++++++++++-
 11 files changed, 127 insertions(+), 20 deletions(-)

diff --git a/derive/src/dialect.rs b/derive/src/dialect.rs
index af17067a..0aad90ae 100644
--- a/derive/src/dialect.rs
+++ b/derive/src/dialect.rs
@@ -229,7 +229,7 @@ fn generate_derived_dialect(input: &DeriveDialectInput, methods: &[DialectMethod
         const _: () = {
             use ::core::iter::Peekable;
             use ::core::str::Chars;
-            use sqlparser::ast::{ColumnOption, Expr, GranteesType, Ident, ObjectNamePart, Statement};
+            use sqlparser::ast::{ColumnOption, Expr, GranteesType, Ident, ObjectNamePart, Statement, Value};
             use sqlparser::dialect::{Dialect, Precedence};
             use sqlparser::keywords::Keyword;
             use sqlparser::parser::{Parser, ParserError};
diff --git a/src/dialect/databricks.rs b/src/dialect/databricks.rs
index ca8610dd..9efd03e8 100644
--- a/src/dialect/databricks.rs
+++ b/src/dialect/databricks.rs
@@ -48,6 +48,10 @@ impl Dialect for DatabricksDialect {
         true
     }
 
+    fn supports_semi_structured_data_traversal(&self) -> bool {
+        true
+    }
+
     fn supports_filter_during_aggregation(&self) -> bool {
         true
     }
diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs
index 09a55185..b83b15b3 100644
--- a/src/dialect/generic.rs
+++ b/src/dialect/generic.rs
@@ -28,6 +28,10 @@ impl Dialect for GenericDialect {
         true
     }
 
+    fn supports_semi_structured_data_traversal(&self) -> bool {
+        true
+    }
+
     fn is_delimited_identifier_start(&self, ch: char) -> bool {
         ch == '"' || ch == '`'
     }
diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs
index 2beb5655..2534105d 100644
--- a/src/dialect/mod.rs
+++ b/src/dialect/mod.rs
@@ -132,7 +132,7 @@ pub use self::teradata::TeradataDialect;
 #[cfg(feature = "derive-dialect")]
 pub use sqlparser_derive::derive_dialect;
 
-use crate::ast::{ColumnOption, Expr, GranteesType, Ident, ObjectNamePart, Statement};
+use crate::ast::{ColumnOption, Expr, GranteesType, Ident, ObjectNamePart, Statement, Value};
 pub use crate::keywords;
 use crate::keywords::Keyword;
 use crate::parser::{Parser, ParserError};
@@ -973,13 +973,15 @@ pub trait Dialect: Debug + Any {
             Token::DoubleColon | Token::ExclamationMark | Token::LBracket | Token::CaretAt => {
                 Ok(p!(DoubleColon))
             }
-            Token::Colon => match &parser.peek_nth_token_ref(1).token {
-                // When colon is followed by a string or a number, it's usually in MAP syntax.
-                Token::SingleQuotedString(_) | Token::Number(_, _) => Ok(self.prec_unknown()),
-                // In other cases, it's used in semi-structured data traversal like in variant or JSON
-                // string columns. See `JsonAccess`.
-                _ => Ok(p!(Colon)),
-            },
+            Token::Colon if self.supports_semi_structured_data_traversal() => {
+                match &parser.peek_nth_token_ref(1).token {
+                    // When colon is followed by a string or a number, it's usually in MAP syntax.
+                    Token::SingleQuotedString(_) | Token::Number(_, _) => Ok(self.prec_unknown()),
+                    // In other cases, it's used in semi-structured data traversal like in variant or JSON
+                    // string columns. See `JsonAccess`.
+                    _ => Ok(p!(Colon)),
+                }
+            }
             Token::Arrow
             | Token::LongArrow
             | Token::HashArrow
@@ -1361,6 +1363,18 @@ pub trait Dialect: Debug + Any {
         false
     }
 
+    /// Returns true if the dialect supports traversing semi-structured data (JSON,
+    /// variant, etc.) with the infix `:` operator, e.g. `a:b`.
+    fn supports_semi_structured_data_traversal(&self) -> bool {
+        false
+    }
+
+    /// Returns true if `value` may be the root of a `.field` or `[index]`
+    /// access chain under this dialect.
+    fn supports_value_access(&self, _value: &Value) -> bool {
+        true
+    }
+
     /// Returns true if the dialect supports object-unpivot table factors in the FROM clause.
     ///
     /// Syntax:
diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs
index 3bec6ceb..93b92023 100644
--- a/src/dialect/postgresql.rs
+++ b/src/dialect/postgresql.rs
@@ -28,6 +28,7 @@
 // limitations under the License.
 use log::debug;
 
+use crate::ast::Value;
 use crate::dialect::{Dialect, Precedence};
 use crate::keywords::Keyword;
 use crate::parser::{Parser, ParserError};
@@ -368,4 +369,11 @@ impl Dialect for PostgreSqlDialect {
     fn supports_comment_optimizer_hint(&self) -> bool {
         true
     }
+
+    /// Postgres' `c_expr: PARAM opt_indirection` grammar allows `.field`/`[index]`
+    /// on a numbered parameter (`$1`), but not on other placeholder forms, on
+    /// string/boolean/null literals, or on a bare numeric literal.
+    fn supports_value_access(&self, value: &Value) -> bool {
+        matches!(value, Value::Placeholder(name) if name.starts_with('$'))
+    }
 }
diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs
index 3ebc1686..cb02f2c6 100644
--- a/src/dialect/snowflake.rs
+++ b/src/dialect/snowflake.rs
@@ -507,6 +507,10 @@ impl Dialect for SnowflakeDialect {
         true
     }
 
+    fn supports_semi_structured_data_traversal(&self) -> bool {
+        true
+    }
+
     fn is_column_alias(&self, kw: &Keyword, parser: &mut Parser) -> bool {
         match kw {
             // The following keywords can be considered an alias as long as
diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs
index 13bc555a..27d0cdbb 100644
--- a/src/dialect/sqlite.rs
+++ b/src/dialect/sqlite.rs
@@ -19,7 +19,7 @@
 use alloc::boxed::Box;
 
 use crate::ast::BinaryOperator;
-use crate::ast::{Expr, Statement};
+use crate::ast::{Expr, Statement, Value};
 use crate::dialect::Dialect;
 use crate::keywords::Keyword;
 use crate::parser::{Parser, ParserError};
@@ -145,4 +145,11 @@ impl Dialect for SQLiteDialect {
     fn supports_national_string_literal(&self) -> bool {
         false
     }
+
+    /// SQLite's grammar takes a quoted string as a table qualifier
+    /// (`nm ::= id | STRING`), so a string-literal root is a valid `.field`
+    /// access root; bind parameters and bare numeric literals aren't.
+    fn supports_value_access(&self, value: &Value) -> bool {
+        matches!(value, Value::SingleQuotedString(_))
+    }
 }
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index 1a46c6e5..3d647fa6 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -2082,8 +2082,13 @@ impl<'a> Parser<'a> {
         mut chain: Vec<AccessExpr>,
     ) -> Result<Expr, ParserError> {
         let mut ending_wildcard: Option<TokenWithSpan> = None;
+        // Whether `root` is allowed to start a `.field` / `[index]` access chain under this dialect.
+        let root_value_access_allowed = match &root {
+            Expr::Value(v) => self.dialect.supports_value_access(&v.value),
+            _ => true,
+        };
         loop {
-            if self.consume_token(&Token::Period) {
+            if root_value_access_allowed && self.consume_token(&Token::Period) {
                 let next_token = self.peek_token_ref();
                 match &next_token.token {
                     Token::Mul => {
@@ -2183,7 +2188,8 @@ impl<'a> Parser<'a> {
                         }
                     }
                 }
-            } else if !self.dialect.supports_partiql()
+            } else if root_value_access_allowed
+                && !self.dialect.supports_partiql()
                 && self.peek_token_ref().token == Token::LBracket
             {
                 self.parse_multi_dim_subscript(&mut chain)?;
@@ -4262,7 +4268,7 @@ impl<'a> Parser<'a> {
                 expr: Box::new(expr),
             })
         } else if Token::LBracket == *tok && self.dialect.supports_partiql()
-            || (Token::Colon == *tok)
+            || (Token::Colon == *tok && self.dialect.supports_semi_structured_data_traversal())
         {
             self.prev_token();
             self.parse_json_access(expr)
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index 8e08c657..f0d06eed 100644
--- a/tests/sqlparser_common.rs
+++ b/tests/sqlparser_common.rs
@@ -20242,12 +20242,15 @@ fn parse_alter_table_column_position() {
 
 #[test]
 fn parse_compound_field_access_numeric_display() {
-    all_dialects().verified_stmt("SELECT 1 . i");
-    all_dialects().verified_stmt("SELECT (1 . i)");
-    all_dialects().verified_stmt("SELECT 1 . 2");
-    all_dialects().verified_stmt("SELECT (a . 1 . b)");
-    all_dialects().verified_stmt("SELECT 1 . i.j");
-    all_dialects().verified_stmt("SELECT 1 . 2 . 3");
+    // PostgreSQL and SQLite reject a bare numeric literal as a `.field` access root
+    // (see `parse_indirection_rejects_non_param_value_roots`, `parse_placeholder_field_access_rejected`).
+    let dialects = all_dialects_except(|d| d.is::<PostgreSqlDialect>() || d.is::<SQLiteDialect>());
+    dialects.verified_stmt("SELECT 1 . i");
+    dialects.verified_stmt("SELECT (1 . i)");
+    dialects.verified_stmt("SELECT 1 . 2");
+    dialects.verified_stmt("SELECT (a . 1 . b)");
+    dialects.verified_stmt("SELECT 1 . i.j");
+    dialects.verified_stmt("SELECT 1 . 2 . 3");
     all_dialects().verified_stmt("SELECT a.b.c");
 
     let err = all_dialects_where(|d| !d.supports_numeric_prefix())
@@ -20433,3 +20436,19 @@ fn parse_bang_not_renders_apart_from_operand() {
     dialects.verified_stmt("SET eaac_cion = ! !o");
     dialects.one_statement_parses_to("SET eaac_cion = ! ! o", "SET eaac_cion = ! !o");
 }
+
+#[test]
+fn parse_colon_json_access_requires_dialect_support() {
+    let unsupported = all_dialects_where(|d| !d.supports_semi_structured_data_traversal());
+    assert_eq!(
+        unsupported
+            .parse_sql_statements("SELECT a:b FROM t")
+            .unwrap_err(),
+        ParserError::ParserError("Expected: end of statement, found: :".to_string())
+    );
+
+    // The infix `:` operator is unrelated to `::` casts and `:name` placeholders,
+    // both of which every dialect still accepts.
+    unsupported.verified_stmt("SELECT a::INT FROM t");
+    unsupported.verified_stmt("SELECT :name");
+}
diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs
index fb998510..0a3c608a 100644
--- a/tests/sqlparser_postgres.rs
+++ b/tests/sqlparser_postgres.rs
@@ -10126,3 +10126,32 @@ fn parse_bitstring_literal_escaping() {
     pg_and_generic().verified_stmt("SELECT B''''");
     pg_and_generic().verified_stmt("SELECT B'it''s'");
 }
+
+#[test]
+fn parse_param_indirection() {
+    // `c_expr: PARAM opt_indirection` allows `.field`/`[index]` on a numbered parameter.
+    pg().verified_stmt("SELECT $1.a");
+    pg().verified_stmt("SELECT $1[1]");
+    pg().verified_stmt("SELECT $1[1].a");
+}
+
+#[test]
+fn parse_indirection_rejects_non_param_value_roots() {
+    // Unlike `$n`, string literals, bare numbers and `:name` placeholders don't carry `opt_indirection`.
+    for sql in [
+        "SELECT 'abc'.foo",
+        "SELECT c = :NA.o FROM t",
+        "SELECT 2 .id FROM t",
+    ] {
+        assert_eq!(
+            pg().parse_sql_statements(sql).unwrap_err(),
+            ParserError::ParserError("Expected: end of statement, found: .".to_string()),
+        );
+    }
+    for sql in ["SELECT 'abc'[1]", "SELECT c = :NA[0] FROM t"] {
+        assert_eq!(
+            pg().parse_sql_statements(sql).unwrap_err(),
+            ParserError::ParserError("No infix parser for token LBracket".to_string()),
+        );
+    }
+}
diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs
index 81059761..72e744ab 100644
--- a/tests/sqlparser_sqlite.rs
+++ b/tests/sqlparser_sqlite.rs
@@ -398,7 +398,19 @@ fn parse_create_table_with_strict() {
 #[test]
 fn parse_single_quoted_identified() {
     sqlite().verified_only_select("SELECT 't'.*, t.'x' FROM 't'");
-    // TODO: add support for select 't'.x
+    // SQLite's `nm ::= id | STRING` grammar takes a quoted string as a table qualifier.
+    sqlite().verified_only_select("SELECT 't'.x FROM 't'");
+}
+
+#[test]
+fn parse_field_access_rejects_non_string_value_roots() {
+    // Bind parameters and bare numeric literals are scalar, so they can't root a `.field` access chain.
+    for sql in ["SELECT c = :NA.o FROM t", "SELECT 2 .id FROM t"] {
+        assert_eq!(
+            sqlite().parse_sql_statements(sql).unwrap_err(),
+            ParserError::ParserError("Expected: end of statement, found: .".to_string())
+        );
+    }
 }
 
 #[test]


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org