Repository navigation
Spark, Databricks: allow an interval string without a unit - #2614
moshap-firebolt wants to merge 4 commits into
Conversation
Spark's multi-units interval syntax lets the string carry its own units, as in INTERVAL '1 YEAR 2 DAYS 3 HOURS', so no unit follows the literal. Databricks Runtime parses with the same grammar. A unit after a literal is still read; the expression form this gives up (INTERVAL 1 + 1 DAY) is not Spark syntax.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2614 +/- ##
==========================================
+ Coverage 81.14% 81.22% +0.07%
==========================================
Files 42 42
Lines 33736 33936 +200
Branches 33736 33936 +200
==========================================
+ Hits 27376 27564 +188
+ Misses 2797 2795 -2
- Partials 3563 3577 +14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
LucaCappelletti94
left a comment
There was a problem hiding this comment.
You could gate unqualified intervals on string literals through a dialect capability, retaining qualifiers for numeric literals and excluding identifiers from literal values.
Setting require_interval_qualifier() to false makes both dialects accept SELECT INTERVAL 1, which Spark 4.0.1 rejects, and misparse SELECT interval FROM t into SELECT INTERVAL FROM AS t, losing the table reference.
I added some red tests to help out.
| Expr::Interval(i) => assert!(i.leading_field.is_none()), | ||
| other => panic!("Expected an interval, got {other:?}"), | ||
| } | ||
| } |
There was a problem hiding this comment.
| } | |
| } | |
| #[test] | |
| fn test_interval_numeric_requires_unit() { | |
| assert_eq!( | |
| spark() | |
| .parse_sql_statements("SELECT INTERVAL 1") | |
| .unwrap_err() | |
| .to_string(), | |
| "sql parser error: INTERVAL requires a unit after the literal value" | |
| ); | |
| } | |
| #[test] | |
| fn test_interval_rejects_value_arithmetic() { | |
| assert!(spark() | |
| .parse_sql_statements("SELECT INTERVAL 1 + 1 DAY") | |
| .is_err()); | |
| } | |
| #[test] | |
| fn test_interval_preserves_column_query() { | |
| let query = spark() | |
| .run_parser_method("SELECT interval FROM t", |parser| parser.parse_query()) | |
| .unwrap(); | |
| let SetExpr::Select(select) = *query.body else { | |
| panic!("Expected SELECT"); | |
| }; | |
| assert_eq!( | |
| select.from, | |
| vec![TableWithJoins { | |
| relation: table("t"), | |
| joins: vec![], | |
| }] | |
| ); | |
| assert_eq!( | |
| select.projection, | |
| vec![SelectItem::UnnamedExpr(Expr::Identifier(Ident::new( | |
| "interval" | |
| )))] | |
| ); | |
| } |
| Expr::Interval(i) => assert!(i.leading_field.is_none()), | ||
| other => panic!("Expected an interval, got {other:?}"), | ||
| } | ||
| } |
There was a problem hiding this comment.
| } | |
| } | |
| #[test] | |
| fn test_interval_numeric_requires_unit() { | |
| assert_eq!( | |
| databricks() | |
| .parse_sql_statements("SELECT INTERVAL 1") | |
| .unwrap_err() | |
| .to_string(), | |
| "sql parser error: INTERVAL requires a unit after the literal value" | |
| ); | |
| } | |
| #[test] | |
| fn test_interval_rejects_value_arithmetic() { | |
| assert!(databricks() | |
| .parse_sql_statements("SELECT INTERVAL 1 + 1 DAY") | |
| .is_err()); | |
| } | |
| #[test] | |
| fn test_interval_preserves_column_query() { | |
| let query = databricks() | |
| .run_parser_method("SELECT interval FROM t", |parser| parser.parse_query()) | |
| .unwrap(); | |
| let SetExpr::Select(select) = *query.body else { | |
| panic!("Expected SELECT"); | |
| }; | |
| assert_eq!( | |
| select.from, | |
| vec![TableWithJoins { | |
| relation: table("t"), | |
| joins: vec![], | |
| }] | |
| ); | |
| assert_eq!( | |
| select.projection, | |
| vec![SelectItem::UnnamedExpr(Expr::Identifier(Ident::new( | |
| "interval" | |
| )))] | |
| ); | |
| } |
Address review: turning require_interval_qualifier off accepted INTERVAL 1 and read SELECT interval FROM t as SELECT INTERVAL FROM AS t. Keep it on and add supports_interval_string_without_qualifier: the value must be a literal, and only an unsigned string may omit the qualifier. INTERVAL is not reserved for these dialects, so SELECT interval FROM t reads a column.
Right, so kept
Thanks - that was super helpful 🙏 |
| let interval_number = w.keyword == Keyword::INTERVAL | ||
| && match &self.peek_token_ref().token { | ||
| Token::Number(..) => true, | ||
| Token::Plus | Token::Minus => { | ||
| matches!(self.peek_nth_token_ref(1).token, Token::Number(..)) | ||
| } | ||
| _ => false, | ||
| }; |
There was a problem hiding this comment.
You should keep only the Token::Number arm. Spark 4.0.1 parses SELECT interval - 1 FROM t, SELECT interval + 1 FROM t and SELECT INTERVAL -1 as arithmetic on the column interval, but the sign arm rejects all three while interval * 2 parses. INTERVAL is non-reserved in Spark, so the grammar reads it as a column reference inside unary and binary arithmetic. Without the arm the identifier fallback parses them the way Spark does, and SELECT INTERVAL 1 keeps its error.
| let interval_number = w.keyword == Keyword::INTERVAL | |
| && match &self.peek_token_ref().token { | |
| Token::Number(..) => true, | |
| Token::Plus | Token::Minus => { | |
| matches!(self.peek_nth_token_ref(1).token, Token::Number(..)) | |
| } | |
| _ => false, | |
| }; | |
| let interval_number = w.keyword == Keyword::INTERVAL | |
| && matches!(self.peek_token_ref().token, Token::Number(..)); |
| !matches!( | ||
| &value, | ||
| Expr::Value(ValueWithSpan { | ||
| value: Value::SingleQuotedString(_), |
There was a problem hiding this comment.
You should let a double-quoted string omit the qualifier too, since Spark's stringLit includes DOUBLEQUOTED_STRING and INTERVAL stringLit takes no unit. Both the Spark and Databricks docs allow "..." as a string literal, and Spark 4.0.1 parses SELECT INTERVAL "2 months" as INTERVAL '2' MONTH.
| value: Value::SingleQuotedString(_), | |
| value: Value::SingleQuotedString(_) | Value::DoubleQuotedString(_), |
| Token::SingleQuotedString(s) => { | ||
| Expr::value(Value::SingleQuotedString(s).with_span(next.span)) | ||
| } |
There was a problem hiding this comment.
You should accept Token::DoubleQuotedString here, since as aforementioned Spark's intervalValue takes any stringLit. SELECT INTERVAL "1" DAY parses on main and in Spark 4.0.1 but fails on this branch. SELECT INTERVAL "2 months" now parses as SELECT INTERVAL AS "2 months", a column INTERVAL aliased "2 months".
| Token::SingleQuotedString(s) => { | |
| Expr::value(Value::SingleQuotedString(s).with_span(next.span)) | |
| } | |
| Token::SingleQuotedString(s) => { | |
| Expr::value(Value::SingleQuotedString(s).with_span(next.span)) | |
| } | |
| Token::DoubleQuotedString(s) => { | |
| Expr::value(Value::DoubleQuotedString(s).with_span(next.span)) | |
| } |
| "SELECT INTERVAL -'1' DAY", | ||
| "SELECT INTERVAL 3 DAY", | ||
| "SELECT INTERVAL -1 DAY, INTERVAL +2 HOURS", | ||
| "SELECT INTERVAL '2 seconds' * 2", |
There was a problem hiding this comment.
You could cover the double-quoted forms. Both lines fail on this branch and pass with the two changes above.
| "SELECT INTERVAL '2 seconds' * 2", | |
| "SELECT INTERVAL '2 seconds' * 2", | |
| "SELECT INTERVAL \"2 months\"", | |
| "SELECT INTERVAL \"1\" DAY", |
| ); | ||
| } | ||
| dialects.verified_stmt("SELECT interval, x FROM t"); | ||
| dialects.verified_stmt("SELECT max(interval) FROM t WHERE interval > 1"); |
There was a problem hiding this comment.
You could cover signed arithmetic on the column. The new line fails on this branch and passes with the guard change above.
| dialects.verified_stmt("SELECT max(interval) FROM t WHERE interval > 1"); | |
| dialects.verified_stmt("SELECT max(interval) FROM t WHERE interval > 1"); | |
| dialects.verified_stmt("SELECT interval - 1, interval + 1 FROM t"); |
There was a problem hiding this comment.
Added. Also RANGE BETWEEN '1 day' PRECEDING now remains string literal (added test)
…ter interval is arithmetic Address review: a double-quoted string is a Spark string literal, so it may be an interval value and may omit the qualifier (INTERVAL "2 months", INTERVAL "1" DAY). Only a bare number or a string after INTERVAL keeps the interval error; a signed number falls back to the identifier, so interval - 1 and INTERVAL -1 are arithmetic on the column, as in Spark. A string window frame bound stays a string literal in these dialects (RANGE BETWEEN '1 day' PRECEDING), as in Spark, rather than being read as an interval without a qualifier.
| .parse_sql_statements("SELECT INTERVAL '1' DAY TO") | ||
| .unwrap_err() | ||
| .to_string(), | ||
| "sql parser error: Expected: date/time field, found: EOF" |
There was a problem hiding this comment.
You should assert the error on SELECT INTERVAL '1' DAY TO x instead. Spark 4.0.1 accepts SELECT INTERVAL '1' DAY TO as INTERVAL '1' DAY AS TO, so this assertion pins a rejection of valid Spark SQL. TO x is a syntax error in Spark 4.0.1 too and still fails if the string arms of the INTERVAL guard are removed.
| .parse_sql_statements("SELECT INTERVAL '1' DAY TO") | |
| .unwrap_err() | |
| .to_string(), | |
| "sql parser error: Expected: date/time field, found: EOF" | |
| .parse_sql_statements("SELECT INTERVAL '1' DAY TO x") | |
| .unwrap_err() | |
| .to_string(), | |
| "sql parser error: Expected: date/time field, found: x" |
| #[test] | ||
| fn parse_interval_number_without_unit_error() { | ||
| for dialect in all_dialects().dialects { | ||
| if let Err(e) = Parser::parse_sql(&*dialect, "SELECT INTERVAL 1") { | ||
| assert_eq!( | ||
| e.to_string(), | ||
| "sql parser error: INTERVAL requires a unit after the literal value", | ||
| "{dialect:?}" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
You should drop this test. It skips every dialect that accepts SELECT INTERVAL 1, and for Spark and Databricks test_interval_numeric_requires_unit already asserts the same message.
| #[test] | |
| fn parse_interval_number_without_unit_error() { | |
| for dialect in all_dialects().dialects { | |
| if let Err(e) = Parser::parse_sql(&*dialect, "SELECT INTERVAL 1") { | |
| assert_eq!( | |
| e.to_string(), | |
| "sql parser error: INTERVAL requires a unit after the literal value", | |
| "{dialect:?}" | |
| ); | |
| } | |
| } | |
| } |
| #[test] | ||
| fn parse_interval_expression_value() { | ||
| let dialects = all_dialects_where(|d| { | ||
| d.require_interval_qualifier() && !d.supports_interval_string_without_qualifier() | ||
| }); | ||
| dialects.verified_stmt("SELECT INTERVAL 1 + 1 DAY"); | ||
| } |
There was a problem hiding this comment.
You should drop this test, since parse_interval_require_qualifier right below already runs verified_only_select on SELECT INTERVAL 1 + 1 DAY over the same dialects.
| #[test] | |
| fn parse_interval_expression_value() { | |
| let dialects = all_dialects_where(|d| { | |
| d.require_interval_qualifier() && !d.supports_interval_string_without_qualifier() | |
| }); | |
| dialects.verified_stmt("SELECT INTERVAL 1 + 1 DAY"); | |
| } |
Address review: Spark accepts INTERVAL '1' DAY TO as an alias, so assert the error on TO x instead. parse_interval_number_without_unit_error and parse_interval_expression_value repeat test_interval_numeric_requires_unit and parse_interval_require_qualifier. The guard comment now covers strings as well as numbers.
Spark's interval literal may be a string that carries its own units,
INTERVAL '2 months', whichSparkSqlDialectandDatabricksDialectreject withINTERVAL requires a unit after the literal value.This adds
Dialect::supports_interval_string_without_qualifier, enabled for both. The value must then be a literal: a number or a string, optionally signed. Only an unsigned string may omit the unit, soINTERVAL 1andINTERVAL 1 + 1 DAYstill fail, as in Spark. Double-quoted strings are string literals in Spark, soINTERVAL "2 months"andINTERVAL "1" DAYare intervals too.INTERVALis non-reserved in Spark, so both dialects now read it as a column name when no literal follows:SELECT interval FROM t,interval - 1,INTERVAL -'2 months'. A string window frame bound (RANGE BETWEEN '1 day' PRECEDING) stays a string literal, as in Spark.