This is an automated email from the ASF dual-hosted git repository.

github-bot pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 31e19429 MSSQL: Add support for OUTPUT clause on INSERT/UPDATE/DELETE 
(#2228)
31e19429 is described below

commit 31e19429a779793c1ee0338e658d76de053cd203
Author: Guan-Ming (Wesley) Chiu <[email protected]>
AuthorDate: Fri Feb 27 22:57:02 2026 +0800

    MSSQL: Add support for OUTPUT clause on INSERT/UPDATE/DELETE (#2228)
    
    Signed-off-by: Guan-Ming Chiu <[email protected]>
    Signed-off-by: Guan-Ming (Wesley) Chiu 
<[email protected]>
    Co-authored-by: Ifeanyi Ubah <[email protected]>
---
 src/ast/dml.rs              | 26 ++++++++++++++++++++++++--
 src/ast/spans.rs            |  8 +++++++-
 src/dialect/snowflake.rs    |  1 +
 src/keywords.rs             |  2 ++
 src/parser/merge.rs         | 16 +++++++++++++++-
 src/parser/mod.rs           | 24 +++++++++++++++++++++---
 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, 119 insertions(+), 7 deletions(-)

diff --git a/src/ast/dml.rs b/src/ast/dml.rs
index 06f731c5..e2c48885 100644
--- a/src/ast/dml.rs
+++ b/src/ast/dml.rs
@@ -79,6 +79,9 @@ pub struct Insert {
     pub on: Option<OnInsert>,
     /// RETURNING
     pub returning: Option<Vec<SelectItem>>,
+    /// OUTPUT (MSSQL)
+    /// See 
<https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql>
+    pub output: Option<OutputClause>,
     /// Only for mysql
     pub replace_into: bool,
     /// Only for mysql
@@ -203,6 +206,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 +297,9 @@ pub struct Delete {
     pub selection: Option<Expr>,
     /// RETURNING
     pub returning: Option<Vec<SelectItem>>,
+    /// OUTPUT (MSSQL)
+    /// See 
<https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql>
+    pub output: Option<OutputClause>,
     /// ORDER BY (MySQL)
     pub order_by: Vec<OrderByExpr>,
     /// LIMIT (MySQL)
@@ -314,6 +325,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 +382,9 @@ pub struct Update {
     pub selection: Option<Expr>,
     /// RETURNING
     pub returning: Option<Vec<SelectItem>>,
+    /// OUTPUT (MSSQL)
+    /// See 
<https://learn.microsoft.com/en-us/sql/t-sql/queries/output-clause-transact-sql>
+    pub output: Option<OutputClause>,
     /// SQLite-specific conflict resolution clause
     pub or: Option<SqliteOnConflict>,
     /// LIMIT
@@ -396,6 +414,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 +739,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)
+/// 
<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 43005cfb..dd62c5ba 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 a9d71fc4..f756c415 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 cc2b9e9d..80f679c0 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 a927bc4b..619be612 100644
--- a/src/parser/merge.rs
+++ b/src/parser/merge.rs
@@ -218,7 +218,21 @@ impl Parser<'_> {
         self.parse_parenthesized_qualified_column_list(IsOptional::Optional, 
allow_empty)
     }
 
-    fn parse_output(
+    /// Parses an `OUTPUT` clause if present (MSSQL).
+    pub(super) fn maybe_parse_output_clause(
+        &mut self,
+    ) -> Result<Option<OutputClause>, 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,
         start_token: TokenWithSpan,
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index bc91213f..75450f75 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -13309,6 +13309,9 @@ impl<'a> Parser<'a> {
         };
 
         let from = self.parse_comma_separated(Parser::parse_table_and_joins)?;
+
+        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)?)
         } else {
@@ -13347,6 +13350,7 @@ impl<'a> Parser<'a> {
             using,
             selection,
             returning,
+            output,
             order_by,
             limit,
         }))
@@ -17275,10 +17279,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 +17299,8 @@ impl<'a> Parser<'a> {
                     Default::default()
                 };
 
+                let output = self.maybe_parse_output_clause()?;
+
                 let (source, assignments) = if 
self.peek_keyword(Keyword::FORMAT)
                     || self.peek_keyword(Keyword::SETTINGS)
                 {
@@ -17305,7 +17311,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 +17420,7 @@ impl<'a> Parser<'a> {
                 has_table_keyword: table,
                 on,
                 returning,
+                output,
                 replace_into,
                 priority,
                 insert_alias,
@@ -17512,6 +17526,9 @@ impl<'a> Parser<'a> {
         };
         self.expect_keyword(Keyword::SET)?;
         let assignments = 
self.parse_comma_separated(Parser::parse_assignment)?;
+
+        let output = self.maybe_parse_output_clause()?;
+
         let from = if from_before_set.is_none() && 
self.parse_keyword(Keyword::FROM) {
             Some(UpdateTableFromKind::AfterSet(
                 self.parse_table_with_joins()?,
@@ -17542,6 +17559,7 @@ impl<'a> Parser<'a> {
             from,
             selection,
             returning,
+            output,
             or,
             limit,
         }
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index ad7697a7..7bf27640 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 8bdb1c20..9033efe0 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', '[email protected]')",
+    );
+}
+
+#[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', '[email protected]')",
+    );
+}
+
+#[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 b4ae764c..541f7df6 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 f4b3a282..434c5fd7 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 a8fa8db2..33c38fb0 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()
         })


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to