From 9f39cb83607d91fe3df78de593c61880d57ea9e6 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 13 Nov 2026 20:55:55 +0100 Subject: [PATCH] ClickHouse: Support time zone in DateTime data type --- fuzz/fuzz_seeds/clickhouse_datetime_zone.sql | 1 + src/ast/data_type.rs | 21 +++++-- src/parser/mod.rs | 29 +++++++++- tests/sqlparser_bigquery.rs | 8 +-- tests/sqlparser_clickhouse.rs | 59 +++++++++++++++++++- tests/sqlparser_common.rs | 2 +- tests/sqlparser_mysql.rs | 15 ++++- 7 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 fuzz/fuzz_seeds/clickhouse_datetime_zone.sql diff --git a/fuzz/fuzz_seeds/clickhouse_datetime_zone.sql b/fuzz/fuzz_seeds/clickhouse_datetime_zone.sql new file mode 100644 index 0000000000..01791d7fae --- /dev/null +++ b/fuzz/fuzz_seeds/clickhouse_datetime_zone.sql @@ -0,0 +1 @@ +CREATE TABLE t (a DateTime('UTC'), b DateTime(3, 'Europe/Rome')) ENGINE = MergeTree ORDER BY a diff --git a/src/ast/data_type.rs b/src/ast/data_type.rs index 164eb8d620..5d038fd88d 100644 --- a/src/ast/data_type.rs +++ b/src/ast/data_type.rs @@ -413,10 +413,11 @@ pub enum DataType { /// /// [1]: https://jakewheat.github.io/sql-overview/sql-2016-foundation-grammar.html#datetime-type Time(Option, TimezoneInfo), - /// Datetime with optional time precision, see [MySQL][1]. + /// Datetime with optional precision and time zone, see [MySQL][1] and [ClickHouse][2]. /// /// [1]: https://dev.mysql.com/doc/refman/8.0/en/datetime.html - Datetime(Option), + /// [2]: https://clickhouse.com/docs/sql-reference/data-types/datetime + Datetime(Option, Option), /// Datetime with time precision and optional timezone, see [ClickHouse][1]. /// /// [1]: https://clickhouse.com/docs/en/sql-reference/data-types/datetime64 @@ -743,8 +744,20 @@ impl fmt::Display for DataType { DataType::Time(precision, timezone_info) => { format_datetime_precision_and_tz(f, "TIME", precision, timezone_info) } - DataType::Datetime(precision) => { - format_type_with_optional_length(f, "DATETIME", precision, false) + DataType::Datetime(precision, time_zone) => { + write!(f, "DATETIME")?; + match (precision, time_zone) { + (None, None) => Ok(()), + (Some(precision), None) => write!(f, "({precision})"), + (None, Some(time_zone)) => { + write!(f, "('{}')", escape_single_quote_string(time_zone)) + } + (Some(precision), Some(time_zone)) => write!( + f, + "({precision}, '{}')", + escape_single_quote_string(time_zone) + ), + } } DataType::Timestamp(precision, timezone_info) => { format_datetime_precision_and_tz(f, "TIMESTAMP", precision, timezone_info) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 76bd114071..13e7a804d6 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13908,7 +13908,10 @@ impl<'a> Parser<'a> { Keyword::UUID => Ok(DataType::Uuid), Keyword::DATE => Ok(DataType::Date), Keyword::DATE32 => Ok(DataType::Date32), - Keyword::DATETIME => Ok(DataType::Datetime(self.parse_optional_precision()?)), + Keyword::DATETIME => { + let (precision, time_zone) = self.parse_optional_datetime_arguments()?; + Ok(DataType::Datetime(precision, time_zone)) + } Keyword::DATETIME64 => { self.prev_token(); let (precision, time_zone) = self.parse_datetime_64()?; @@ -15222,6 +15225,30 @@ impl<'a> Parser<'a> { Ok((precision, time_zone)) } + /// Parse the optional `(precision)`, `(time_zone)` or `(precision, time_zone)` + /// of a `DATETIME` type, see [ClickHouse]. + /// + /// [ClickHouse]: https://clickhouse.com/docs/sql-reference/data-types/datetime + fn parse_optional_datetime_arguments( + &mut self, + ) -> Result<(Option, Option), ParserError> { + if !self.consume_token(&Token::LParen) { + return Ok((None, None)); + } + let precision = if matches!(self.peek_token_ref().token, Token::Number(..)) { + Some(self.parse_literal_uint()?) + } else { + None + }; + let time_zone = if precision.is_none() || self.consume_token(&Token::Comma) { + Some(self.parse_literal_string()?) + } else { + None + }; + self.expect_token(&Token::RParen)?; + Ok((precision, time_zone)) + } + /// Parse an optional character length specification `(n | MAX [CHARACTERS|OCTETS])`. pub fn parse_optional_character_length( &mut self, diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index f26e591aac..3abd96a003 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -917,7 +917,7 @@ fn parse_typed_struct_syntax_bigquery() { assert_eq!( &Expr::Struct { values: vec![Expr::TypedString(TypedString { - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), value: ValueWithSpan { value: Value::SingleQuotedString("1999-01-01 01:23:34.45".into()), span: Span::empty(), @@ -926,7 +926,7 @@ fn parse_typed_struct_syntax_bigquery() { })], fields: vec![StructField { field_name: None, - field_type: DataType::Datetime(None), + field_type: DataType::Datetime(None, None), options: None, }] }, @@ -1259,7 +1259,7 @@ fn parse_typed_struct_syntax_bigquery_and_generic() { assert_eq!( &Expr::Struct { values: vec![Expr::TypedString(TypedString { - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), value: ValueWithSpan { value: Value::SingleQuotedString("1999-01-01 01:23:34.45".into()), span: Span::empty(), @@ -1268,7 +1268,7 @@ fn parse_typed_struct_syntax_bigquery_and_generic() { })], fields: vec![StructField { field_name: None, - field_type: DataType::Datetime(None), + field_type: DataType::Datetime(None, None), options: None, }] }, diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs index f4d637be69..f4bc038a98 100644 --- a/tests/sqlparser_clickhouse.rs +++ b/tests/sqlparser_clickhouse.rs @@ -842,7 +842,7 @@ fn parse_create_table_with_variant_default_expressions() { vec![ ColumnDef { name: Ident::new("a"), - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), options: vec![ColumnOptionDef { name: None, option: ColumnOption::Materialized(Expr::Function(Function { @@ -863,7 +863,7 @@ fn parse_create_table_with_variant_default_expressions() { }, ColumnDef { name: Ident::new("b"), - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), options: vec![ColumnOptionDef { name: None, option: ColumnOption::Ephemeral(Some(Expr::Function(Function { @@ -884,7 +884,7 @@ fn parse_create_table_with_variant_default_expressions() { }, ColumnDef { name: Ident::new("c"), - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), options: vec![ColumnOptionDef { name: None, option: ColumnOption::Ephemeral(None) @@ -2485,3 +2485,56 @@ fn parse_unterminated_dollar_tag_spans() { ] ); } + +#[test] +fn parse_datetime_precision_and_time_zone() { + for (data_type, expected) in [ + ("DATETIME", DataType::Datetime(None, None)), + ("DATETIME(3)", DataType::Datetime(Some(3), None)), + ( + "DATETIME('UTC')", + DataType::Datetime(None, Some("UTC".into())), + ), + ( + "DATETIME(3, 'Europe/Rome')", + DataType::Datetime(Some(3), Some("Europe/Rome".into())), + ), + ] { + let sql = format!("CREATE TABLE t (a {data_type}) ENGINE = MergeTree ORDER BY a"); + match clickhouse_and_generic().verified_stmt(&sql) { + Statement::CreateTable(CreateTable { columns, .. }) => { + assert_eq!(columns, vec![column_def("a".into(), expected)]); + } + _ => unreachable!(), + } + } + + clickhouse_and_generic().one_statement_parses_to( + "CREATE TABLE t (a Nullable(DateTime('Asia/Tokyo'))) ENGINE = MergeTree ORDER BY a", + "CREATE TABLE t (a Nullable(DATETIME('Asia/Tokyo'))) ENGINE = MergeTree ORDER BY a", + ); + + for (sql, error) in [ + ( + "SELECT CAST(a AS DATETIME())", + "Expected: literal string, found: )", + ), + ( + "SELECT CAST(a AS DATETIME(3, 4))", + "Expected: literal string, found: 4", + ), + ( + "SELECT CAST(a AS DATETIME('UTC', 3))", + "Expected: ), found: ,", + ), + ] { + assert_eq!( + clickhouse_and_generic() + .parse_sql_statements(sql) + .unwrap_err() + .to_string(), + format!("sql parser error: {error}"), + "{sql}" + ); + } +} diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index f2f7849154..222f738d19 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -7155,7 +7155,7 @@ fn parse_literal_datetime() { let select = verified_only_select(sql); assert_eq!( &Expr::TypedString(TypedString { - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), value: ValueWithSpan { value: Value::SingleQuotedString("1999-01-01 01:23:34.45".into()), span: Span::empty(), diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index d5ba2a9056..13f708152c 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -3546,7 +3546,7 @@ fn parse_table_column_option_on_update() { assert_eq!( vec![ColumnDef { name: Ident::with_quote('`', "modification_time"), - data_type: DataType::Datetime(None), + data_type: DataType::Datetime(None, None), options: vec![ColumnOptionDef { name: None, option: ColumnOption::OnUpdate(call("CURRENT_TIMESTAMP", [])), @@ -5247,3 +5247,16 @@ fn parse_key_constraint_index_type_before_columns() { ); } } + +#[test] +fn parse_datetime_precision() { + assert_eq!( + mysql_and_generic().verified_expr("CAST(a AS DATETIME(6))"), + Expr::Cast { + kind: CastKind::Cast, + expr: Box::new(Expr::Identifier(Ident::new("a"))), + data_type: DataType::Datetime(Some(6), None), + format: None, + } + ); +}