From 0d25d938e1770c5250049aa0c6eb218d11da7fdd Mon Sep 17 00:00:00 2001 From: Guan-Ming Chiu Date: Sun, 22 Feb 2026 03:20:59 +0800 Subject: [PATCH 1/2] MSSQL: Add support for OUTPUT clause on INSERT/UPDATE/DELETE Signed-off-by: Guan-Ming Chiu --- src/ast/dml.rs | 23 ++++++++++++++++++-- src/ast/spans.rs | 8 ++++++- src/dialect/snowflake.rs | 1 + src/keywords.rs | 2 ++ src/parser/merge.rs | 2 +- src/parser/mod.rs | 42 ++++++++++++++++++++++++++++++++++--- tests/sqlparser_common.rs | 2 ++ tests/sqlparser_mssql.rs | 42 +++++++++++++++++++++++++++++++++++++ tests/sqlparser_mysql.rs | 1 + tests/sqlparser_postgres.rs | 3 +++ tests/sqlparser_sqlite.rs | 1 + 11 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 06f731c5c..553818ba5 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -79,6 +79,8 @@ pub struct Insert { pub on: Option, /// RETURNING pub returning: Option>, + /// OUTPUT (MSSQL) + pub output: Option, /// Only for mysql pub replace_into: bool, /// Only for mysql @@ -203,6 +205,11 @@ impl Display for Insert { SpaceOrNewline.fmt(f)?; } + if let Some(output) = &self.output { + write!(f, "{output}")?; + SpaceOrNewline.fmt(f)?; + } + if let Some(settings) = &self.settings { write!(f, "SETTINGS {}", display_comma_separated(settings))?; SpaceOrNewline.fmt(f)?; @@ -289,6 +296,8 @@ pub struct Delete { pub selection: Option, /// RETURNING pub returning: Option>, + /// OUTPUT (MSSQL) + pub output: Option, /// ORDER BY (MySQL) pub order_by: Vec, /// LIMIT (MySQL) @@ -314,6 +323,10 @@ impl Display for Delete { indented_list(f, from)?; } } + if let Some(output) = &self.output { + SpaceOrNewline.fmt(f)?; + write!(f, "{output}")?; + } if let Some(using) = &self.using { SpaceOrNewline.fmt(f)?; f.write_str("USING")?; @@ -367,6 +380,8 @@ pub struct Update { pub selection: Option, /// RETURNING pub returning: Option>, + /// OUTPUT (MSSQL) + pub output: Option, /// SQLite-specific conflict resolution clause pub or: Option, /// LIMIT @@ -396,6 +411,10 @@ impl Display for Update { f.write_str("SET")?; indented_list(f, &self.assignments)?; } + if let Some(output) = &self.output { + SpaceOrNewline.fmt(f)?; + write!(f, "{output}")?; + } if let Some(UpdateTableFromKind::AfterSet(from)) = &self.from { SpaceOrNewline.fmt(f)?; f.write_str("FROM")?; @@ -717,11 +736,11 @@ impl Display for MergeUpdateExpr { } } -/// A `OUTPUT` Clause in the end of a `MERGE` Statement +/// An `OUTPUT` clause on `MERGE`, `INSERT`, `UPDATE`, or `DELETE` (MSSQL). /// /// Example: /// OUTPUT $action, deleted.* INTO dbo.temp_products; -/// [mssql](https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql) +/// #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 43005cfbb..dd62c5ba1 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -906,6 +906,7 @@ impl Spanned for Delete { using, selection, returning, + output, order_by, limit, } = self; @@ -923,6 +924,7 @@ impl Spanned for Delete { ) .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())), ), @@ -940,6 +942,7 @@ impl Spanned for Update { from, selection, returning, + output, or: _, limit, } = self; @@ -951,6 +954,7 @@ impl Spanned for Update { .chain(from.iter().map(|i| i.span())) .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(limit.iter().map(|i| i.span())), ) } @@ -1312,6 +1316,7 @@ impl Spanned for Insert { has_table_keyword: _, // bool on, returning, + output, replace_into: _, // bool priority: _, // todo, mysql specific insert_alias: _, // todo, mysql specific @@ -1334,7 +1339,8 @@ impl Spanned for Insert { .chain(partitioned.iter().flat_map(|i| i.iter().map(|k| k.span()))) .chain(after_columns.iter().map(|i| i.span)) .chain(on.as_ref().map(|i| i.span())) - .chain(returning.iter().flat_map(|i| i.iter().map(|k| k.span()))), + .chain(returning.iter().flat_map(|i| i.iter().map(|k| k.span()))) + .chain(output.iter().map(|i| i.span())), ) } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index a9d71fc4b..f756c4159 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1784,6 +1784,7 @@ fn parse_multi_table_insert( has_table_keyword: false, on: None, returning: None, + output: None, replace_into: false, priority: None, insert_alias: None, diff --git a/src/keywords.rs b/src/keywords.rs index cc2b9e9dd..80f679c07 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -1210,6 +1210,7 @@ pub const RESERVED_FOR_TABLE_ALIAS: &[Keyword] = &[ Keyword::ANTI, Keyword::SEMI, Keyword::RETURNING, + Keyword::OUTPUT, Keyword::ASOF, Keyword::MATCH_CONDITION, // for MSSQL-specific OUTER APPLY (seems reserved in most dialects) @@ -1264,6 +1265,7 @@ pub const RESERVED_FOR_COLUMN_ALIAS: &[Keyword] = &[ Keyword::CLUSTER, Keyword::DISTRIBUTE, Keyword::RETURNING, + Keyword::VALUES, // Reserved only as a column alias in the `SELECT` clause Keyword::FROM, Keyword::INTO, diff --git a/src/parser/merge.rs b/src/parser/merge.rs index a927bc4b1..03906819f 100644 --- a/src/parser/merge.rs +++ b/src/parser/merge.rs @@ -218,7 +218,7 @@ impl Parser<'_> { self.parse_parenthesized_qualified_column_list(IsOptional::Optional, allow_empty) } - fn parse_output( + pub(super) fn parse_output( &mut self, start_keyword: Keyword, start_token: TokenWithSpan, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index bc91213fa..aeff024d7 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13309,6 +13309,15 @@ impl<'a> Parser<'a> { }; let from = self.parse_comma_separated(Parser::parse_table_and_joins)?; + + // MSSQL OUTPUT clause appears after FROM table, before USING/WHERE + // https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql + let output = if self.parse_keyword(Keyword::OUTPUT) { + Some(self.parse_output(Keyword::OUTPUT, self.get_current_token().clone())?) + } else { + None + }; + let using = if self.parse_keyword(Keyword::USING) { Some(self.parse_comma_separated(Parser::parse_table_and_joins)?) } else { @@ -13347,6 +13356,7 @@ impl<'a> Parser<'a> { using, selection, returning, + output, order_by, limit, })) @@ -17275,10 +17285,10 @@ impl<'a> Parser<'a> { let is_mysql = dialect_of!(self is MySqlDialect); - let (columns, partitioned, after_columns, source, assignments) = if self + let (columns, partitioned, after_columns, output, source, assignments) = if self .parse_keywords(&[Keyword::DEFAULT, Keyword::VALUES]) { - (vec![], None, vec![], None, vec![]) + (vec![], None, vec![], None, None, vec![]) } else { let (columns, partitioned, after_columns) = if !self.peek_subquery_start() { let columns = self.parse_parenthesized_column_list(Optional, is_mysql)?; @@ -17295,6 +17305,14 @@ impl<'a> Parser<'a> { Default::default() }; + // MSSQL OUTPUT clause appears between columns and source + // https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql + let output = if self.parse_keyword(Keyword::OUTPUT) { + Some(self.parse_output(Keyword::OUTPUT, self.get_current_token().clone())?) + } else { + None + }; + let (source, assignments) = if self.peek_keyword(Keyword::FORMAT) || self.peek_keyword(Keyword::SETTINGS) { @@ -17305,7 +17323,14 @@ impl<'a> Parser<'a> { (Some(self.parse_query()?), vec![]) }; - (columns, partitioned, after_columns, source, assignments) + ( + columns, + partitioned, + after_columns, + output, + source, + assignments, + ) }; let (format_clause, settings) = if self.dialect.supports_insert_format() { @@ -17407,6 +17432,7 @@ impl<'a> Parser<'a> { has_table_keyword: table, on, returning, + output, replace_into, priority, insert_alias, @@ -17512,6 +17538,15 @@ impl<'a> Parser<'a> { }; self.expect_keyword(Keyword::SET)?; let assignments = self.parse_comma_separated(Parser::parse_assignment)?; + + // MSSQL OUTPUT clause appears after SET, before FROM/WHERE + // https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql + let output = if self.parse_keyword(Keyword::OUTPUT) { + Some(self.parse_output(Keyword::OUTPUT, self.get_current_token().clone())?) + } else { + None + }; + let from = if from_before_set.is_none() && self.parse_keyword(Keyword::FROM) { Some(UpdateTableFromKind::AfterSet( self.parse_table_with_joins()?, @@ -17542,6 +17577,7 @@ impl<'a> Parser<'a> { from, selection, returning, + output, or, limit, } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index ad7697a7f..7bf276407 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -530,6 +530,7 @@ fn parse_update_set_from() { ])), }), returning: None, + output: None, or: None, limit: None }) @@ -553,6 +554,7 @@ fn parse_update_with_table_alias() { limit: None, optimizer_hints, update_token: _, + output: _, }) if optimizer_hints.is_empty() => { assert_eq!( TableWithJoins { diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 8bdb1c205..9033efe00 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -2806,3 +2806,45 @@ fn test_exec_dynamic_sql() { .expect("EXEC (@sql) followed by DROP TABLE should parse"); assert_eq!(stmts.len(), 2); } + +// MSSQL OUTPUT clause on INSERT/UPDATE/DELETE +// https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql +#[test] +fn parse_mssql_insert_with_output() { + ms_and_generic().verified_stmt( + "INSERT INTO customers (name, email) OUTPUT INSERTED.id, INSERTED.name VALUES ('John', 'john@example.com')", + ); +} + +#[test] +fn parse_mssql_insert_with_output_into() { + ms_and_generic().verified_stmt( + "INSERT INTO customers (name, email) OUTPUT INSERTED.id, INSERTED.name INTO @new_ids VALUES ('John', 'john@example.com')", + ); +} + +#[test] +fn parse_mssql_delete_with_output() { + ms_and_generic().verified_stmt("DELETE FROM customers OUTPUT DELETED.* WHERE id = 1"); +} + +#[test] +fn parse_mssql_delete_with_output_into() { + ms_and_generic().verified_stmt( + "DELETE FROM customers OUTPUT DELETED.id, DELETED.name INTO @deleted_rows WHERE active = 0", + ); +} + +#[test] +fn parse_mssql_update_with_output() { + ms_and_generic().verified_stmt( + "UPDATE employees SET salary = salary * 1.1 OUTPUT INSERTED.id, DELETED.salary, INSERTED.salary WHERE department = 'Engineering'", + ); +} + +#[test] +fn parse_mssql_update_with_output_into() { + ms_and_generic().verified_stmt( + "UPDATE employees SET salary = salary * 1.1 OUTPUT INSERTED.id, DELETED.salary, INSERTED.salary INTO @changes WHERE department = 'Engineering'", + ); +} diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index b4ae764c2..541f7df6e 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -2671,6 +2671,7 @@ fn parse_update_with_joins() { limit: None, optimizer_hints, update_token: _, + output: _, }) if optimizer_hints.is_empty() => { assert_eq!( TableWithJoins { diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index f4b3a2826..434c5fd7b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5530,6 +5530,7 @@ fn test_simple_postgres_insert_with_alias() { has_table_keyword: false, on: None, returning: None, + output: None, replace_into: false, priority: None, insert_alias: None, @@ -5612,6 +5613,7 @@ fn test_simple_postgres_insert_with_alias() { has_table_keyword: false, on: None, returning: None, + output: None, replace_into: false, priority: None, insert_alias: None, @@ -5692,6 +5694,7 @@ fn test_simple_insert_with_quoted_alias() { has_table_keyword: false, on: None, returning: None, + output: None, replace_into: false, priority: None, insert_alias: None, diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index a8fa8db22..33c38fb0a 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -496,6 +496,7 @@ fn parse_update_tuple_row_values() { }, from: None, returning: None, + output: None, limit: None, update_token: AttachedToken::empty() }) From 16343865d3244642ec7ee0583b4e77b2c63622e7 Mon Sep 17 00:00:00 2001 From: "Guan-Ming (Wesley) Chiu" <105915352+guan404ming@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:01:07 +0800 Subject: [PATCH 2/2] Apply fix Co-authored-by: Ifeanyi Ubah Signed-off-by: Guan-Ming (Wesley) Chiu <105915352+guan404ming@users.noreply.github.com> --- src/ast/dml.rs | 3 +++ src/parser/merge.rs | 14 ++++++++++++++ src/parser/mod.rs | 24 +++--------------------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/ast/dml.rs b/src/ast/dml.rs index 553818ba5..e2c48885f 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -80,6 +80,7 @@ pub struct Insert { /// RETURNING pub returning: Option>, /// OUTPUT (MSSQL) + /// See pub output: Option, /// Only for mysql pub replace_into: bool, @@ -297,6 +298,7 @@ pub struct Delete { /// RETURNING pub returning: Option>, /// OUTPUT (MSSQL) + /// See pub output: Option, /// ORDER BY (MySQL) pub order_by: Vec, @@ -381,6 +383,7 @@ pub struct Update { /// RETURNING pub returning: Option>, /// OUTPUT (MSSQL) + /// See pub output: Option, /// SQLite-specific conflict resolution clause pub or: Option, diff --git a/src/parser/merge.rs b/src/parser/merge.rs index 03906819f..619be612b 100644 --- a/src/parser/merge.rs +++ b/src/parser/merge.rs @@ -218,6 +218,20 @@ impl Parser<'_> { self.parse_parenthesized_qualified_column_list(IsOptional::Optional, allow_empty) } + /// Parses an `OUTPUT` clause if present (MSSQL). + pub(super) fn maybe_parse_output_clause( + &mut self, + ) -> Result, ParserError> { + if self.parse_keyword(Keyword::OUTPUT) { + Ok(Some(self.parse_output( + Keyword::OUTPUT, + self.get_current_token().clone(), + )?)) + } else { + Ok(None) + } + } + pub(super) fn parse_output( &mut self, start_keyword: Keyword, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index aeff024d7..75450f75d 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13310,13 +13310,7 @@ impl<'a> Parser<'a> { let from = self.parse_comma_separated(Parser::parse_table_and_joins)?; - // MSSQL OUTPUT clause appears after FROM table, before USING/WHERE - // https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql - let output = if self.parse_keyword(Keyword::OUTPUT) { - Some(self.parse_output(Keyword::OUTPUT, self.get_current_token().clone())?) - } else { - None - }; + let output = self.maybe_parse_output_clause()?; let using = if self.parse_keyword(Keyword::USING) { Some(self.parse_comma_separated(Parser::parse_table_and_joins)?) @@ -17305,13 +17299,7 @@ impl<'a> Parser<'a> { Default::default() }; - // MSSQL OUTPUT clause appears between columns and source - // https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql - let output = if self.parse_keyword(Keyword::OUTPUT) { - Some(self.parse_output(Keyword::OUTPUT, self.get_current_token().clone())?) - } else { - None - }; + let output = self.maybe_parse_output_clause()?; let (source, assignments) = if self.peek_keyword(Keyword::FORMAT) || self.peek_keyword(Keyword::SETTINGS) @@ -17539,13 +17527,7 @@ impl<'a> Parser<'a> { self.expect_keyword(Keyword::SET)?; let assignments = self.parse_comma_separated(Parser::parse_assignment)?; - // MSSQL OUTPUT clause appears after SET, before FROM/WHERE - // https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql - let output = if self.parse_keyword(Keyword::OUTPUT) { - Some(self.parse_output(Keyword::OUTPUT, self.get_current_token().clone())?) - } else { - None - }; + let output = self.maybe_parse_output_clause()?; let from = if from_before_set.is_none() && self.parse_keyword(Keyword::FROM) { Some(UpdateTableFromKind::AfterSet(