diff --git a/src/ast/dml.rs b/src/ast/dml.rs index a0be916de..f75a2c661 100644 --- a/src/ast/dml.rs +++ b/src/ast/dml.rs @@ -77,6 +77,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 @@ -196,6 +198,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)?; @@ -282,6 +289,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) @@ -307,6 +316,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")?; @@ -360,6 +373,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 @@ -389,6 +404,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")?; @@ -710,11 +729,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 128fe01be..3bbbcb82d 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 984e384fd..780025b6c 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -1780,6 +1780,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 16eb7a8b1..717f2a9ef 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13288,6 +13288,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 { @@ -13326,6 +13335,7 @@ impl<'a> Parser<'a> { using, selection, returning, + output, order_by, limit, })) @@ -17238,10 +17248,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)?; @@ -17258,6 +17268,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) { @@ -17268,7 +17286,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() { @@ -17370,6 +17395,7 @@ impl<'a> Parser<'a> { has_table_keyword: table, on, returning, + output, replace_into, priority, insert_alias, @@ -17475,6 +17501,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()?, @@ -17505,6 +17540,7 @@ impl<'a> Parser<'a> { from, selection, returning, + output, or, limit, } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index a3b5404d3..40506ce85 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 b5fd1e77e..1fac31023 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -2730,3 +2730,46 @@ fn parse_mssql_tran_shorthand() { // ROLLBACK TRAN normalizes to ROLLBACK (same as ROLLBACK TRANSACTION) ms().one_statement_parses_to("ROLLBACK TRAN", "ROLLBACK"); } + +// 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 30405623d..4abd691e9 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -2662,6 +2662,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 03517876d..a7498d49b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -5488,6 +5488,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, @@ -5567,6 +5568,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, @@ -5644,6 +5646,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() })