Cannot use UPDATE with OUTPUT clause when a trigger is on the table
Asked Answered
S

6

108

I'm performing an UPDATE with OUTPUT query:

UPDATE BatchReports
SET IsProcessed = 1
OUTPUT inserted.BatchFileXml, inserted.ResponseFileXml, deleted.ProcessedDate
WHERE BatchReports.BatchReportGUID = @someGuid

This statement is well and fine; until a trigger is defined on the table. Then my UPDATE statement will get the error 334:

The target table 'BatchReports' of the DML statement cannot have any enabled triggers if the statement contains an OUTPUT clause without INTO clause

Now this problem is explained in a blog post by the SQL Server team -- UPDATE with OUTPUT clause – Triggers – and SQLMoreResults:

The error message is self-explanatory

And they also give solutions:

The application was changed to utilize the INTO clause

Except I cannot make head nor tail of the entirety of the blog post.

So let me ask my question: What should I change my UPDATE to so that it works?

See also

Stabilizer answered 2/11, 2012 at 15:38 Comment(0)
S
81

Given the kludge needed to make UPDATE with OUTPUT work in SQL Server 2008 R2, I changed my query from:

UPDATE BatchReports  
SET IsProcessed = 1
OUTPUT inserted.BatchFileXml, inserted.ResponseFileXml, deleted.ProcessedDate
WHERE BatchReports.BatchReportGUID = @someGuid

to:

SELECT BatchFileXml, ResponseFileXml, ProcessedDate FROM BatchReports
WHERE BatchReports.BatchReportGUID = @someGuid

UPDATE BatchReports
SET IsProcessed = 1
WHERE BatchReports.BatchReportGUID = @someGuid

Basically I stopped using OUTPUT. This isn't so bad as Entity Framework itself uses this very same hack!

Hopefully 2012 2014 2016 2018 2019 2020 will have a better implementation.


Update: using OUTPUT is harmful

The problem we started with was trying to use the OUTPUT clause to retrieve the "after" values in a table:

UPDATE BatchReports
SET IsProcessed = 1
OUTPUT inserted.LastModifiedDate, inserted.RowVersion, inserted.BatchReportID
WHERE BatchReports.BatchReportGUID = @someGuid

That then hits the well-know limitation ("won't-fix" bug) in SQL Server:

The target table 'BatchReports' of the DML statement cannot have any enabled triggers if the statement contains an OUTPUT clause without INTO clause

Workaround Attempt #1

So we try something where we will use an intermediate TABLE variable to hold the OUTPUT results:

DECLARE @t TABLE (
   LastModifiedDate datetime,
   RowVersion timestamp, 
   BatchReportID int
)
  
UPDATE BatchReports
SET IsProcessed = 1
OUTPUT inserted.LastModifiedDate, inserted.RowVersion, inserted.BatchReportID
INTO @t
WHERE BatchReports.BatchReportGUID = @someGuid

SELECT * FROM @t

Except that fails because you're not allowed to insert a timestamp into the table (even a temporary table variable).

Workaround Attempt #2

We secretly know that a timestamp is actually a 64-bit (aka 8 byte) unsigned integer. We can change our temporary table definition to use binary(8) rather than timestamp:

DECLARE @t TABLE (
   LastModifiedDate datetime,
   RowVersion binary(8), 
   BatchReportID int
)
  
UPDATE BatchReports
SET IsProcessed = 1
OUTPUT inserted.LastModifiedDate, inserted.RowVersion, inserted.BatchReportID
INTO @t
WHERE BatchReports.BatchReportGUID = @someGuid

SELECT * FROM @t

And that works, except that the value are wrong.

The timestamp RowVersion we return is not the value of the timestamp as it existed after the UPDATE completed:

  • returned timestamp: 0x0000000001B71692
  • actual timestamp: 0x0000000001B71693

That is because the values OUTPUT into our table are not the values as they were at the end of the UPDATE statement:

  • UPDATE statement starting
    • modifies row
      • timestamp is updated (e.g. 2 → 3)
    • OUTPUT retrieves new timestamp (i.e. 3)
    • trigger runs
      • modifies row again
        • timestamp is updated (e.g. 3 → 4)
  • UPDATE statement complete
  • OUTPUT returns 3 (the wrong value)

This means:

  • We do not get the timestamp as it exists at the end of the UPDATE statement (4)
  • Instead we get the timestamp as it was in the indeterminate middle of the UPDATE statement (3)
  • We do not get the correct timestamp

The same is true of any trigger that modifies any value in the row. The OUTPUT will not OUTPUT the value as of the end of the UPDATE.

This means you cannot trust OUTPUT to return any correct values ever.

This painful reality is documented in the BOL:

Columns returned from OUTPUT reflect the data as it is after the INSERT, UPDATE, or DELETE statement has completed but before triggers are executed.

How did Entity Framework solve it?

The .NET Entity Framework uses rowversion for Optimistic Concurrency. The EF depends on knowing the value of the timestamp as it exists after they issue an UPDATE.

Since you cannot use OUTPUT for any important data, Microsoft's Entity Framework uses the same workaround that I do:

Workaround #3 - Final - Do not use OUTPUT clause

In order to retrieve the after values, Entity Framework issues:

UPDATE [dbo].[BatchReports]
SET [IsProcessed] = @0
WHERE (([BatchReportGUID] = @1) AND ([RowVersion] = @2))

SELECT [RowVersion], [LastModifiedDate]
FROM [dbo].[BatchReports]
WHERE @@ROWCOUNT > 0 AND [BatchReportGUID] = @1

Don't use OUTPUT.

Yes it suffers from a race condition, but that's the best SQL Server can do.

What about INSERTs

Do what Entity Framework does:

SET NOCOUNT ON;

DECLARE @generated_keys table([CustomerID] int)

INSERT Customers (FirstName, LastName)
OUTPUT inserted.[CustomerID] INTO @generated_keys
VALUES ('Steve', 'Brown')

SELECT t.[CustomerID], t.[CustomerGuid], t.[RowVersion], t.[CreatedDate]
FROM @generated_keys AS g
   INNER JOIN Customers AS t
   ON g.[CustomerGUID] = t.[CustomerGUID]
WHERE @@ROWCOUNT > 0

Again, they use a SELECT statement to read the row, rather than placing any trust in the OUTPUT clause.

NOTE: In 2022, Entity Framework added a pre-mature optimization bug.

  • in the same way you have to disable any use of OUTPUT in T-SQL
  • you have to disable any use of OUTPUT by Entity Framework

Microsoft explains how to undo the bug added by #27372:

modelBuilder.Entity<Blog>().ToTable(tb => tb.UseSqlOutputClause(false));

With the down-side that you have to retroactively apply it to every table you have, or ever will have.

Stabilizer answered 2/11, 2012 at 16:4 Comment(15)
Ok everyone, how much does it SUCK that we have to resort to something like this? I'm working on a 350 line stored proc, and for testing purposes I had intended to put in OUTPUT statements. Of course I can't because of this bug. Dumb oversight Microsoft.Buatti
SQL Server 2014 and still gives the same error messageArabesque
No better in 2016.Praise
It's nice if you can switch your queries, but if you like me are mandated to use a 3d party DAL library which uses OUTPUT w/o INTO then you can only disable/re-enable or completely remove triggers.Vagabond
You can fix the race condition in the last workaround by putting the UPDATE and the SELECT in a transaction and a proper isolation level or a query hint in the UPDATE, at the cost of a slightly reduced concurrency.Everglades
What about INSERT, though? You wouldn't know the id of the row in order to SELECT it afterwards.Phalanx
@Phalanx Added to the answer the answer for INSERT. It's what Entity Framework does; they also gave up on using OUTPUT to output anything.Stabilizer
tldr: Your tldr is horribly wrong. What if this Columns returned from OUTPUT reflect the data as it is after the INSERT, UPDATE, or DELETE statement has completed but before triggers are executed. is fine for my scenario (I need the value of an AutoNumber column. The trigger is inserted related rows in other tables and has no effect on the primary target table, no even on its timestamp)? In fact your recommendation for INSERT does still use OUTPUTLetty
@BenVoigt (In fact your recommendation for INSERT does still use OUTPUT). It does use OUTPUT - but not to OUTPUT anything i want to output That's because (in general), you cannot use OUTPUT to output values you want to output. Yes, if i didn't have a trigger then i could risk it. But the lovely thing about this SQL Sever issue is that 7 years from now someone adds a trigger, and suddenly you'll get wrong values; in the worst case. In the best case the code will simply suddenly start crashing. So, in general, your answer is fragile and bad.Stabilizer
I don't get why the values from OUTPUT are wrong. The values I want to output are always the ones I put into my INSERT, I'm not interested in someone else's trigger modifying my results. timestamp/rowversion is no exception.Aniakudo
@Aniakudo The problem is that the rowversion you get is not the rowversion in the database. This is especially disastrous for optimistic concurrency (e.g. the kind used by Entity Framework). But forgetting the disaster: it's just not-sensical - i asked for the value "after" my CommandText ran - it does not return that. You don't care about consistency and integrity: you're fine - you don't have to worry about it.Stabilizer
for / after triggers are meant to run after the update, so the SQL Server behavior with ouptut and such triggers modifying the data after the fact sounds expected to me, and not a bug. I have never tested them, but have you tried using an instead of trigger? It seems meant to fully replace the insert/update operation, allowing to change the data as you wish, allowing to actually do one single change statement on the data. It seems to me using such triggers should be the way to go when we want to change the modified data with a trigger.Cath
@Frédéric I, like everyone else, expect OUTPUT to return the values as they after after the UPDATE statement has completed.Stabilizer
Is this going to b fixed in Entity Framework for SQL Server, or do we not upgrade to the newest version of EF?Gailey
@HrvojeBatrnek There is no bug in the Entity Framework. The Entity Framework is behaving exactly as it should.Stabilizer
I
57

To work around this restriction you need to OUTPUT INTO ... something. e.g. declare an intermediary table variable to be the target then SELECT from that.

DECLARE @T TABLE (
  BatchFileXml    XML,
  ResponseFileXml XML,
  ProcessedDate   DATE,
  RowVersion      BINARY(8) )

UPDATE BatchReports
SET    IsProcessed = 1
OUTPUT inserted.BatchFileXml,
       inserted.ResponseFileXml,
       deleted.ProcessedDate,
       inserted.Timestamp
INTO @T
WHERE  BatchReports.BatchReportGUID = @someGuid

SELECT *
FROM   @T 

As cautioned in the other answer if your trigger writes back to the rows modified by the UPDATE statement itself in such a way that it affects the columns that you are OUTPUT-ing then you may not find the results useful but this is only a subset of triggers. The above technique works fine in other cases, such as triggers recording to other tables for audit purposes, or returning inserted identity values even if the original row is written back to in the trigger.

Interrex answered 2/11, 2012 at 15:43 Comment(3)
This workaround fails when your @T table contains a timestamp column - for example when you need to return the rows new timestamp for optimistic locking. SQL Server does not let you specify the values for a timestamp column, even if the column is in a temporary table variable.Stabilizer
@IanBoyd just include a column list that omits the time stamp column. INTO @T (Foo, bar, Baz) or if you are in fact trying to populate it declare it as binary(8) in the table variable instead of timestamp.Interrex
@IanBoyd - I've rolled back your edit. If you want to editorialize do it in some other venue. Such as your own answer.Interrex
C
3

Why put all needed columns into table variable? We just need primary key and we can read all data after the UPDATE. There is no race when you use transaction:

DECLARE @t TABLE (ID INT PRIMARY KEY);

BEGIN TRAN;

UPDATE BatchReports SET 
    IsProcessed = 1
OUTPUT inserted.ID INTO @t(ID)
WHERE BatchReports.BatchReportGUID = @someGuid;

SELECT b.* 
FROM @t t JOIN BatchReports b ON t.ID = b.ID;

COMMIT;
Chinfest answered 13/8, 2018 at 9:14 Comment(2)
This is certainly another valid alternative; that also completely negates the virtue of OUTPUT clause. You could also rewrite it as: UPDATE BatchReports SET IsProcessed=1 WHERE BatchReportGUID = @someGuid; SELECT * FROM BatchReports WHERE BatchReportGUID = @someGuid;Stabilizer
But you may not have the luxury of updating based on the primary key. The OUTPUT clause still provides goodness.Deficit
C
2

In my case, I didn't have any OUTPUT statement in my query, but it raised the same error. Then I found this article from Microsoft released 27/03/2023: https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-7.0/breaking-changes#sqlserver-tables-with-triggers It is a new behavior of EF. So, I have added the following code in my DB Context file in OnModelCreating() method

modelBuilder.Entity<Table_Name>()
           .ToTable(tb => tb.HasTrigger("Table_Name_Trigger"));

It works find now.

Casino answered 13/6, 2023 at 5:8 Comment(1)
thanks for the link, I ended up using the 'BlankTriggerAddingConvention' workaround instead :)Addlebrained
D
0

If you are here just because you upgraded your application to EF7+ and you table has triggers associated with it and now you are facing this error.

Go with 'BlankTriggerAddingConvention' method. Mentioned in EF7 Breaking Change

Thanks to #Pellet for mentioning in the comments.

Dhar answered 22/3 at 3:15 Comment(0)
N
-1

Triggers are a code smell that can silently assassinate other parts of code. Use stored procedures instead. Instead Of triggers are even worse. So, if you run into an Instead Of trigger, Output won't work even with an INTO, and neither will Scope_Identity.
For example:

CREATE   Trigger [DI].[TRG_AJC_BeforeInsert]
On [DI].[AJC]
INSTEAD OF INSERT
As
Set NoCount on
Insert Into [DI].[AJC] (val1, date2)
From Select [val1], getDate()

will return no results for Output or ScopeIdentity. No results for Scope Identity because it hasn't been created yet, and no results for Output because it wasn't created at the time the OUTPUT was read.

Your only hope is that somehow the trigger didn't do another insert in another table. Then you can use @@identity. But if another table was updated/inserted during that session, you will now have @@identity returning that other table's identity, not the ScopedIdentity you were looking for.

Nonagon answered 6/10, 2022 at 20:28 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.