You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/09/26 12:06:18 UTC
(datafusion-sqlparser-rs) branch main updated: ClickHouse: Support tuple element access by index (#2574)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 91f7ce279 ClickHouse: Support tuple element access by index (#2574)
91f7ce279 is described below
commit 91f7ce27923c0b39fa49525c5f03057247c9732a
Author: Dmitri B. <s5...@proton.me>
AuthorDate: Sat Sep 26 12:04:33 2026 +0000
ClickHouse: Support tuple element access by index (#2574)
---
.../fuzz_seeds/clickhouse_tuple_element_access.sql | 1 +
src/dialect/clickhouse.rs | 5 ++
src/dialect/mod.rs | 6 ++
src/parser/mod.rs | 2 +
src/tokenizer.rs | 96 ++++++++++++++++++++++
tests/sqlparser_clickhouse.rs | 62 ++++++++++++++
6 files changed, 172 insertions(+)
diff --git a/fuzz/fuzz_seeds/clickhouse_tuple_element_access.sql b/fuzz/fuzz_seeds/clickhouse_tuple_element_access.sql
new file mode 100644
index 000000000..ee21b503d
--- /dev/null
+++ b/fuzz/fuzz_seeds/clickhouse_tuple_element_access.sql
@@ -0,0 +1 @@
+SELECT t.1, t.2.1, (1, 'a').2, tuple(1, (2, 3)).2.1, [(1, 2)][1].1 FROM (SELECT (1, (2, 3)) AS t) WHERE t.2.2 = 3
\ No newline at end of file
diff --git a/src/dialect/clickhouse.rs b/src/dialect/clickhouse.rs
index 908075827..d5c130d04 100644
--- a/src/dialect/clickhouse.rs
+++ b/src/dialect/clickhouse.rs
@@ -178,4 +178,9 @@ impl Dialect for ClickHouseDialect {
fn supports_alter_column_position(&self) -> bool {
true
}
+
+ /// See <https://clickhouse.com/docs/sql-reference/data-types/tuple#addressing-tuple-elements>
+ fn supports_tuple_element_access(&self) -> bool {
+ true
+ }
}
diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs
index e3e79281a..4bd80d580 100644
--- a/src/dialect/mod.rs
+++ b/src/dialect/mod.rs
@@ -505,6 +505,12 @@ pub trait Dialect: Debug + Any {
false
}
+ /// Returns true if a period directly after an identifier starts a tuple
+ /// element access such as `t.1`, instead of the number `.1`.
+ fn supports_tuple_element_access(&self) -> bool {
+ false
+ }
+
/// Returns true if the dialect supports numbers containing underscores, e.g. `10_000_000`
fn supports_numeric_literal_underscores(&self) -> bool {
false
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index 9ae1de545..f7fc6d33b 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -1360,6 +1360,8 @@ impl<'a> Parser<'a> {
AttachedToken(next_token),
));
}
+ // A field index such as `t.1` is parsed as an expression.
+ Token::Number(..) if self.dialect.supports_tuple_element_access() => break,
_ => {
return self.expected("an identifier or a '*' after '.'", next_token);
}
diff --git a/src/tokenizer.rs b/src/tokenizer.rs
index 7d363793c..423bbea31 100644
--- a/src/tokenizer.rs
+++ b/src/tokenizer.rs
@@ -1354,8 +1354,34 @@ impl<'a> Tokenizer<'a> {
);
}
+ // A period directly after an identifier, `)`, `]` or a tuple index
+ // starts a field access such as `t.1`, `(1, 2).1` or `t.1.2`, never a float.
+ if ch == '.'
+ && self.dialect.supports_tuple_element_access()
+ && matches!(
+ prev_token,
+ Some(
+ Token::Word(_)
+ | Token::RParen
+ | Token::RBracket
+ | Token::Number(..)
+ )
+ )
+ {
+ chars.next();
+ return Ok(Some(Token::Period));
+ }
+
let mut s = self.tokenize_number_part(chars, |ch| ch.is_ascii_digit())?;
+ // A tuple index is an integer, so `t.1.2` is `(t.1).2`.
+ if !s.is_empty()
+ && self.dialect.supports_tuple_element_access()
+ && prev_token == Some(&Token::Period)
+ {
+ return Ok(Some(Token::Number(s, false)));
+ }
+
// match binary literal that starts with 0x
if s == "0" && chars.peek() == Some(&'x') {
chars.next();
@@ -4580,4 +4606,74 @@ mod tests {
"[a b]"
);
}
+
+ #[test]
+ fn tokenize_period_before_digits_after_identifier_or_bracket() {
+ let dialect = ClickHouseDialect {};
+ for (sql, expected) in [
+ (
+ "t.1",
+ vec![
+ Token::make_word("t", None),
+ Token::Period,
+ Token::Number("1".to_string(), false),
+ ],
+ ),
+ (
+ "(1).1",
+ vec![
+ Token::LParen,
+ Token::Number("1".to_string(), false),
+ Token::RParen,
+ Token::Period,
+ Token::Number("1".to_string(), false),
+ ],
+ ),
+ (
+ "a[1].1",
+ vec![
+ Token::make_word("a", None),
+ Token::LBracket,
+ Token::Number("1".to_string(), false),
+ Token::RBracket,
+ Token::Period,
+ Token::Number("1".to_string(), false),
+ ],
+ ),
+ (
+ "t.1.2",
+ vec![
+ Token::make_word("t", None),
+ Token::Period,
+ Token::Number("1".to_string(), false),
+ Token::Period,
+ Token::Number("2".to_string(), false),
+ ],
+ ),
+ (
+ "t .1",
+ vec![
+ Token::make_word("t", None),
+ Token::Whitespace(Whitespace::Space),
+ Token::Number(".1".to_string(), false),
+ ],
+ ),
+ ("1.5", vec![Token::Number("1.5".to_string(), false)]),
+ (".5", vec![Token::Number(".5".to_string(), false)]),
+ ] {
+ let tokens = Tokenizer::new(&dialect, sql).tokenize().unwrap();
+ compare(expected, tokens);
+ }
+
+ let tokens = Tokenizer::new(&GenericDialect {}, "t.1")
+ .tokenize()
+ .unwrap();
+ compare(
+ vec![
+ Token::make_word("t", None),
+ Token::Number(".1".to_string(), false),
+ ],
+ tokens,
+ );
+ }
}
diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs
index affba45aa..17d558526 100644
--- a/tests/sqlparser_clickhouse.rs
+++ b/tests/sqlparser_clickhouse.rs
@@ -2022,6 +2022,68 @@ fn parse_object_type_parameter() {
}
}
+#[test]
+fn parse_tuple_element_access() {
+ for (sql, canonical) in [
+ ("SELECT t.1 FROM t", "SELECT t . 1 FROM t"),
+ (
+ "SELECT t.1 AS a, t.2 AS b FROM (SELECT (1, 'x') AS t)",
+ "SELECT t . 1 AS a, t . 2 AS b FROM (SELECT (1, 'x') AS t)",
+ ),
+ ("SELECT (1, 'a').1", "SELECT (1, 'a') . 1"),
+ ("SELECT tuple(1, 'a').2", "SELECT tuple(1, 'a') . 2"),
+ ("SELECT arr[1].1 FROM t", "SELECT arr[1] . 1 FROM t"),
+ ("SELECT `t`.1 FROM t", "SELECT `t` . 1 FROM t"),
+ ("SELECT t.1 + 1 FROM t", "SELECT t . 1 + 1 FROM t"),
+ (
+ "SELECT * FROM t WHERE t.1 = 1",
+ "SELECT * FROM t WHERE t . 1 = 1",
+ ),
+ ("SELECT t.1.2 FROM t", "SELECT t . 1 . 2 FROM t"),
+ (
+ "SELECT ((1, 2), 3).1.2, tuple(1, (2, 3)).2.1",
+ "SELECT ((1, 2), 3) . 1 . 2, tuple(1, (2, 3)) . 2 . 1",
+ ),
+ ] {
+ clickhouse().one_statement_parses_to(sql, canonical);
+ }
+
+ let select = clickhouse().verified_only_select("SELECT t . 1 FROM t");
+ assert_eq!(
+ select.projection[0],
+ UnnamedExpr(Expr::CompoundFieldAccess {
+ root: Box::new(Identifier(Ident::new("t"))),
+ access_chain: vec![AccessExpr::Dot(Expr::Value(number("1").with_empty_span()))],
+ })
+ );
+
+ let select = clickhouse().verified_only_select("SELECT t . 1 . 2 FROM t");
+ assert_eq!(
+ select.projection[0],
+ UnnamedExpr(Expr::CompoundFieldAccess {
+ root: Box::new(Identifier(Ident::new("t"))),
+ access_chain: vec![
+ AccessExpr::Dot(Expr::Value(number("1").with_empty_span())),
+ AccessExpr::Dot(Expr::Value(number("2").with_empty_span())),
+ ],
+ })
+ );
+
+ clickhouse().verified_stmt("SELECT 1.5, 1 + 0.5");
+ clickhouse().one_statement_parses_to("SELECT .5, 1e3", "SELECT 0.5, 1000");
+
+ assert!(clickhouse().parse_sql_statements("SELECT t.").is_err());
+ assert!(clickhouse().parse_sql_statements("SELECT t.1.").is_err());
+
+ let unsupported = all_dialects_where(|d| !d.supports_tuple_element_access());
+ unsupported.verified_stmt("SELECT (1, 'a') . 1");
+ for dialect in unsupported.dialects {
+ assert!(TestedDialects::new(vec![dialect])
+ .parse_sql_statements("SELECT t.1 FROM t")
+ .is_err());
+ }
+}
+
fn clickhouse() -> TestedDialects {
TestedDialects::new(vec![Box::new(ClickHouseDialect {})])
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org