From 56c476d93141666eb59eccd58bac12df6c395895 Mon Sep 17 00:00:00 2001 From: Prince Jain Date: Wed, 21 Oct 2026 09:33:47 +0530 Subject: [PATCH] MySQL: Support compound time units in EXTRACT and INTERVAL --- .../mysql_2693_compound_datetime_units.sql | 1 + .../mysql_2693_compound_interval_units.sql | 1 + src/ast/value.rs | 33 ++++++++++++ src/dialect/generic.rs | 4 ++ src/dialect/mod.rs | 9 ++++ src/dialect/mysql.rs | 5 ++ src/keywords.rs | 11 ++++ src/parser/mod.rs | 53 ++++++++++++++++++- tests/sqlparser_mysql.rs | 52 ++++++++++++++++++ 9 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 fuzz/fuzz_seeds/mysql_2693_compound_datetime_units.sql create mode 100644 fuzz/fuzz_seeds/mysql_2693_compound_interval_units.sql diff --git a/fuzz/fuzz_seeds/mysql_2693_compound_datetime_units.sql b/fuzz/fuzz_seeds/mysql_2693_compound_datetime_units.sql new file mode 100644 index 0000000000..24628b11f3 --- /dev/null +++ b/fuzz/fuzz_seeds/mysql_2693_compound_datetime_units.sql @@ -0,0 +1 @@ +SELECT EXTRACT(YEAR_MONTH FROM d), DATE_ADD(d, INTERVAL '1-2' YEAR_MONTH), d + INTERVAL '1 2' DAY_HOUR FROM t diff --git a/fuzz/fuzz_seeds/mysql_2693_compound_interval_units.sql b/fuzz/fuzz_seeds/mysql_2693_compound_interval_units.sql new file mode 100644 index 0000000000..ff8ec9a945 --- /dev/null +++ b/fuzz/fuzz_seeds/mysql_2693_compound_interval_units.sql @@ -0,0 +1 @@ +SELECT DATE_ADD(d, INTERVAL '1-2' YEAR_MONTH) FROM t diff --git a/src/ast/value.rs b/src/ast/value.rs index 5b05af694c..eb2ef8806e 100644 --- a/src/ast/value.rs +++ b/src/ast/value.rs @@ -441,6 +441,28 @@ pub enum DateTimeField { TimezoneMinute, /// `TIMEZONE_REGION` TimezoneRegion, + /// `SECOND_MICROSECOND` (MySQL) + SecondMicrosecond, + /// `MINUTE_MICROSECOND` (MySQL) + MinuteMicrosecond, + /// `MINUTE_SECOND` (MySQL) + MinuteSecond, + /// `HOUR_MICROSECOND` (MySQL) + HourMicrosecond, + /// `HOUR_SECOND` (MySQL) + HourSecond, + /// `HOUR_MINUTE` (MySQL) + HourMinute, + /// `DAY_MICROSECOND` (MySQL) + DayMicrosecond, + /// `DAY_SECOND` (MySQL) + DaySecond, + /// `DAY_MINUTE` (MySQL) + DayMinute, + /// `DAY_HOUR` (MySQL) + DayHour, + /// `YEAR_MONTH` (MySQL) + YearMonth, /// `NODATETIME` indicates no date/time part NoDateTime, /// Arbitrary abbreviation or custom date-time part. @@ -503,6 +525,17 @@ impl fmt::Display for DateTimeField { DateTimeField::TimezoneHour => write!(f, "TIMEZONE_HOUR"), DateTimeField::TimezoneMinute => write!(f, "TIMEZONE_MINUTE"), DateTimeField::TimezoneRegion => write!(f, "TIMEZONE_REGION"), + DateTimeField::SecondMicrosecond => write!(f, "SECOND_MICROSECOND"), + DateTimeField::MinuteMicrosecond => write!(f, "MINUTE_MICROSECOND"), + DateTimeField::MinuteSecond => write!(f, "MINUTE_SECOND"), + DateTimeField::HourMicrosecond => write!(f, "HOUR_MICROSECOND"), + DateTimeField::HourSecond => write!(f, "HOUR_SECOND"), + DateTimeField::HourMinute => write!(f, "HOUR_MINUTE"), + DateTimeField::DayMicrosecond => write!(f, "DAY_MICROSECOND"), + DateTimeField::DaySecond => write!(f, "DAY_SECOND"), + DateTimeField::DayMinute => write!(f, "DAY_MINUTE"), + DateTimeField::DayHour => write!(f, "DAY_HOUR"), + DateTimeField::YearMonth => write!(f, "YEAR_MONTH"), DateTimeField::NoDateTime => write!(f, "NODATETIME"), DateTimeField::Custom(custom) => write!(f, "{custom}"), } diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 9c1dc08b3a..fa607bd5bc 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -117,6 +117,10 @@ impl Dialect for GenericDialect { true } + fn supports_compound_date_time_fields(&self) -> bool { + true + } + fn allow_extract_single_quotes(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index a2920e61eb..8a070dc5cd 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1107,6 +1107,15 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if this dialect supports compound date and time units + /// such as `YEAR_MONTH`, `DAY_HOUR` or `SECOND_MICROSECOND`, e.g. in + /// `EXTRACT(YEAR_MONTH FROM d)` or `INTERVAL '1-2' YEAR_MONTH`. + /// + /// [MySQL](https://dev.mysql.com/doc/refman/8.4/en/expressions.html#temporal-intervals) + fn supports_compound_date_time_fields(&self) -> bool { + false + } + /// Returns true if this dialect allows the `EXTRACT` function to use single quotes in the part being extracted. fn allow_extract_single_quotes(&self) -> bool { false diff --git a/src/dialect/mysql.rs b/src/dialect/mysql.rs index c5330d8eb5..8745c65cfe 100644 --- a/src/dialect/mysql.rs +++ b/src/dialect/mysql.rs @@ -142,6 +142,11 @@ impl Dialect for MySqlDialect { true } + /// See: + fn supports_compound_date_time_fields(&self) -> bool { + true + } + fn supports_limit_comma(&self) -> bool { true } diff --git a/src/keywords.rs b/src/keywords.rs index 7e93822416..4982a37fb0 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -305,6 +305,10 @@ define_keywords!( DAYOFWEEK, DAYOFYEAR, DAYS, + DAY_HOUR, + DAY_MICROSECOND, + DAY_MINUTE, + DAY_SECOND, DCPROPERTIES, DEALLOCATE, DEC, @@ -497,6 +501,9 @@ define_keywords!( HOSTS, HOUR, HOURS, + HOUR_MICROSECOND, + HOUR_MINUTE, + HOUR_SECOND, HUGEINT, IAM_ROLE, ICEBERG, @@ -660,6 +667,8 @@ define_keywords!( MINUS, MINUTE, MINUTES, + MINUTE_MICROSECOND, + MINUTE_SECOND, MINVALUE, MIN_ROWS, MOD, @@ -936,6 +945,7 @@ define_keywords!( SECONDARY, SECONDARY_ENGINE_ATTRIBUTE, SECONDS, + SECOND_MICROSECOND, SECRET, SECURE, SECURITY, @@ -1193,6 +1203,7 @@ define_keywords!( XOR, YEAR, YEARS, + YEAR_MONTH, YES, ZEROFILL, ZONE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 276b252ed2..25d6f56ca1 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3338,6 +3338,43 @@ impl<'a> Parser<'a> { Keyword::TIMEZONE_HOUR => Ok(DateTimeField::TimezoneHour), Keyword::TIMEZONE_MINUTE => Ok(DateTimeField::TimezoneMinute), Keyword::TIMEZONE_REGION => Ok(DateTimeField::TimezoneRegion), + Keyword::SECOND_MICROSECOND + if self.dialect.supports_compound_date_time_fields() => + { + Ok(DateTimeField::SecondMicrosecond) + } + Keyword::MINUTE_MICROSECOND + if self.dialect.supports_compound_date_time_fields() => + { + Ok(DateTimeField::MinuteMicrosecond) + } + Keyword::MINUTE_SECOND if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::MinuteSecond) + } + Keyword::HOUR_MICROSECOND if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::HourMicrosecond) + } + Keyword::HOUR_SECOND if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::HourSecond) + } + Keyword::HOUR_MINUTE if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::HourMinute) + } + Keyword::DAY_MICROSECOND if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::DayMicrosecond) + } + Keyword::DAY_SECOND if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::DaySecond) + } + Keyword::DAY_MINUTE if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::DayMinute) + } + Keyword::DAY_HOUR if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::DayHour) + } + Keyword::YEAR_MONTH if self.dialect.supports_compound_date_time_fields() => { + Ok(DateTimeField::YearMonth) + } _ if self.dialect.allow_extract_custom() => { self.prev_token(); let custom = self.parse_identifier()?; @@ -3579,7 +3616,21 @@ impl<'a> Parser<'a> { | Keyword::TIMEZONE | Keyword::TIMEZONE_HOUR | Keyword::TIMEZONE_MINUTE - ) + ) || (self.dialect.supports_compound_date_time_fields() + && matches!( + word.keyword, + Keyword::SECOND_MICROSECOND + | Keyword::MINUTE_MICROSECOND + | Keyword::MINUTE_SECOND + | Keyword::HOUR_MICROSECOND + | Keyword::HOUR_SECOND + | Keyword::HOUR_MINUTE + | Keyword::DAY_MICROSECOND + | Keyword::DAY_SECOND + | Keyword::DAY_MINUTE + | Keyword::DAY_HOUR + | Keyword::YEAR_MONTH + )) } else { false } diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 8baa83b6bb..9d01cf13da 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -5202,3 +5202,55 @@ fn parse_create_table_engine_name_quoting() { mysql_and_generic().verified_stmt("CREATE TABLE t (a INT) ENGINE = `InnoDB`"); mysql_and_generic().verified_stmt("CREATE TABLE t (a INT) ENGINE = InnoDB"); } + +#[test] +fn parse_compound_date_time_units() { + // https://dev.mysql.com/doc/refman/8.4/en/expressions.html#temporal-intervals + for (unit, expected_field) in [ + ("SECOND_MICROSECOND", DateTimeField::SecondMicrosecond), + ("MINUTE_MICROSECOND", DateTimeField::MinuteMicrosecond), + ("MINUTE_SECOND", DateTimeField::MinuteSecond), + ("HOUR_MICROSECOND", DateTimeField::HourMicrosecond), + ("HOUR_SECOND", DateTimeField::HourSecond), + ("HOUR_MINUTE", DateTimeField::HourMinute), + ("DAY_MICROSECOND", DateTimeField::DayMicrosecond), + ("DAY_SECOND", DateTimeField::DaySecond), + ("DAY_MINUTE", DateTimeField::DayMinute), + ("DAY_HOUR", DateTimeField::DayHour), + ("YEAR_MONTH", DateTimeField::YearMonth), + ] { + assert_eq!( + mysql_and_generic().verified_expr(&format!("EXTRACT({unit} FROM d)")), + Expr::Extract { + field: expected_field.clone(), + syntax: ExtractSyntax::From, + expr: Box::new(Expr::Identifier(Ident::new("d"))), + }, + ); + assert_eq!( + mysql_and_generic().verified_expr(&format!("INTERVAL '1-2' {unit}")), + Expr::Interval(Interval { + value: Box::new(Expr::Value( + Value::SingleQuotedString("1-2".into()).with_empty_span() + )), + leading_field: Some(expected_field), + leading_precision: None, + last_field: None, + fractional_seconds_precision: None, + }), + ); + mysql_and_generic().verified_stmt(&format!( + "SELECT DATE_ADD(d, INTERVAL '1-2' {unit}), d + INTERVAL '1-2' {unit} FROM t" + )); + } + + let dialects = all_dialects_where(|d| { + !d.supports_compound_date_time_fields() && !d.allow_extract_custom() + }); + assert_eq!( + dialects.parse_sql_statements("SELECT EXTRACT(YEAR_MONTH FROM d) FROM t"), + Err(ParserError::ParserError( + "Expected: date/time field, found: YEAR_MONTH".to_string() + )) + ); +}