From 9428126123551d5f05b1558bea61f905325f9b3b Mon Sep 17 00:00:00 2001 From: Mosha Pasumansky Date: Mon, 5 Oct 2026 15:35:37 -0700 Subject: [PATCH 1/4] Spark, Databricks: read multi-units interval literals Spark's multi-units syntax lists several value / unit pairs, INTERVAL 10 YEAR 20 MONTH, and documents it as the same thing as the string form INTERVAL '10 YEAR 20 MONTH'. A new dialect hook, supports_interval_multi_units, reads the pairs into that string form; a single unit, or a unit followed by anything else, parses as before. --- src/dialect/databricks.rs | 5 ++++ src/dialect/mod.rs | 7 ++++++ src/dialect/spark.rs | 5 ++++ src/parser/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++ tests/sqlparser_spark.rs | 23 ++++++++++++++++++ 5 files changed, 90 insertions(+) diff --git a/src/dialect/databricks.rs b/src/dialect/databricks.rs index 1ca58ed1a7..324a45ec39 100644 --- a/src/dialect/databricks.rs +++ b/src/dialect/databricks.rs @@ -114,6 +114,11 @@ impl Dialect for DatabricksDialect { false } + /// See + fn supports_interval_multi_units(&self) -> bool { + true + } + // See https://docs.databricks.com/en/sql/language-manual/functions/struct.html fn supports_struct_literal(&self) -> bool { true diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 801c5351bd..3ea13b37ce 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1182,6 +1182,13 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if an interval literal may list several value / unit pairs, + /// as Spark's multi-units syntax does: `INTERVAL 10 YEAR 20 MONTH` is read + /// as `INTERVAL '10 YEAR 20 MONTH'`. + fn supports_interval_multi_units(&self) -> bool { + false + } + /// Returns true if the dialect supports `EXPLAIN` statements with utility options /// e.g. `EXPLAIN (ANALYZE TRUE, BUFFERS TRUE) SELECT * FROM tbl;` fn supports_explain_with_utility_options(&self) -> bool { diff --git a/src/dialect/spark.rs b/src/dialect/spark.rs index b3be7c9b07..3a1d2bd1ef 100644 --- a/src/dialect/spark.rs +++ b/src/dialect/spark.rs @@ -142,6 +142,11 @@ impl Dialect for SparkSqlDialect { false } + /// See + fn supports_interval_multi_units(&self) -> bool { + true + } + fn supports_bang_not_operator(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4f6c77077c..d2f97e40e5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3444,6 +3444,39 @@ impl<'a> Parser<'a> { None }; + // Spark's multi-units form, `INTERVAL 10 YEAR 20 MONTH`, means the same + // as the string form `INTERVAL '10 YEAR 20 MONTH'`, which is what it is + // read as. + if self.dialect.supports_interval_multi_units() { + if let (Some(first_value), Some(first_unit)) = + (Self::interval_unit_value(&value), leading_field.as_ref()) + { + let mut text = format!("{first_value} {first_unit}"); + let mut more = false; + while let Some((v, unit)) = self.maybe_parse(|p| { + let v = p.parse_prefix()?; + let v = Self::interval_unit_value(&v) + .ok_or_else(|| ParserError::ParserError("not an interval value".into()))?; + if !p.next_token_is_temporal_unit() { + return p.expected_ref("an interval unit", p.peek_token_ref()); + } + Ok((v, p.parse_date_time_field()?)) + })? { + text.push_str(&format!(" {v} {unit}")); + more = true; + } + if more { + return Ok(Expr::Interval(Interval { + value: Box::new(Expr::value(Value::SingleQuotedString(text))), + leading_field: None, + leading_precision: None, + last_field: None, + fractional_seconds_precision: None, + })); + } + } + } + let (leading_precision, last_field, fsec_precision) = if leading_field == Some(DateTimeField::Second) { // SQL mandates special syntax for `SECOND TO SECOND` literals. @@ -3478,6 +3511,23 @@ impl<'a> Parser<'a> { })) } + /// The text of one multi-units interval value: a number or string literal, + /// optionally negated. + fn interval_unit_value(value: &Expr) -> Option { + match value { + Expr::Value(v) => match &v.value { + Value::Number(n, _) => Some(n.to_string()), + Value::SingleQuotedString(s) => Some(s.clone()), + _ => None, + }, + Expr::UnaryOp { + op: UnaryOperator::Minus, + expr, + } => Self::interval_unit_value(expr).map(|v| format!("-{v}")), + _ => None, + } + } + /// Peek at the next token and determine if it is a temporal unit /// like `second`. pub fn next_token_is_temporal_unit(&mut self) -> bool { diff --git a/tests/sqlparser_spark.rs b/tests/sqlparser_spark.rs index c88fb17d37..aa37f69001 100644 --- a/tests/sqlparser_spark.rs +++ b/tests/sqlparser_spark.rs @@ -545,3 +545,26 @@ fn test_interval_literals() { other => panic!("Expected an interval, got {other:?}"), } } + +#[test] +fn test_interval_multi_units() { + spark().one_statement_parses_to( + "SELECT INTERVAL 10 YEAR 20 MONTH", + "SELECT INTERVAL '10 YEAR 20 MONTH'", + ); + spark().one_statement_parses_to( + "SELECT INTERVAL 1 DAY -2 HOURS '30' MINUTE", + "SELECT INTERVAL '1 DAY -2 HOURS 30 MINUTE'", + ); + spark().one_statement_parses_to( + "SELECT INTERVAL -1 DAY 2 HOURS AS i", + "SELECT INTERVAL '-1 DAY 2 HOURS' AS i", + ); + // A single unit, a unit followed by something else, and a qualifier are + // left as they are. + spark().verified_stmt("SELECT INTERVAL 3 DAY"); + spark().verified_stmt("SELECT INTERVAL 3 DAY + 1"); + spark().verified_stmt("SELECT INTERVAL '1-2' YEAR TO MONTH"); + spark().verified_stmt("SELECT d + INTERVAL 1 DAY FROM t"); + spark().verified_stmt("SELECT INTERVAL 1 DAY, 2 FROM t"); +} From 4eb5482963af9ec9319d461d0d86297dedc0ed01 Mon Sep 17 00:00:00 2001 From: Mosha Pasumansky Date: Mon, 5 Oct 2026 21:26:00 -0700 Subject: [PATCH 2/4] Accept plus-signed multi-units interval values; keep the shared docs dialect-neutral A value in a multi-units interval may carry a sign; only minus was read, so INTERVAL +1 DAY +2 HOURS rolled back to a binary addition and failed. The supports_interval_multi_units doc and the parser comment no longer name a dialect, and the doc notes the form is not ANSI SQL. --- src/dialect/mod.rs | 5 +++-- src/parser/mod.rs | 10 +++++++--- tests/sqlparser_spark.rs | 4 ++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 3ea13b37ce..5869be4002 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1183,8 +1183,9 @@ pub trait Dialect: Debug + Any { } /// Returns true if an interval literal may list several value / unit pairs, - /// as Spark's multi-units syntax does: `INTERVAL 10 YEAR 20 MONTH` is read - /// as `INTERVAL '10 YEAR 20 MONTH'`. + /// `INTERVAL 10 YEAR 20 MONTH`, read as the string form + /// `INTERVAL '10 YEAR 20 MONTH'`. Not part of ANSI SQL, whose interval + /// literal takes one quoted value and a single qualifier. fn supports_interval_multi_units(&self) -> bool { false } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index d2f97e40e5..a1adfe59bc 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3444,8 +3444,8 @@ impl<'a> Parser<'a> { None }; - // Spark's multi-units form, `INTERVAL 10 YEAR 20 MONTH`, means the same - // as the string form `INTERVAL '10 YEAR 20 MONTH'`, which is what it is + // The multi-units form, `INTERVAL 10 YEAR 20 MONTH`, means the same as + // the string form `INTERVAL '10 YEAR 20 MONTH'`, which is what it is // read as. if self.dialect.supports_interval_multi_units() { if let (Some(first_value), Some(first_unit)) = @@ -3512,7 +3512,7 @@ impl<'a> Parser<'a> { } /// The text of one multi-units interval value: a number or string literal, - /// optionally negated. + /// optionally signed. fn interval_unit_value(value: &Expr) -> Option { match value { Expr::Value(v) => match &v.value { @@ -3524,6 +3524,10 @@ impl<'a> Parser<'a> { op: UnaryOperator::Minus, expr, } => Self::interval_unit_value(expr).map(|v| format!("-{v}")), + Expr::UnaryOp { + op: UnaryOperator::Plus, + expr, + } => Self::interval_unit_value(expr), _ => None, } } diff --git a/tests/sqlparser_spark.rs b/tests/sqlparser_spark.rs index aa37f69001..364c2110ed 100644 --- a/tests/sqlparser_spark.rs +++ b/tests/sqlparser_spark.rs @@ -560,6 +560,10 @@ fn test_interval_multi_units() { "SELECT INTERVAL -1 DAY 2 HOURS AS i", "SELECT INTERVAL '-1 DAY 2 HOURS' AS i", ); + spark().one_statement_parses_to( + "SELECT INTERVAL +1 DAY +2 HOURS", + "SELECT INTERVAL '1 DAY 2 HOURS'", + ); // A single unit, a unit followed by something else, and a qualifier are // left as they are. spark().verified_stmt("SELECT INTERVAL 3 DAY"); From fa0071250416541186a82f6a12889f5045f8fae6 Mon Sep 17 00:00:00 2001 From: Mosha Pasumansky Date: Tue, 6 Oct 2026 11:03:45 -0700 Subject: [PATCH 3/4] Trim the supports_interval_multi_units doc to the example --- src/dialect/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 5869be4002..dfd0a0fba0 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1183,9 +1183,7 @@ pub trait Dialect: Debug + Any { } /// Returns true if an interval literal may list several value / unit pairs, - /// `INTERVAL 10 YEAR 20 MONTH`, read as the string form - /// `INTERVAL '10 YEAR 20 MONTH'`. Not part of ANSI SQL, whose interval - /// literal takes one quoted value and a single qualifier. + /// e.g. `INTERVAL 10 YEAR 20 MONTH`. fn supports_interval_multi_units(&self) -> bool { false } From 479e11fe0861efaa37e9b0289cdd41d70964f23f Mon Sep 17 00:00:00 2001 From: Mosha Pasumansky Date: Tue, 6 Oct 2026 11:31:48 -0700 Subject: [PATCH 4/4] Test multi-units intervals in the common suite, including dialects without the hook Hook-gated syntax is tested across all dialects in sqlparser_common.rs: the multi-units forms parse for every dialect that sets supports_interval_multi_units, and every other dialect rejects them while still parsing a single-unit interval. --- tests/sqlparser_common.rs | 42 +++++++++++++++++++++++++++++++++++++++ tests/sqlparser_spark.rs | 27 ------------------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index ef800256d3..31524da26e 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -6763,6 +6763,48 @@ fn parse_interval_require_unit() { ) } +#[test] +fn parse_interval_multi_units() { + let dialects = all_dialects_where(|d| d.supports_interval_multi_units()); + dialects.one_statement_parses_to( + "SELECT INTERVAL 10 YEAR 20 MONTH", + "SELECT INTERVAL '10 YEAR 20 MONTH'", + ); + dialects.one_statement_parses_to( + "SELECT INTERVAL 1 DAY -2 HOURS '30' MINUTE", + "SELECT INTERVAL '1 DAY -2 HOURS 30 MINUTE'", + ); + dialects.one_statement_parses_to( + "SELECT INTERVAL -1 DAY 2 HOURS AS i", + "SELECT INTERVAL '-1 DAY 2 HOURS' AS i", + ); + dialects.one_statement_parses_to( + "SELECT INTERVAL +1 DAY +2 HOURS", + "SELECT INTERVAL '1 DAY 2 HOURS'", + ); + // A single unit, or a unit followed by something else, is left as it is. + dialects.verified_stmt("SELECT INTERVAL 3 DAY"); + dialects.verified_stmt("SELECT INTERVAL 3 DAY + 1"); + dialects.verified_stmt("SELECT d + INTERVAL 1 DAY FROM t"); + dialects.verified_stmt("SELECT INTERVAL 1 DAY, 2 FROM t"); +} + +#[test] +fn parse_interval_multi_units_unsupported() { + let dialects = all_dialects_where(|d| !d.supports_interval_multi_units()); + for sql in [ + "SELECT INTERVAL 10 YEAR 20 MONTH", + "SELECT INTERVAL 1 DAY 2 HOURS", + ] { + assert!( + dialects.parse_sql_statements(sql).is_err(), + "{sql} should not parse without supports_interval_multi_units" + ); + } + // A single unit still parses. + dialects.verified_stmt("SELECT INTERVAL 1 DAY"); +} + #[test] fn parse_interval_require_qualifier() { let dialects = all_dialects_where(|d| d.require_interval_qualifier()); diff --git a/tests/sqlparser_spark.rs b/tests/sqlparser_spark.rs index 364c2110ed..c88fb17d37 100644 --- a/tests/sqlparser_spark.rs +++ b/tests/sqlparser_spark.rs @@ -545,30 +545,3 @@ fn test_interval_literals() { other => panic!("Expected an interval, got {other:?}"), } } - -#[test] -fn test_interval_multi_units() { - spark().one_statement_parses_to( - "SELECT INTERVAL 10 YEAR 20 MONTH", - "SELECT INTERVAL '10 YEAR 20 MONTH'", - ); - spark().one_statement_parses_to( - "SELECT INTERVAL 1 DAY -2 HOURS '30' MINUTE", - "SELECT INTERVAL '1 DAY -2 HOURS 30 MINUTE'", - ); - spark().one_statement_parses_to( - "SELECT INTERVAL -1 DAY 2 HOURS AS i", - "SELECT INTERVAL '-1 DAY 2 HOURS' AS i", - ); - spark().one_statement_parses_to( - "SELECT INTERVAL +1 DAY +2 HOURS", - "SELECT INTERVAL '1 DAY 2 HOURS'", - ); - // A single unit, a unit followed by something else, and a qualifier are - // left as they are. - spark().verified_stmt("SELECT INTERVAL 3 DAY"); - spark().verified_stmt("SELECT INTERVAL 3 DAY + 1"); - spark().verified_stmt("SELECT INTERVAL '1-2' YEAR TO MONTH"); - spark().verified_stmt("SELECT d + INTERVAL 1 DAY FROM t"); - spark().verified_stmt("SELECT INTERVAL 1 DAY, 2 FROM t"); -}