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