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/10/14 17:35:41 UTC
(datafusion-sqlparser-rs) 01/01: ClickHouse: Support ON CLUSTER and SETTINGS in DELETE and UPDATE (#2617)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-2617-ffd5adea81a271c849098a47c72350e6ba57698c
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git
commit efce184d051da6f5bec1e857172671b0d39880b7
Author: Jordi Orihuela <jo...@tinybird.co>
AuthorDate: Wed Oct 14 17:35:15 2026 +0000
ClickHouse: Support ON CLUSTER and SETTINGS in DELETE and UPDATE (#2617)
---
fuzz/fuzz_seeds/clickhouse_delete.sql | 5 ++
fuzz/fuzz_seeds/clickhouse_update.sql | 5 ++
src/ast/dml.rs | 24 +++++++
src/ast/spans.rs | 59 ++++++++++++++--
src/parser/mod.rs | 8 +++
tests/sqlparser_clickhouse.rs | 127 ++++++++++++++++++++++++++++++++++
tests/sqlparser_common.rs | 6 +-
tests/sqlparser_mysql.rs | 2 +
tests/sqlparser_sqlite.rs | 2 +
9 files changed, 231 insertions(+), 7 deletions(-)
diff --git a/fuzz/fuzz_seeds/clickhouse_delete.sql b/fuzz/fuzz_seeds/clickhouse_delete.sql
new file mode 100644
index 00000000..eaa9e272
--- /dev/null
+++ b/fuzz/fuzz_seeds/clickhouse_delete.sql
@@ -0,0 +1,5 @@
+DELETE FROM t WHERE a < 0;
+DELETE FROM db.t ON CLUSTER c1 WHERE a < 0;
+DELETE FROM t ON CLUSTER '{cluster}' WHERE a < 0;
+DELETE FROM t WHERE a < 0 SETTINGS lightweight_deletes_sync = 0;
+DELETE FROM db.t ON CLUSTER '{cluster}' WHERE a < 0 SETTINGS lightweight_deletes_sync = 0, mutations_sync = 2
\ No newline at end of file
diff --git a/fuzz/fuzz_seeds/clickhouse_update.sql b/fuzz/fuzz_seeds/clickhouse_update.sql
new file mode 100644
index 00000000..256fd528
--- /dev/null
+++ b/fuzz/fuzz_seeds/clickhouse_update.sql
@@ -0,0 +1,5 @@
+UPDATE t SET a = a + 1 WHERE c IS NOT NULL;
+UPDATE db.t ON CLUSTER c1 SET a = a + 1 WHERE c IS NOT NULL;
+UPDATE t ON CLUSTER '{cluster}' SET a = a + 1, b = 'z' WHERE c IS NOT NULL;
+UPDATE t SET a = a + 1 WHERE c IS NOT NULL SETTINGS update_parallel_mode = 'auto';
+UPDATE db.t ON CLUSTER c1 SET a = a + 1, b = 'z' WHERE c IS NOT NULL SETTINGS update_parallel_mode = 'auto', mutations_sync = 2
\ No newline at end of file
diff --git a/src/ast/dml.rs b/src/ast/dml.rs
index c8d55ee6..f394db26 100644
--- a/src/ast/dml.rs
+++ b/src/ast/dml.rs
@@ -342,6 +342,8 @@ pub struct Delete {
pub tables: Vec<ObjectName>,
/// FROM
pub from: FromTable,
+ /// ClickHouse `ON CLUSTER`
+ pub on_cluster: Option<Ident>,
/// USING (Snowflake, Postgres, MySQL)
pub using: Option<Vec<TableWithJoins>>,
/// WHERE
@@ -355,6 +357,8 @@ pub struct Delete {
pub order_by: Vec<OrderByExpr>,
/// LIMIT (MySQL)
pub limit: Option<Expr>,
+ /// ClickHouse `SETTINGS`
+ pub settings: Option<Vec<Setting>>,
}
impl Display for Delete {
@@ -376,6 +380,9 @@ impl Display for Delete {
indented_list(f, from)?;
}
}
+ if let Some(on_cluster) = &self.on_cluster {
+ write!(f, " ON CLUSTER {on_cluster}")?;
+ }
if let Some(output) = &self.output {
SpaceOrNewline.fmt(f)?;
write!(f, "{output}")?;
@@ -407,6 +414,11 @@ impl Display for Delete {
SpaceOrNewline.fmt(f)?;
Indent(limit).fmt(f)?;
}
+ if let Some(settings) = &self.settings {
+ SpaceOrNewline.fmt(f)?;
+ f.write_str("SETTINGS")?;
+ indented_list(f, settings)?;
+ }
Ok(())
}
}
@@ -425,6 +437,8 @@ pub struct Update {
pub optimizer_hints: Vec<OptimizerHint>,
/// TABLE
pub table: TableWithJoins,
+ /// ClickHouse `ON CLUSTER`
+ pub on_cluster: Option<Ident>,
/// Column assignments
pub assignments: Vec<Assignment>,
/// Table which provide value to be set
@@ -443,6 +457,8 @@ pub struct Update {
pub order_by: Vec<OrderByExpr>,
/// LIMIT
pub limit: Option<Expr>,
+ /// ClickHouse `SETTINGS`
+ pub settings: Option<Vec<Setting>>,
}
impl Display for Update {
@@ -458,6 +474,9 @@ impl Display for Update {
f.write_str(" ")?;
}
self.table.fmt(f)?;
+ if let Some(on_cluster) = &self.on_cluster {
+ write!(f, " ON CLUSTER {on_cluster}")?;
+ }
if let Some(UpdateTableFromKind::BeforeSet(from)) = &self.from {
SpaceOrNewline.fmt(f)?;
f.write_str("FROM")?;
@@ -497,6 +516,11 @@ impl Display for Update {
SpaceOrNewline.fmt(f)?;
write!(f, "LIMIT {limit}")?;
}
+ if let Some(settings) = &self.settings {
+ SpaceOrNewline.fmt(f)?;
+ f.write_str("SETTINGS")?;
+ indented_list(f, settings)?;
+ }
Ok(())
}
}
diff --git a/src/ast/spans.rs b/src/ast/spans.rs
index 6c4a11c9..5800236f 100644
--- a/src/ast/spans.rs
+++ b/src/ast/spans.rs
@@ -44,10 +44,10 @@ use super::{
OnInsert, OpenStatement, OrderBy, OrderByExpr, OrderByKind, OutputClause, Parens, Partition,
PartitionBoundValue, PivotValueSource, ProjectionSelect, Query, RaiseStatement,
RaiseStatementValue, ReferentialAction, RenameSelectItem, ReplaceSelectElement,
- ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, SqlOption, Statement, Subscript,
- SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint, TableFactor, TableObject,
- TableOptionsClustered, TableWithJoins, Update, UpdateTableFromKind, Use, Values, ViewColumnDef,
- WhileStatement, WildcardAdditionalOptions, With, WithFill,
+ ReplaceSelectItem, Select, SelectInto, SelectItem, SetExpr, Setting, SqlOption, Statement,
+ Subscript, SymbolDefinition, TableAlias, TableAliasColumnDef, TableConstraint, TableFactor,
+ TableObject, TableOptionsClustered, TableWithJoins, Update, UpdateTableFromKind, Use, Values,
+ ViewColumnDef, WhileStatement, WildcardAdditionalOptions, With, WithFill,
};
/// Given an iterator of spans, return the [Span::union] of all spans.
@@ -944,12 +944,14 @@ impl Spanned for Delete {
optimizer_hints: _,
tables,
from,
+ on_cluster,
using,
selection,
returning,
output,
order_by,
limit,
+ settings,
} = self;
union_spans(
@@ -958,6 +960,7 @@ impl Spanned for Delete {
.iter()
.map(|i| i.span())
.chain(core::iter::once(from.span()))
+ .chain(on_cluster.iter().map(|i| i.span))
.chain(
using
.iter()
@@ -967,7 +970,8 @@ impl Spanned for Delete {
.chain(returning.iter().flat_map(|i| i.iter().map(|k| k.span())))
.chain(output.iter().map(|i| i.span()))
.chain(order_by.iter().map(|i| i.span()))
- .chain(limit.iter().map(|i| i.span())),
+ .chain(limit.iter().map(|i| i.span()))
+ .chain(settings.iter().flatten().map(|i| i.span())),
),
)
}
@@ -979,6 +983,7 @@ impl Spanned for Update {
update_token,
optimizer_hints: _,
table,
+ on_cluster,
assignments,
from,
selection,
@@ -987,18 +992,21 @@ impl Spanned for Update {
or: _,
order_by,
limit,
+ settings,
} = self;
union_spans(
core::iter::once(table.span())
.chain(core::iter::once(update_token.0.span))
+ .chain(on_cluster.iter().map(|i| i.span))
.chain(assignments.iter().map(|i| i.span()))
.chain(from.iter().map(|i| i.span()))
.chain(selection.iter().map(|i| i.span()))
.chain(returning.iter().flat_map(|i| i.iter().map(|k| k.span())))
.chain(output.iter().map(|i| i.span()))
.chain(order_by.iter().map(|i| i.span()))
- .chain(limit.iter().map(|i| i.span())),
+ .chain(limit.iter().map(|i| i.span()))
+ .chain(settings.iter().flatten().map(|i| i.span())),
)
}
}
@@ -1464,6 +1472,14 @@ impl Spanned for Assignment {
}
}
+impl Spanned for Setting {
+ fn span(&self) -> Span {
+ let Setting { key, value } = self;
+
+ key.span.union(&value.span())
+ }
+}
+
impl Spanned for AssignmentTarget {
fn span(&self) -> Span {
match self {
@@ -2903,6 +2919,37 @@ WHERE id = 1
assert_eq!(stmt_span.end, (4, 24).into());
}
+ #[test]
+ fn test_delete_on_cluster_settings_span() {
+ let sql = r#"DELETE FROM foo ON CLUSTER c1
+WHERE x = 42
+SETTINGS lightweight_deletes_sync = 0"#;
+
+ let r = Parser::parse_sql(&crate::dialect::ClickHouseDialect {}, sql).unwrap();
+ assert_eq!(1, r.len());
+
+ let stmt_span = r[0].span();
+
+ assert_eq!(stmt_span.start, (1, 1).into());
+ assert_eq!(stmt_span.end, (3, 38).into());
+ }
+
+ #[test]
+ fn test_update_on_cluster_settings_span() {
+ let sql = r#"UPDATE foo ON CLUSTER c1
+SET bar = 3
+WHERE x = 42
+SETTINGS update_parallel_mode = 'auto'"#;
+
+ let r = Parser::parse_sql(&crate::dialect::ClickHouseDialect {}, sql).unwrap();
+ assert_eq!(1, r.len());
+
+ let stmt_span = r[0].span();
+
+ assert_eq!(stmt_span.start, (1, 1).into());
+ assert_eq!(stmt_span.end, (4, 39).into());
+ }
+
#[test]
fn test_merge_statement_spans() {
let sql = r#"
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index 7649f68b..37e9997c 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -14640,6 +14640,7 @@ impl<'a> Parser<'a> {
};
let from = self.parse_comma_separated(Parser::parse_table_and_joins)?;
+ let on_cluster = self.parse_optional_on_cluster()?;
let output = self.maybe_parse_output_clause()?;
@@ -14668,6 +14669,7 @@ impl<'a> Parser<'a> {
} else {
None
};
+ let settings = self.parse_settings()?;
Ok(Statement::Delete(Delete {
delete_token: delete_token.into(),
@@ -14678,12 +14680,14 @@ impl<'a> Parser<'a> {
} else {
FromTable::WithoutKeyword(from)
},
+ on_cluster,
using,
selection,
returning,
output,
order_by,
limit,
+ settings,
}))
}
@@ -19026,6 +19030,7 @@ impl<'a> Parser<'a> {
let optimizer_hints = self.maybe_parse_optimizer_hints()?;
let or = self.parse_conflict_clause();
let table = self.parse_table_and_joins()?;
+ let on_cluster = self.parse_optional_on_cluster()?;
let from_before_set = if self.parse_keyword(Keyword::FROM) {
Some(UpdateTableFromKind::BeforeSet(
self.parse_table_with_joins()?,
@@ -19067,10 +19072,12 @@ impl<'a> Parser<'a> {
} else {
None
};
+ let settings = self.parse_settings()?;
Ok(Update {
update_token: update_token.into(),
optimizer_hints,
table,
+ on_cluster,
assignments,
from,
selection,
@@ -19079,6 +19086,7 @@ impl<'a> Parser<'a> {
or,
order_by,
limit,
+ settings,
}
.into())
}
diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs
index ff818811..f6b959bf 100644
--- a/tests/sqlparser_clickhouse.rs
+++ b/tests/sqlparser_clickhouse.rs
@@ -2203,6 +2203,133 @@ fn global_in_bare_table_binds_before_equality() {
));
}
+#[test]
+fn parse_delete_on_cluster_and_settings() {
+ clickhouse_and_generic().verified_stmt("DELETE FROM db.hits ON CLUSTER c1 WHERE x = 1");
+ clickhouse_and_generic().verified_stmt("DELETE FROM hits ON CLUSTER '{cluster}' WHERE x = 1");
+ clickhouse_and_generic()
+ .verified_stmt("DELETE FROM hits WHERE x = 1 SETTINGS lightweight_deletes_sync = 0");
+
+ match clickhouse_and_generic()
+ .verified_stmt("DELETE FROM hits ON CLUSTER c1 WHERE x = 1 SETTINGS a = 1, b = 'x'")
+ {
+ Statement::Delete(Delete {
+ on_cluster,
+ settings,
+ ..
+ }) => {
+ assert_eq!(on_cluster, Some(Ident::new("c1")));
+ assert_eq!(
+ settings,
+ Some(vec![
+ Setting {
+ key: Ident::new("a"),
+ value: Expr::value(number("1")),
+ },
+ Setting {
+ key: Ident::new("b"),
+ value: Expr::value(Value::SingleQuotedString("x".into())),
+ },
+ ])
+ );
+ }
+ _ => unreachable!(),
+ }
+
+ for (sql, err) in [
+ (
+ "DELETE FROM hits SETTINGS a = 1 WHERE x = 1",
+ "Expected: end of statement, found: WHERE",
+ ),
+ (
+ "DELETE FROM hits WHERE x = 1 ON CLUSTER c1",
+ "Expected: end of statement, found: ON",
+ ),
+ (
+ "DELETE FROM hits ON CLUSTER 1 WHERE x = 1",
+ "Expected: identifier, found: 1",
+ ),
+ (
+ "DELETE FROM hits WHERE x = 1 SETTINGS a",
+ "Expected: =, found: EOF",
+ ),
+ ] {
+ assert_eq!(
+ clickhouse_and_generic()
+ .parse_sql_statements(sql)
+ .unwrap_err(),
+ ParserError(err.to_string())
+ );
+ }
+ assert_eq!(
+ all_dialects_where(|d| !d.supports_settings())
+ .parse_sql_statements("DELETE FROM hits WHERE x = 1 SETTINGS a = 1")
+ .unwrap_err(),
+ ParserError("Expected: end of statement, found: SETTINGS".to_string())
+ );
+}
+
+#[test]
+fn parse_update_on_cluster_and_settings() {
+ clickhouse_and_generic().verified_stmt("UPDATE db.hits ON CLUSTER c1 SET x = 1 WHERE y = 2");
+ clickhouse_and_generic()
+ .verified_stmt("UPDATE hits ON CLUSTER '{cluster}' SET x = 1, z = 2 WHERE y = 2");
+ clickhouse_and_generic()
+ .verified_stmt("UPDATE hits SET x = 1 WHERE y = 2 SETTINGS update_parallel_mode = 'auto'");
+
+ match clickhouse_and_generic()
+ .verified_stmt("UPDATE hits ON CLUSTER c1 SET x = 1 WHERE y = 2 SETTINGS a = 1")
+ {
+ Statement::Update(Update {
+ on_cluster,
+ settings,
+ ..
+ }) => {
+ assert_eq!(on_cluster, Some(Ident::new("c1")));
+ assert_eq!(
+ settings,
+ Some(vec![Setting {
+ key: Ident::new("a"),
+ value: Expr::value(number("1")),
+ }])
+ );
+ }
+ _ => unreachable!(),
+ }
+
+ for (sql, err) in [
+ (
+ "UPDATE hits SET x = 1 ON CLUSTER c1 WHERE y = 2",
+ "Expected: end of statement, found: ON",
+ ),
+ (
+ "UPDATE hits SET x = 1 SETTINGS a = 1 WHERE y = 2",
+ "Expected: end of statement, found: WHERE",
+ ),
+ (
+ "UPDATE hits ON CLUSTER 1 SET x = 1 WHERE y = 2",
+ "Expected: identifier, found: 1",
+ ),
+ (
+ "UPDATE hits SET x = 1 WHERE y = 2 SETTINGS a",
+ "Expected: =, found: EOF",
+ ),
+ ] {
+ assert_eq!(
+ clickhouse_and_generic()
+ .parse_sql_statements(sql)
+ .unwrap_err(),
+ ParserError(err.to_string())
+ );
+ }
+ assert_eq!(
+ all_dialects_where(|d| !d.supports_settings())
+ .parse_sql_statements("UPDATE hits SET x = 1 WHERE y = 2 SETTINGS a = 1")
+ .unwrap_err(),
+ ParserError("Expected: end of statement, found: SETTINGS".to_string())
+ );
+}
+
fn clickhouse() -> TestedDialects {
TestedDialects::new(vec![Box::new(ClickHouseDialect {})])
}
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index ea542b50..f153a59d 100644
--- a/tests/sqlparser_common.rs
+++ b/tests/sqlparser_common.rs
@@ -489,6 +489,7 @@ fn parse_update_set_from() {
relation: table_from_name(ObjectName::from(vec![Ident::new("t1")])),
joins: vec![],
},
+ on_cluster: None,
assignments: vec![Assignment {
target: AssignmentTarget::ColumnName(ObjectName::from(vec![Ident::new("name")])),
value: Expr::CompoundIdentifier(vec![Ident::new("t2"), Ident::new("name")])
@@ -562,7 +563,8 @@ fn parse_update_set_from() {
output: None,
or: None,
order_by: vec![],
- limit: None
+ limit: None,
+ settings: None,
})
);
@@ -586,6 +588,8 @@ fn parse_update_with_table_alias() {
optimizer_hints,
update_token: _,
output: _,
+ on_cluster: None,
+ settings: None,
}) if optimizer_hints.is_empty() => {
assert_eq!(
TableWithJoins {
diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs
index 369d4d69..24e0cfb3 100644
--- a/tests/sqlparser_mysql.rs
+++ b/tests/sqlparser_mysql.rs
@@ -2767,6 +2767,8 @@ fn parse_update_with_joins() {
optimizer_hints,
update_token: _,
output: _,
+ on_cluster: None,
+ settings: None,
}) if optimizer_hints.is_empty() => {
assert_eq!(
TableWithJoins {
diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs
index b8fad7e9..35e9f81a 100644
--- a/tests/sqlparser_sqlite.rs
+++ b/tests/sqlparser_sqlite.rs
@@ -496,8 +496,10 @@ fn parse_update_tuple_row_values() {
relation: table_from_name(ObjectName::from(vec![Ident::new("x")])),
joins: vec![],
},
+ on_cluster: None,
from: None,
returning: None,
+ settings: None,
output: None,
order_by: vec![],
limit: None,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org