From 5945e32fa196ab956d686a93fc0bfd34b6cd62a2 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Tue, 13 Jan 2026 21:27:13 +0100 Subject: [PATCH 1/7] [MySQL, Oracle] Parse optimizer hints for SELECTs --- src/ast/mod.rs | 51 +++++++++++++++++++++++++++++ src/ast/query.rs | 10 ++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 61 +++++++++++++++++++++++++++++++++++ tests/sqlparser_bigquery.rs | 2 ++ tests/sqlparser_clickhouse.rs | 1 + tests/sqlparser_common.rs | 10 ++++++ tests/sqlparser_duckdb.rs | 2 ++ tests/sqlparser_mssql.rs | 3 ++ tests/sqlparser_mysql.rs | 26 +++++++++++++-- tests/sqlparser_oracle.rs | 35 ++++++++++++++++++++ tests/sqlparser_postgres.rs | 3 ++ 12 files changed, 202 insertions(+), 3 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d77186bc7..215b1f2af 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -11666,6 +11666,57 @@ pub struct ResetStatement { pub reset: Reset, } +/// Query optimizer hints are optionally supported comments after the +/// `SELECT`, `INSERT`, `UPDATE`, `REPLACE`, `MERGE`, and `DELETE` keywords in +/// the corresponding statements. +/// +/// See [Select::optimizer_hint] +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct OptimizerHint { + /// the raw test of the optimizer hint without its markers + pub text: String, + /// the style of the comment which `text` was extracted from, + /// e.g. `/*+...*/` or `--+...` + /// + /// Not all dialects support all styles, though. + pub style: OptimizerHintStyle, +} + +/// The commentary style of an [optimizer hint](OptimizerHint) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum OptimizerHintStyle { + /// A hint corresponding to a single line comment, + /// e.g. `--+ LEADING(v.e v.d t)` + SingleLine { + /// the comment prefix, e.g. `--` + prefix: String, + }, + /// A hint corresponding to a multi line comment, + /// e.g. `/*+ LEADING(v.e v.d t) */` + MultiLine, +} + +impl fmt::Display for OptimizerHint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.style { + OptimizerHintStyle::SingleLine { prefix } => { + f.write_str(prefix)?; + f.write_str("+")?; + f.write_str(&self.text) + } + OptimizerHintStyle::MultiLine => { + f.write_str("/*+")?; + f.write_str(&self.text)?; + f.write_str("*/") + } + } + } +} + impl fmt::Display for ResetStatement { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match &self.reset { diff --git a/src/ast/query.rs b/src/ast/query.rs index a1fc33b6a..a79bb07b8 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -343,6 +343,11 @@ pub enum SelectFlavor { pub struct Select { /// Token for the `SELECT` keyword pub select_token: AttachedToken, + /// A query optimizer hint + /// + /// [MySQL](https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html) + /// [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Comments.html#GUID-D316D545-89E2-4D54-977F-FC97815CD62E) + pub optimizer_hint: Option, /// `SELECT [DISTINCT] ...` pub distinct: Option, /// MSSQL syntax: `TOP () [ PERCENT ] [ WITH TIES ]` @@ -410,6 +415,11 @@ impl fmt::Display for Select { } } + if let Some(hint) = self.optimizer_hint.as_ref() { + f.write_str(" ")?; + hint.fmt(f)?; + } + if let Some(value_table_mode) = self.value_table_mode { f.write_str(" ")?; value_table_mode.fmt(f)?; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 488c88624..6ba3014a3 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2229,6 +2229,7 @@ impl Spanned for Select { fn span(&self) -> Span { let Select { select_token, + optimizer_hint: _, distinct: _, // todo top: _, // todo, mysql specific projection, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 733abbbf3..f4542b53e 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4293,6 +4293,11 @@ impl<'a> Parser<'a> { }) } + /// Return nth token, possibly whitespace, that has not yet been processed. + fn peek_nth_token_no_skip_ref(&self, n: usize) -> &TokenWithSpan { + self.tokens.get(self.index + n).unwrap_or(&EOF_TOKEN) + } + /// Return true if the next tokens exactly `expected` /// /// Does not advance the current token. @@ -13795,6 +13800,7 @@ impl<'a> Parser<'a> { if !self.peek_keyword(Keyword::SELECT) { return Ok(Select { select_token: AttachedToken(from_token), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -13822,6 +13828,7 @@ impl<'a> Parser<'a> { } let select_token = self.expect_keyword(Keyword::SELECT)?; + let optimizer_hint = self.parse_optional_optimizer_hint()?; let value_table_mode = self.parse_value_table_mode()?; let mut top_before_distinct = false; @@ -13977,6 +13984,7 @@ impl<'a> Parser<'a> { Ok(Select { select_token: AttachedToken(select_token), + optimizer_hint, distinct, top, top_before_distinct, @@ -14005,6 +14013,59 @@ impl<'a> Parser<'a> { }) } + /// Parses an optional optimizer hint at the current token position + /// + /// [MySQL](https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html#optimizer-hints-overview) + /// [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Comments.html#GUID-D316D545-89E2-4D54-977F-FC97815CD62E) + fn parse_optional_optimizer_hint(&mut self) -> Result, ParserError> { + let supports_multiline = dialect_of!(self is MySqlDialect | OracleDialect | GenericDialect); + let supports_singleline = dialect_of!(self is OracleDialect | GenericDialect); + if !supports_multiline && !supports_singleline { + return Ok(None); + } + loop { + let t = self.peek_nth_token_no_skip_ref(0); + match &t.token { + // ~ only the very first comment + Token::Whitespace(ws) => { + match ws { + Whitespace::SingleLineComment { comment, prefix } => { + return Ok(if supports_singleline && comment.starts_with("+") { + let text = comment.split_at(1).1.into(); + let prefix = prefix.clone(); + self.next_token_no_skip(); // ~ consume the token + Some(OptimizerHint { + text, + style: OptimizerHintStyle::SingleLine { prefix }, + }) + } else { + None + }); + } + Whitespace::MultiLineComment(comment) => { + return Ok(if supports_multiline && comment.starts_with("+") { + let text = comment.split_at(1).1.into(); + self.next_token_no_skip(); // ~ consume the token + Some(OptimizerHint { + text, + style: OptimizerHintStyle::MultiLine, + }) + } else { + None + }); + } + // ~ but skip (pure) whitespace + Whitespace::Space | Whitespace::Tab | Whitespace::Newline => { + // ~ consume the token and try with the next whitespace (if any) + self.next_token_no_skip(); + } + } + } + _ => return Ok(None), + } + } + } + fn parse_value_table_mode(&mut self) -> Result, ParserError> { if !dialect_of!(self is BigQueryDialect) { return Ok(None); diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index d8c3ada1d..fb28b4d21 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -2681,6 +2681,7 @@ fn test_export_data() { }), Span::empty() )), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -2785,6 +2786,7 @@ fn test_export_data() { }), Span::empty() )), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs index 44bfcda42..ac31a2783 100644 --- a/tests/sqlparser_clickhouse.rs +++ b/tests/sqlparser_clickhouse.rs @@ -41,6 +41,7 @@ fn parse_map_access_expr() { assert_eq!( Select { distinct: None, + optimizer_hint: None, select_token: AttachedToken::empty(), top: None, top_before_distinct: false, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index c67bcb18e..457bb1ba9 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -472,6 +472,7 @@ fn parse_update_set_from() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -5794,6 +5795,7 @@ fn test_parse_named_window() { let actual_select_only = dialects.verified_only_select(sql); let expected = Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -6523,6 +6525,7 @@ fn parse_interval_and_or_xor() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -8897,6 +8900,7 @@ fn lateral_function() { let actual_select_only = verified_only_select(sql); let expected = Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, projection: vec![SelectItem::Wildcard(WildcardAdditionalOptions::default())], @@ -9897,6 +9901,7 @@ fn parse_merge() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -12299,6 +12304,7 @@ fn parse_unload() { query: Some(Box::new(Query { body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -12607,6 +12613,7 @@ fn parse_map_access_expr() { fn parse_connect_by() { let expect_query = Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -12689,6 +12696,7 @@ fn parse_connect_by() { all_dialects_where(|d| d.supports_connect_by()).verified_only_select(connect_by_3), Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -13619,6 +13627,7 @@ fn test_extract_seconds_ok() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -15711,6 +15720,7 @@ fn test_select_from_first() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, projection, diff --git a/tests/sqlparser_duckdb.rs b/tests/sqlparser_duckdb.rs index 80a15eb11..93ef74ff8 100644 --- a/tests/sqlparser_duckdb.rs +++ b/tests/sqlparser_duckdb.rs @@ -266,6 +266,7 @@ fn test_select_union_by_name() { set_quantifier: *expected_quantifier, left: Box::::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, projection: vec![SelectItem::Wildcard(WildcardAdditionalOptions::default())], @@ -297,6 +298,7 @@ fn test_select_union_by_name() { }))), right: Box::::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, projection: vec![SelectItem::Wildcard(WildcardAdditionalOptions::default())], diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 1927b864e..7ef4ce85c 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -141,6 +141,7 @@ fn parse_create_procedure() { pipe_operators: vec![], body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -1348,6 +1349,7 @@ fn parse_substring_in_select() { body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: Some(Distinct::Distinct), top: None, top_before_distinct: false, @@ -1505,6 +1507,7 @@ fn parse_mssql_declare() { body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index e847d3edb..09b5fafd3 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -1416,6 +1416,7 @@ fn parse_escaped_quote_identifiers_with_escape() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -1471,6 +1472,7 @@ fn parse_escaped_quote_identifiers_with_no_escape() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -1518,7 +1520,7 @@ fn parse_escaped_backticks_with_escape() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), - + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -1570,7 +1572,7 @@ fn parse_escaped_backticks_with_no_escape() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), - + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -2390,7 +2392,7 @@ fn parse_select_with_numeric_prefix_column_name() { q.body, Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), - + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -2565,6 +2567,7 @@ fn parse_select_with_concatenation_of_exp_number_and_numeric_prefix_column() { q.body, Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -3197,6 +3200,7 @@ fn parse_substring_in_select() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: Some(Distinct::Distinct), top: None, top_before_distinct: false, @@ -3520,6 +3524,7 @@ fn parse_hex_string_introducer() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -4354,3 +4359,18 @@ fn test_create_index_options() { "CREATE INDEX idx_name ON t(c1, c2) USING BTREE LOCK = EXCLUSIVE ALGORITHM = DEFAULT", ); } + +#[test] +fn test_select_optimizer_hints() { + mysql_and_generic().verified_stmt( + "\ + SELECT /*+ SET_VAR(optimizer_switch = 'mrr_cost_based=off') \ + SET_VAR(max_heap_table_size = 1G) */ 1", + ); + + mysql_and_generic().verified_stmt( + "\ + SELECT /*+ SET_VAR(target_partitions=1) */ * FROM \ + (SELECT /*+ SET_VAR(target_partitions=8) */ * FROM t1 LIMIT 1) AS dt", + ); +} diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index 683660369..c1caca39e 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -333,3 +333,38 @@ fn parse_national_quote_delimited_string_but_is_a_word() { expr_from_projection(&select.projection[2]) ); } + +#[test] +fn parse_optimizer_hints() { + let oracle_dialect = oracle(); + + let select = oracle_dialect.verified_only_select_with_canonical( + "SELECT /*+one two three*/ /*+not a hint!*/ 1 FROM dual", + "SELECT /*+one two three*/ 1 FROM dual", + ); + assert_eq!( + select + .optimizer_hint + .as_ref() + .map(|hint| hint.text.as_str()), + Some("one two three") + ); + + let select = oracle_dialect.verified_only_select_with_canonical( + "SELECT /*one two three*/ /*+not a hint!*/ 1 FROM dual", + "SELECT 1 FROM dual", + ); + assert_eq!(select.optimizer_hint, None); + + let select = oracle_dialect.verified_only_select_with_canonical( + "SELECT --+ one two three /* asdf */\n 1 FROM dual", + "SELECT --+ one two three /* asdf */\n 1 FROM dual", + ); + assert_eq!( + select + .optimizer_hint + .as_ref() + .map(|hint| hint.text.as_str()), + Some(" one two three /* asdf */\n") + ); +} diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 57bddc656..23fcdbd46 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -1282,6 +1282,7 @@ fn parse_copy_to() { with: None, body: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -3059,6 +3060,7 @@ fn parse_array_subquery_expr() { set_quantifier: SetQuantifier::None, left: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, @@ -3085,6 +3087,7 @@ fn parse_array_subquery_expr() { }))), right: Box::new(SetExpr::Select(Box::new(Select { select_token: AttachedToken::empty(), + optimizer_hint: None, distinct: None, top: None, top_before_distinct: false, From 55164cea5a5af77c479e37acadd52845027c6b4e Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Wed, 14 Jan 2026 08:13:45 +0100 Subject: [PATCH 2/7] [MySQL, Oracle] Parse optimizer hints for INSERTs --- src/ast/dml.rs | 26 +++++++++++++++++--------- src/ast/spans.rs | 1 + src/parser/mod.rs | 2 ++ tests/sqlparser_mysql.rs | 18 +++++++++++++++--- tests/sqlparser_oracle.rs | 8 +++++++- tests/sqlparser_postgres.rs | 3 +++ 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 32c023e05..f8b40b17d 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -25,15 +25,12 @@ use serde::{Deserialize, Serialize}; use sqlparser_derive::{Visit, VisitMut}; use crate::{ - ast::display_separated, - display_utils::{indented_list, Indent, SpaceOrNewline}, + ast::{display_separated}, + display_utils::{Indent, SpaceOrNewline, indented_list}, }; use super::{ - display_comma_separated, helpers::attached_token::AttachedToken, query::InputFormatClause, - Assignment, Expr, FromTable, Ident, InsertAliases, MysqlInsertPriority, ObjectName, OnInsert, - OrderByExpr, Query, SelectInto, SelectItem, Setting, SqliteOnConflict, TableFactor, - TableObject, TableWithJoins, UpdateTableFromKind, Values, + Assignment, Expr, FromTable, Ident, InsertAliases, MysqlInsertPriority, ObjectName, OnInsert, OptimizerHint, OrderByExpr, Query, SelectInto, SelectItem, Setting, SqliteOnConflict, TableFactor, TableObject, TableWithJoins, UpdateTableFromKind, Values, display_comma_separated, helpers::attached_token::AttachedToken, query::InputFormatClause }; /// INSERT statement. @@ -43,6 +40,11 @@ use super::{ pub struct Insert { /// Token for the `INSERT` keyword (or its substitutes) pub insert_token: AttachedToken, + /// A query optimizer hint + /// + /// [MySQL](https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html) + /// [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Comments.html#GUID-D316D545-89E2-4D54-977F-FC97815CD62E) + pub optimizer_hint: Option, /// Only for Sqlite pub or: Option, /// Only for mysql @@ -102,7 +104,11 @@ impl Display for Insert { }; if let Some(on_conflict) = self.or { - write!(f, "INSERT {on_conflict} INTO {table_name} ")?; + f.write_str("INSERT")?; + if let Some(hint) = self.optimizer_hint.as_ref() { + write!(f, " {hint}")?; + } + write!(f, " {on_conflict} INTO {table_name} ")?; } else { write!( f, @@ -111,8 +117,10 @@ impl Display for Insert { "REPLACE" } else { "INSERT" - }, - )?; + })?; + if let Some(hint) = self.optimizer_hint.as_ref() { + write!(f, " {hint}")?; + } if let Some(priority) = self.priority { write!(f, " {priority}",)?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 6ba3014a3..8a1579d65 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1288,6 +1288,7 @@ impl Spanned for Insert { fn span(&self) -> Span { let Insert { insert_token, + optimizer_hint: _, or: _, // enum, sqlite specific ignore: _, // bool into: _, // bool diff --git a/src/parser/mod.rs b/src/parser/mod.rs index f4542b53e..12b82cb8f 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -16752,6 +16752,7 @@ impl<'a> Parser<'a> { /// Parse an INSERT statement pub fn parse_insert(&mut self, insert_token: TokenWithSpan) -> Result { + let optimizer_hint = self.parse_optional_optimizer_hint()?; let or = self.parse_conflict_clause(); let priority = if !dialect_of!(self is MySqlDialect | GenericDialect) { None @@ -16921,6 +16922,7 @@ impl<'a> Parser<'a> { Ok(Insert { insert_token: insert_token.into(), + optimizer_hint, or, table: table_object, table_alias, diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 09b5fafd3..6b85fe5e9 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -4361,16 +4361,28 @@ fn test_create_index_options() { } #[test] -fn test_select_optimizer_hints() { - mysql_and_generic().verified_stmt( +fn test_optimizer_hints() { + let mysql_dialect = mysql_and_generic(); + + // ~ selects + mysql_dialect.verified_stmt( "\ SELECT /*+ SET_VAR(optimizer_switch = 'mrr_cost_based=off') \ SET_VAR(max_heap_table_size = 1G) */ 1", ); - mysql_and_generic().verified_stmt( + mysql_dialect.verified_stmt( "\ SELECT /*+ SET_VAR(target_partitions=1) */ * FROM \ (SELECT /*+ SET_VAR(target_partitions=8) */ * FROM t1 LIMIT 1) AS dt", ); + + // ~ inserts / replace + mysql_dialect.verified_stmt("\ + INSERT /*+ RESOURCE_GROUP(Batch) */ \ + INTO t2 VALUES (2)"); + + mysql_dialect.verified_stmt("\ + REPLACE /*+ foobar */ INTO test \ + VALUES (1, 'Old', '2014-08-20 18:47:00')"); } diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index c1caca39e..c9bdce391 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -335,9 +335,10 @@ fn parse_national_quote_delimited_string_but_is_a_word() { } #[test] -fn parse_optimizer_hints() { +fn test_optimizer_hints() { let oracle_dialect = oracle(); + // ~ selects let select = oracle_dialect.verified_only_select_with_canonical( "SELECT /*+one two three*/ /*+not a hint!*/ 1 FROM dual", "SELECT /*+one two three*/ 1 FROM dual", @@ -367,4 +368,9 @@ fn parse_optimizer_hints() { .map(|hint| hint.text.as_str()), Some(" one two three /* asdf */\n") ); + + // ~ inserts + oracle_dialect.verified_stmt( + "INSERT /*+ append */ INTO t1 SELECT * FROM all_objects"); + } diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 23fcdbd46..8e3f52fd3 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5320,6 +5320,7 @@ fn test_simple_postgres_insert_with_alias() { statement, Statement::Insert(Insert { insert_token: AttachedToken::empty(), + optimizer_hint: None, or: None, ignore: false, into: true, @@ -5391,6 +5392,7 @@ fn test_simple_postgres_insert_with_alias() { statement, Statement::Insert(Insert { insert_token: AttachedToken::empty(), + optimizer_hint: None, or: None, ignore: false, into: true, @@ -5464,6 +5466,7 @@ fn test_simple_insert_with_quoted_alias() { statement, Statement::Insert(Insert { insert_token: AttachedToken::empty(), + optimizer_hint: None, or: None, ignore: false, into: true, From d1a35445234e96cc9a0fb1b88b2d5cdf55319ecd Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Wed, 14 Jan 2026 09:15:24 +0100 Subject: [PATCH 3/7] [MySQL, Oracle] Parse optimizer hints for UPDATEs --- src/ast/dml.rs | 9 +++++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 2 ++ tests/sqlparser_common.rs | 2 ++ tests/sqlparser_mysql.rs | 7 +++++++ tests/sqlparser_oracle.rs | 3 +++ tests/sqlparser_sqlite.rs | 1 + 7 files changed, 25 insertions(+) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index f8b40b17d..3375b6c9d 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -265,6 +265,11 @@ impl Display for Delete { pub struct Update { /// Token for the `UPDATE` keyword pub update_token: AttachedToken, + /// A query optimizer hint + /// + /// [MySQL](https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html) + /// [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Comments.html#GUID-D316D545-89E2-4D54-977F-FC97815CD62E) + pub optimizer_hint: Option, /// TABLE pub table: TableWithJoins, /// Column assignments @@ -284,6 +289,10 @@ pub struct Update { impl Display for Update { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("UPDATE ")?; + if let Some(hint) = self.optimizer_hint.as_ref() { + hint.fmt(f)?; + f.write_str(" ")?; + } if let Some(or) = &self.or { or.fmt(f)?; f.write_str(" ")?; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 8a1579d65..38ce25a4c 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -927,6 +927,7 @@ impl Spanned for Update { fn span(&self) -> Span { let Update { update_token, + optimizer_hint: _, table, assignments, from, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 12b82cb8f..44c00db66 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -17026,6 +17026,7 @@ impl<'a> Parser<'a> { /// Parse an `UPDATE` statement and return `Statement::Update`. pub fn parse_update(&mut self, update_token: TokenWithSpan) -> Result { + let optimizer_hint = self.parse_optional_optimizer_hint()?; let or = self.parse_conflict_clause(); let table = self.parse_table_and_joins()?; let from_before_set = if self.parse_keyword(Keyword::FROM) { @@ -17061,6 +17062,7 @@ impl<'a> Parser<'a> { }; Ok(Update { update_token: update_token.into(), + optimizer_hint, table, assignments, from, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 457bb1ba9..86046ebf4 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -457,6 +457,7 @@ fn parse_update_set_from() { stmt, Statement::Update(Update { update_token: AttachedToken::empty(), + optimizer_hint: None, table: TableWithJoins { relation: table_from_name(ObjectName::from(vec![Ident::new("t1")])), joins: vec![], @@ -550,6 +551,7 @@ fn parse_update_with_table_alias() { returning, or: None, limit: None, + optimizer_hint: None, update_token: _, }) => { assert_eq!( diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 6b85fe5e9..1aff23fef 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -2635,6 +2635,7 @@ fn parse_update_with_joins() { returning, or: None, limit: None, + optimizer_hint: None, update_token: _, }) => { assert_eq!( @@ -4385,4 +4386,10 @@ fn test_optimizer_hints() { mysql_dialect.verified_stmt("\ REPLACE /*+ foobar */ INTO test \ VALUES (1, 'Old', '2014-08-20 18:47:00')"); + + // ~ updates + mysql_dialect.verified_stmt("\ + UPDATE /*+ quux */ table_name \ + SET column1 = 1 \ + WHERE 1 = 1"); } diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index c9bdce391..f6737321b 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -373,4 +373,7 @@ fn test_optimizer_hints() { oracle_dialect.verified_stmt( "INSERT /*+ append */ INTO t1 SELECT * FROM all_objects"); + // ~ updates + oracle_dialect.verified_stmt( + "UPDATE /*+ DISABLE_PARALLEL_DML */ table_name SET column1 = 1"); } diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 321cfef07..da311ac06 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -477,6 +477,7 @@ fn parse_update_tuple_row_values() { assert_eq!( sqlite().verified_stmt("UPDATE x SET (a, b) = (1, 2)"), Statement::Update(Update { + optimizer_hint: None, or: None, assignments: vec![Assignment { target: AssignmentTarget::Tuple(vec![ From 491f70f2014d92edbefe8521acaae7e6b7673c8b Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Wed, 14 Jan 2026 09:21:13 +0100 Subject: [PATCH 4/7] [MySQL, Oracle] Parse optimizer hints for DELETEs --- src/ast/dml.rs | 9 +++++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 2 ++ tests/sqlparser_mysql.rs | 4 ++++ tests/sqlparser_oracle.rs | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 3375b6c9d..94071eebf 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -196,6 +196,11 @@ impl Display for Insert { pub struct Delete { /// Token for the `DELETE` keyword pub delete_token: AttachedToken, + /// A query optimizer hint + /// + /// [MySQL](https://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html) + /// [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Comments.html#GUID-D316D545-89E2-4D54-977F-FC97815CD62E) + pub optimizer_hint: Option, /// Multi tables delete are supported in mysql pub tables: Vec, /// FROM @@ -215,6 +220,10 @@ pub struct Delete { impl Display for Delete { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("DELETE")?; + if let Some(hint) = self.optimizer_hint.as_ref() { + f.write_str(" ")?; + hint.fmt(f)?; + } if !self.tables.is_empty() { indented_list(f, &self.tables)?; } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 38ce25a4c..514c0c348 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -894,6 +894,7 @@ impl Spanned for Delete { fn span(&self) -> Span { let Delete { delete_token, + optimizer_hint: _, tables, from, using, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 44c00db66..8e2f5a490 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12985,6 +12985,7 @@ impl<'a> Parser<'a> { /// Parse a `DELETE` statement and return `Statement::Delete`. pub fn parse_delete(&mut self, delete_token: TokenWithSpan) -> Result { + let optimizer_hint = self.parse_optional_optimizer_hint()?; let (tables, with_from_keyword) = if !self.parse_keyword(Keyword::FROM) { // `FROM` keyword is optional in BigQuery SQL. // https://cloud.google.com/bigquery/docs/reference/standard-sql/dml-syntax#delete_statement @@ -13028,6 +13029,7 @@ impl<'a> Parser<'a> { Ok(Statement::Delete(Delete { delete_token: delete_token.into(), + optimizer_hint, tables, from: if with_from_keyword { FromTable::WithFromKeyword(from) diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 1aff23fef..925080248 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -4392,4 +4392,8 @@ fn test_optimizer_hints() { UPDATE /*+ quux */ table_name \ SET column1 = 1 \ WHERE 1 = 1"); + + // ~ deletes + mysql_dialect.verified_stmt("\ + DELETE /*+ foobar */ FROM table_name"); } diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index f6737321b..387261e96 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -376,4 +376,8 @@ fn test_optimizer_hints() { // ~ updates oracle_dialect.verified_stmt( "UPDATE /*+ DISABLE_PARALLEL_DML */ table_name SET column1 = 1"); + + // ~ deletes + oracle_dialect.verified_stmt( + "DELETE --+ ENABLE_PARALLEL_DML\n FROM table_name"); } From 45bb4de4c7a42267ec0a9e08056ee49012cb7fd6 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Wed, 14 Jan 2026 09:43:17 +0100 Subject: [PATCH 5/7] [MySQL, Oracle] Parse optimizer hints for MERGEs --- src/ast/dml.rs | 19 +++++++++++++------ src/ast/spans.rs | 1 + src/parser/merge.rs | 2 ++ tests/sqlparser_oracle.rs | 9 +++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 94071eebf..5cca99824 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -348,6 +348,10 @@ impl Display for Update { pub struct Merge { /// The `MERGE` token that starts the statement. pub merge_token: AttachedToken, + /// A query optimizer hint + /// + /// [Oracle](https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/Comments.html#GUID-D316D545-89E2-4D54-977F-FC97815CD62E) + pub optimizer_hint: Option, /// optional INTO keyword pub into: bool, /// Specifies the table to merge @@ -364,13 +368,16 @@ pub struct Merge { impl Display for Merge { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "MERGE{int} {table} USING {source} ", - int = if self.into { " INTO" } else { "" }, + f.write_str("MERGE")?; + if let Some(hint) = self.optimizer_hint.as_ref() { + write!(f, " {hint}")?; + } + if self.into { + write!(f, " INTO")?; + } + write!(f, " {table} USING {source} ", table = self.table, - source = self.source, - )?; + source = self.source)?; write!(f, "ON {on} ", on = self.on)?; write!(f, "{}", display_separated(&self.clauses, " "))?; if let Some(ref output) = self.output { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 514c0c348..c83db8155 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -2819,6 +2819,7 @@ WHERE id = 1 // ~ individual tokens within the statement let Statement::Merge(Merge { merge_token, + optimizer_hint: _, into: _, table: _, source: _, diff --git a/src/parser/merge.rs b/src/parser/merge.rs index 62da68a20..b2f5f8c1d 100644 --- a/src/parser/merge.rs +++ b/src/parser/merge.rs @@ -43,6 +43,7 @@ impl Parser<'_> { /// Parse a `MERGE` statement pub fn parse_merge(&mut self, merge_token: TokenWithSpan) -> Result { + let optimizer_hint = self.parse_optional_optimizer_hint()?; let into = self.parse_keyword(Keyword::INTO); let table = self.parse_table_factor()?; @@ -59,6 +60,7 @@ impl Parser<'_> { Ok(Merge { merge_token: merge_token.into(), + optimizer_hint, into, table, source, diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index 387261e96..f137c2116 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -380,4 +380,13 @@ fn test_optimizer_hints() { // ~ deletes oracle_dialect.verified_stmt( "DELETE --+ ENABLE_PARALLEL_DML\n FROM table_name"); + + // ~ merges + oracle_dialect.verified_stmt( + "MERGE /*+ CLUSTERING */ INTO people_target pt \ + USING people_source ps \ + ON (pt.person_id = ps.person_id) \ + WHEN NOT MATCHED THEN INSERT \ + (pt.person_id, pt.first_name, pt.last_name, pt.title) \ + VALUES (ps.person_id, ps.first_name, ps.last_name, ps.title)"); } From 304397f6f4faf522458f51c2dde857ca8c948a76 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Wed, 14 Jan 2026 09:43:51 +0100 Subject: [PATCH 6/7] Cargo fmt --- src/ast/dml.rs | 19 +++++++++++++------ tests/sqlparser_mysql.rs | 24 ++++++++++++++++-------- tests/sqlparser_oracle.rs | 12 +++++------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 5cca99824..4c36f7059 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -25,12 +25,15 @@ use serde::{Deserialize, Serialize}; use sqlparser_derive::{Visit, VisitMut}; use crate::{ - ast::{display_separated}, - display_utils::{Indent, SpaceOrNewline, indented_list}, + ast::display_separated, + display_utils::{indented_list, Indent, SpaceOrNewline}, }; use super::{ - Assignment, Expr, FromTable, Ident, InsertAliases, MysqlInsertPriority, ObjectName, OnInsert, OptimizerHint, OrderByExpr, Query, SelectInto, SelectItem, Setting, SqliteOnConflict, TableFactor, TableObject, TableWithJoins, UpdateTableFromKind, Values, display_comma_separated, helpers::attached_token::AttachedToken, query::InputFormatClause + display_comma_separated, helpers::attached_token::AttachedToken, query::InputFormatClause, + Assignment, Expr, FromTable, Ident, InsertAliases, MysqlInsertPriority, ObjectName, OnInsert, + OptimizerHint, OrderByExpr, Query, SelectInto, SelectItem, Setting, SqliteOnConflict, + TableFactor, TableObject, TableWithJoins, UpdateTableFromKind, Values, }; /// INSERT statement. @@ -117,7 +120,8 @@ impl Display for Insert { "REPLACE" } else { "INSERT" - })?; + } + )?; if let Some(hint) = self.optimizer_hint.as_ref() { write!(f, " {hint}")?; } @@ -375,9 +379,12 @@ impl Display for Merge { if self.into { write!(f, " INTO")?; } - write!(f, " {table} USING {source} ", + write!( + f, + " {table} USING {source} ", table = self.table, - source = self.source)?; + source = self.source + )?; write!(f, "ON {on} ", on = self.on)?; write!(f, "{}", display_separated(&self.clauses, " "))?; if let Some(ref output) = self.output { diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 925080248..a7f2c96fd 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -4379,21 +4379,29 @@ fn test_optimizer_hints() { ); // ~ inserts / replace - mysql_dialect.verified_stmt("\ + mysql_dialect.verified_stmt( + "\ INSERT /*+ RESOURCE_GROUP(Batch) */ \ - INTO t2 VALUES (2)"); + INTO t2 VALUES (2)", + ); - mysql_dialect.verified_stmt("\ + mysql_dialect.verified_stmt( + "\ REPLACE /*+ foobar */ INTO test \ - VALUES (1, 'Old', '2014-08-20 18:47:00')"); + VALUES (1, 'Old', '2014-08-20 18:47:00')", + ); // ~ updates - mysql_dialect.verified_stmt("\ + mysql_dialect.verified_stmt( + "\ UPDATE /*+ quux */ table_name \ SET column1 = 1 \ - WHERE 1 = 1"); + WHERE 1 = 1", + ); // ~ deletes - mysql_dialect.verified_stmt("\ - DELETE /*+ foobar */ FROM table_name"); + mysql_dialect.verified_stmt( + "\ + DELETE /*+ foobar */ FROM table_name", + ); } diff --git a/tests/sqlparser_oracle.rs b/tests/sqlparser_oracle.rs index f137c2116..1c12f868f 100644 --- a/tests/sqlparser_oracle.rs +++ b/tests/sqlparser_oracle.rs @@ -370,16 +370,13 @@ fn test_optimizer_hints() { ); // ~ inserts - oracle_dialect.verified_stmt( - "INSERT /*+ append */ INTO t1 SELECT * FROM all_objects"); + oracle_dialect.verified_stmt("INSERT /*+ append */ INTO t1 SELECT * FROM all_objects"); // ~ updates - oracle_dialect.verified_stmt( - "UPDATE /*+ DISABLE_PARALLEL_DML */ table_name SET column1 = 1"); + oracle_dialect.verified_stmt("UPDATE /*+ DISABLE_PARALLEL_DML */ table_name SET column1 = 1"); // ~ deletes - oracle_dialect.verified_stmt( - "DELETE --+ ENABLE_PARALLEL_DML\n FROM table_name"); + oracle_dialect.verified_stmt("DELETE --+ ENABLE_PARALLEL_DML\n FROM table_name"); // ~ merges oracle_dialect.verified_stmt( @@ -388,5 +385,6 @@ fn test_optimizer_hints() { ON (pt.person_id = ps.person_id) \ WHEN NOT MATCHED THEN INSERT \ (pt.person_id, pt.first_name, pt.last_name, pt.title) \ - VALUES (ps.person_id, ps.first_name, ps.last_name, ps.title)"); + VALUES (ps.person_id, ps.first_name, ps.last_name, ps.title)", + ); } From 800a4f30a4a1e880e7cb9fc54ec6606ac61919e2 Mon Sep 17 00:00:00 2001 From: Petr Novotnik Date: Wed, 14 Jan 2026 11:26:50 +0100 Subject: [PATCH 7/7] Fix no_std compilation --- src/ast/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 215b1f2af..096463189 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -11701,7 +11701,7 @@ pub enum OptimizerHintStyle { } impl fmt::Display for OptimizerHint { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.style { OptimizerHintStyle::SingleLine { prefix } => { f.write_str(prefix)?;