From 2c78bb95f887bd0471f27e9bfbfc68f3399bf05d Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 9 Apr 2026 18:30:07 +0200 Subject: [PATCH 1/4] Add xml '...' TypedString support for PostgreSQL --- src/dialect/generic.rs | 4 ++++ src/dialect/mod.rs | 11 +++++++++++ src/dialect/postgresql.rs | 4 ++++ src/parser/mod.rs | 26 +++++++++++++++++++++++++- tests/sqlparser_common.rs | 8 ++++++++ tests/sqlparser_postgres.rs | 22 ++++++++++++++++++++++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index c7f17351b..b76ac5a6c 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -292,4 +292,8 @@ impl Dialect for GenericDialect { fn supports_cte_without_as(&self) -> bool { true } + + fn supports_xml_expressions(&self) -> bool { + true + } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ef9cafed5..3d09a731f 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1681,6 +1681,17 @@ pub trait Dialect: Debug + Any { fn supports_cte_without_as(&self) -> bool { false } + + /// Returns true if the dialect supports XML-related expressions + /// such as `xml ''` typed strings, XML functions like + /// `XMLCONCAT`, `XMLELEMENT`, etc. + /// + /// When this returns false, `xml` is treated as a regular identifier. + /// + /// [PostgreSQL](https://www.postgresql.org/docs/current/functions-xml.html) + fn supports_xml_expressions(&self) -> bool { + false + } } /// Operators for which precedence must be defined. diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index b99a8b5c3..982dc3649 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -310,4 +310,8 @@ impl Dialect for PostgreSqlDialect { fn supports_comma_separated_trim(&self) -> bool { true } + + fn supports_xml_expressions(&self) -> bool { + true + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9056f85d3..0b6410034 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1687,6 +1687,16 @@ impl<'a> Parser<'a> { } } + /// Returns true if the given [ObjectName] is a single unquoted + /// identifier matching `expected` (case-insensitive). + fn is_simple_unquoted_object_name(name: &ObjectName, expected: &str) -> bool { + if let [ObjectNamePart::Identifier(ident)] = name.0.as_slice() { + ident.quote_style.is_none() && ident.value.eq_ignore_ascii_case(expected) + } else { + false + } + } + /// Parse an expression prefix. pub fn parse_prefix(&mut self) -> Result { // allow the dialect to override prefix parsing @@ -1720,7 +1730,21 @@ impl<'a> Parser<'a> { // so given `NOT 'a' LIKE 'b'`, we'd accept `NOT` as a possible custom data type // name, resulting in `NOT 'a'` being recognized as a `TypedString` instead of // an unary negation `NOT ('a' LIKE 'b')`. To solve this, we don't accept the - // `type 'string'` syntax for the custom data types at all. + // `type 'string'` syntax for the custom data types at all ... + // + // ... with the exception of `xml '...'` on dialects that support XML + // expressions, which is a valid PostgreSQL typed string literal. + DataType::Custom(ref name, ref modifiers) + if modifiers.is_empty() + && Self::is_simple_unquoted_object_name(name, "xml") + && parser.dialect.supports_xml_expressions() => + { + Ok(Expr::TypedString(TypedString { + data_type: DataType::Custom(name.clone(), modifiers.clone()), + value: parser.parse_value()?, + uses_odbc_syntax: false, + })) + } DataType::Custom(..) => parser_err!("dummy", loc), // MySQL supports using the `BINARY` keyword as a cast to binary type. DataType::Binary(..) if self.dialect.supports_binary_kw_as_cast() => { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index ad1e521f1..c65d27cf0 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -18754,3 +18754,11 @@ fn test_wildcard_func_arg() { dialects.verified_expr("HASH(* EXCLUDE (col1))"); dialects.verified_expr("HASH(* EXCLUDE (col1, col2))"); } + +#[test] +fn parse_non_pg_dialects_keep_xml_names_as_regular_identifiers() { + // On dialects that do NOT support XML expressions, bare `xml` should + // be treated as a regular column identifier, not a typed-string prefix. + let dialects = all_dialects_except(|d| d.supports_xml_expressions()); + dialects.verified_only_select("SELECT xml FROM t"); +} diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index af0f2be33..9cbe7233f 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -3750,6 +3750,28 @@ fn parse_on_commit() { pg_and_generic().verified_stmt("CREATE TEMPORARY TABLE table (COL INT) ON COMMIT DROP"); } +#[test] +fn parse_xml_typed_string() { + // xml '...' should parse as a TypedString on PostgreSQL and Generic + let sql = "SELECT xml ''"; + let select = pg_and_generic().verified_only_select(sql); + match expr_from_projection(&select.projection[0]) { + Expr::TypedString(TypedString { + data_type: DataType::Custom(name, modifiers), + value, + uses_odbc_syntax: false, + }) => { + assert_eq!(name.to_string(), "xml"); + assert!(modifiers.is_empty()); + assert_eq!( + value.value, + Value::SingleQuotedString("".to_string()) + ); + } + other => panic!("Expected TypedString, got: {other:?}"), + } +} + fn pg() -> TestedDialects { TestedDialects::new(vec![Box::new(PostgreSqlDialect {})]) } From 03a63f942345d312809f1b810b5a3399c779f8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donovan=20Sch=C3=B6nknecht?= Date: Thu, 9 Apr 2026 14:31:02 +0200 Subject: [PATCH 2/4] MySQL: Add support for `ORDER BY` on single-table `UPDATE` (#2296) --- src/ast/dml.rs | 8 ++++++ src/ast/spans.rs | 2 ++ src/dialect/generic.rs | 4 +++ src/dialect/mod.rs | 10 ++++++++ src/dialect/mysql.rs | 5 ++++ src/parser/mod.rs | 8 ++++++ tests/sqlparser_common.rs | 2 ++ tests/sqlparser_mysql.rs | 54 +++++++++++++++++++++++++++++++++++++++ tests/sqlparser_sqlite.rs | 1 + 9 files changed, 94 insertions(+) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 446d44b20..3e2f7ee09 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -387,6 +387,9 @@ pub struct Update { pub output: Option, /// SQLite-specific conflict resolution clause pub or: Option, + /// ORDER BY (MySQL extension for single-table UPDATE) + /// See + pub order_by: Vec, /// LIMIT pub limit: Option, } @@ -434,6 +437,11 @@ impl Display for Update { f.write_str("RETURNING")?; indented_list(f, returning)?; } + if !self.order_by.is_empty() { + SpaceOrNewline.fmt(f)?; + f.write_str("ORDER BY")?; + indented_list(f, &self.order_by)?; + } if let Some(limit) = &self.limit { SpaceOrNewline.fmt(f)?; write!(f, "LIMIT {limit}")?; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index d80a3f4d5..70c12de11 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -952,6 +952,7 @@ impl Spanned for Update { returning, output, or: _, + order_by, limit, } = self; @@ -963,6 +964,7 @@ impl Spanned for Update { .chain(selection.iter().map(|i| i.span())) .chain(returning.iter().flat_map(|i| i.iter().map(|k| k.span()))) .chain(output.iter().map(|i| i.span())) + .chain(order_by.iter().map(|i| i.span())) .chain(limit.iter().map(|i| i.span())), ) } diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index b76ac5a6c..0967ae4ee 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -133,6 +133,10 @@ impl Dialect for GenericDialect { true } + fn supports_update_order_by(&self) -> bool { + true + } + fn supports_from_first_select(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 3d09a731f..9479950e9 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -520,6 +520,16 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports `ORDER BY` in `UPDATE` statements. + /// + /// ```sql + /// UPDATE foo SET bar = false WHERE foo = true ORDER BY foo ASC; + /// ``` + /// See + fn supports_update_order_by(&self) -> bool { + false + } + /// Returns true if the dialect supports an `EXCEPT` clause following a /// wildcard in a select list. /// diff --git a/src/dialect/mysql.rs b/src/dialect/mysql.rs index 6b057539e..7c793d0df 100644 --- a/src/dialect/mysql.rs +++ b/src/dialect/mysql.rs @@ -179,6 +179,11 @@ impl Dialect for MySqlDialect { true } + /// See: + fn supports_update_order_by(&self) -> bool { + true + } + fn supports_data_type_signed_suffix(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0b6410034..6185b1456 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -17779,6 +17779,13 @@ impl<'a> Parser<'a> { } else { None }; + let order_by = if self.dialect.supports_update_order_by() + && self.parse_keywords(&[Keyword::ORDER, Keyword::BY]) + { + self.parse_comma_separated(Parser::parse_order_by_expr)? + } else { + vec![] + }; let limit = if self.parse_keyword(Keyword::LIMIT) { Some(self.parse_expr()?) } else { @@ -17794,6 +17801,7 @@ impl<'a> Parser<'a> { returning, output, or, + order_by, limit, } .into()) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index c65d27cf0..fcc288d98 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -535,6 +535,7 @@ fn parse_update_set_from() { returning: None, output: None, or: None, + order_by: vec![], limit: None }) ); @@ -554,6 +555,7 @@ fn parse_update_with_table_alias() { selection, returning, or: None, + order_by: _, limit: None, optimizer_hints, update_token: _, diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 269787c29..6680929cb 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -2707,6 +2707,7 @@ fn parse_update_with_joins() { selection, returning, or: None, + order_by: _, limit: None, optimizer_hints, update_token: _, @@ -2784,6 +2785,59 @@ fn parse_update_with_joins() { } } +#[test] +fn parse_update_with_order_by() { + let sql = "UPDATE foo SET bar = false WHERE foo = true ORDER BY foo ASC"; + match mysql_and_generic().verified_stmt(sql) { + Statement::Update(Update { order_by, .. }) => { + assert_eq!( + vec![OrderByExpr { + expr: Expr::Identifier(Ident { + value: "foo".to_owned(), + quote_style: None, + span: Span::empty(), + }), + options: OrderByOptions { + asc: Some(true), + nulls_first: None, + }, + with_fill: None, + }], + order_by + ); + } + _ => unreachable!(), + } +} + +#[test] +fn parse_update_with_order_by_and_limit() { + let sql = "UPDATE foo SET bar = false WHERE foo = true ORDER BY foo ASC LIMIT 10"; + match mysql_and_generic().verified_stmt(sql) { + Statement::Update(Update { + order_by, limit, .. + }) => { + assert_eq!( + vec![OrderByExpr { + expr: Expr::Identifier(Ident { + value: "foo".to_owned(), + quote_style: None, + span: Span::empty(), + }), + options: OrderByOptions { + asc: Some(true), + nulls_first: None, + }, + with_fill: None, + }], + order_by + ); + assert_eq!(Some(Expr::value(number("10"))), limit); + } + _ => unreachable!(), + } +} + #[test] fn parse_delete_with_order_by() { let sql = "DELETE FROM customers ORDER BY id DESC"; diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 33c38fb0a..f9536bc28 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -497,6 +497,7 @@ fn parse_update_tuple_row_values() { from: None, returning: None, output: None, + order_by: vec![], limit: None, update_token: AttachedToken::empty() }) From 9557d419daadff9a9f9d79f4ed99bbcba7da06f2 Mon Sep 17 00:00:00 2001 From: Minjun Kim <48622976+funcpp@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:14:59 +0900 Subject: [PATCH 3/4] Support multi-column aliases in SELECT items (#2289) --- src/ast/query.rs | 15 +++++++++++++++ src/ast/spans.rs | 3 +++ src/dialect/databricks.rs | 4 ++++ src/dialect/generic.rs | 4 ++++ src/dialect/mod.rs | 11 +++++++++++ src/parser/mod.rs | 13 +++++++++++++ tests/sqlparser_common.rs | 15 +++++++++++++++ 7 files changed, 65 insertions(+) diff --git a/src/ast/query.rs b/src/ast/query.rs index a52d518b1..49ba86f1f 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -872,6 +872,15 @@ pub enum SelectItem { /// The alias for the expression. alias: Ident, }, + /// An expression, followed by `[ AS ] (alias1, alias2, ...)` + /// + /// [Spark SQL](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select.html) + ExprWithAliases { + /// The expression being projected. + expr: Expr, + /// The list of aliases for the expression. + aliases: Vec, + }, /// An expression, followed by a wildcard expansion. /// e.g. `alias.*`, `STRUCT('foo').*` QualifiedWildcard(SelectItemQualifiedWildcardKind, WildcardAdditionalOptions), @@ -1175,6 +1184,12 @@ impl fmt::Display for SelectItem { f.write_str(" AS ")?; alias.fmt(f) } + SelectItem::ExprWithAliases { expr, aliases } => { + expr.fmt(f)?; + f.write_str(" AS (")?; + display_comma_separated(aliases).fmt(f)?; + f.write_str(")") + } SelectItem::QualifiedWildcard(kind, additional_options) => { kind.fmt(f)?; additional_options.fmt(f) diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 70c12de11..95d2e8810 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1823,6 +1823,9 @@ impl Spanned for SelectItem { match self { SelectItem::UnnamedExpr(expr) => expr.span(), SelectItem::ExprWithAlias { expr, alias } => expr.span().union(&alias.span), + SelectItem::ExprWithAliases { expr, aliases } => { + union_spans(iter::once(expr.span()).chain(aliases.iter().map(|i| i.span))) + } SelectItem::QualifiedWildcard(kind, wildcard_additional_options) => union_spans( [kind.span()] .into_iter() diff --git a/src/dialect/databricks.rs b/src/dialect/databricks.rs index c76b464a0..679d335d7 100644 --- a/src/dialect/databricks.rs +++ b/src/dialect/databricks.rs @@ -104,4 +104,8 @@ impl Dialect for DatabricksDialect { fn supports_cte_without_as(&self) -> bool { true } + + fn supports_select_item_multi_column_alias(&self) -> bool { + true + } } diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 0967ae4ee..674311a92 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -297,6 +297,10 @@ impl Dialect for GenericDialect { true } + fn supports_select_item_multi_column_alias(&self) -> bool { + true + } + fn supports_xml_expressions(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 9479950e9..8dae15be8 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1692,6 +1692,17 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports parenthesized multi-column + /// aliases in SELECT items. For example: + /// ```sql + /// SELECT stack(2, 'a', 'b') AS (col1, col2) + /// ``` + /// + /// [Spark SQL](https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select.html) + fn supports_select_item_multi_column_alias(&self) -> bool { + false + } + /// Returns true if the dialect supports XML-related expressions /// such as `xml ''` typed strings, XML functions like /// `XMLCONCAT`, `XMLELEMENT`, etc. diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6185b1456..40ea9a014 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -18116,6 +18116,19 @@ impl<'a> Parser<'a> { self.parse_wildcard_additional_options(wildcard_token)?, )) } + expr if self.dialect.supports_select_item_multi_column_alias() + && self.peek_keyword(Keyword::AS) + && self.peek_nth_token(1).token == Token::LParen => + { + self.expect_keyword(Keyword::AS)?; + self.expect_token(&Token::LParen)?; + let aliases = self.parse_comma_separated(|p| p.parse_identifier())?; + self.expect_token(&Token::RParen)?; + Ok(SelectItem::ExprWithAliases { + expr: maybe_prefixed_expr(expr, prefix), + aliases, + }) + } expr => self .maybe_parse_select_item_alias() .map(|alias| match alias { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index fcc288d98..4db5edeb3 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -18757,6 +18757,21 @@ fn test_wildcard_func_arg() { dialects.verified_expr("HASH(* EXCLUDE (col1, col2))"); } +#[test] +fn parse_select_item_multi_column_alias() { + all_dialects_where(|d| d.supports_select_item_multi_column_alias()) + .verified_stmt("SELECT stack(2, 'a', 'b', 'c', 'd') AS (col1, col2)"); + + all_dialects_where(|d| d.supports_select_item_multi_column_alias()) + .verified_stmt("SELECT stack(2, 'a', 'b', 'c', 'd') AS (col1, col2) FROM t"); + + assert!( + all_dialects_where(|d| !d.supports_select_item_multi_column_alias()) + .parse_sql_statements("SELECT stack(2, 'a', 'b') AS (col1, col2)") + .is_err() + ); +} + #[test] fn parse_non_pg_dialects_keep_xml_names_as_regular_identifiers() { // On dialects that do NOT support XML expressions, bare `xml` should From 5d425186fc338667d00f9b5c1055594a4dcba44a Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 9 Apr 2026 20:30:40 +0200 Subject: [PATCH 4/4] fmt --- tests/sqlparser_postgres.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 9cbe7233f..ff0476ca5 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -3763,10 +3763,7 @@ fn parse_xml_typed_string() { }) => { assert_eq!(name.to_string(), "xml"); assert!(modifiers.is_empty()); - assert_eq!( - value.value, - Value::SingleQuotedString("".to_string()) - ); + assert_eq!(value.value, Value::SingleQuotedString("".to_string())); } other => panic!("Expected TypedString, got: {other:?}"), }