The Right and Wrong of T-SQL DML TRIGGERs (SQL Spackle)

,

"SQL Spackle" is a collection of short articles written based on multiple requests for similar code. These short articles are NOT meant to be complete solutions. Rather, they are meant to "fill in the cracks".

--Phil McCracken

Whenever I am given the exciting task of looking into a new database, one of the first things that I look at is Data Manipulation Language (DML) defined triggers.  I am not sure quite why I do so, but learning about a new database is kind of like peeling an onion.  You’ve got to start somewhere.  Sometimes to my chagrin, the more layers I peel off, the more depressing it gets.  Today we’ll describe one example that can lead me to that.

Finding the triggers in a database is relatively simple using the following query, which also shows the parent table on which they are defined.

SELECT a.[object_id], b.name, b.[Type], b.type_desc, b.modify_date
    ,[Tigger On]=c.type_desc
    ,[Trigger Parent]=c.name
FROM sys.sql_modules a
JOIN sys.all_objects b ON a.[object_id] = b.[object_id]
JOIN sys.all_objects c ON b.[parent_object_id] = c.[object_id]
WHERE b.[Type] = 'TR'
ORDER BY b.[Type], b.name;

Note that this searches all databases in your SQL Server instance, so you may want to limit the results to just one specific database.

Sample Tables and Data to get us Started

In order to create a trigger you must first have a table.  In our case, we’ll borrow a couple of sample tables and data from a prior article kindly published earlier by SSC (How to Design, Build and Test a Dynamic Search Stored Procedure).

CREATE TABLE dbo.Shipments (
   Consignment_No     VARCHAR(30) NOT NULL
  ,ReferenceNo        VARCHAR(30) NULL
  ,CustID             VARCHAR(20) NULL
  ,Total_Pkgs         INT NOT NULL
  ,Last_Status        VARCHAR(12) NULL
  ,Last_Status_DT     DATETIME NULL
  ,PRIMARY KEY CLUSTERED ( Consignment_No )
 );
CREATE TABLE Shipment_History (
   Consignment_No      VARCHAR(30) NOT NULL FOREIGN KEY REFERENCES Shipments ( Consignment_No )
  ,Status_DT          DATETIME NOT NULL
  ,Status_Code        VARCHAR(12) NOT NULL
  ,Pkg_No             INT NOT NULL DEFAULT ( 0 )
  ,PRIMARY KEY CLUSTERED ( Consignment_No, Status_Code, Status_DT, Pkg_No )
 );
GO
-- Clean up your sandbox 
--DROP TABLE dbo.Shipment_History; 
--DROP TABLE dbo.Shipments;

Here is some sample data to populate the Shipments table, which you can add immediately if you are following along.

-- 8 consignments for two different customers. 
-- Some shipments have no customer yet assigned. 
INSERT INTO dbo.Shipments 
 ( Consignment_No, ReferenceNo, CustID, Total_Pkgs )
 VALUES  ( '8033701', 'PO 12345', 'ACME', 1 )
        ,( '0152037', NULL, 'ACME', 1 )
        ,( '4292860', NULL, 'ATLAS', 1 )
        ,( '0806473', 'INV 43523', 'ATLAS', 1 )
        ,( '6289811', NULL, 'APPLE', 1 )
        ,( '0642191', 'SO 1111', 'APPLE', 1 )
        ,( '8363496', NULL, NULL, 1 ) 
        ,( '7271931', NULL, NULL, 1 );
SELECT *
 FROM dbo.Shipments;

And some additional data to populate the Shipment_History table.  Before you run this however, let’s take a look at what columns are present in the Shipments table.

-- Some history records for these shipments
INSERT INTO dbo.Shipment_History 
  ( Consignment_No, Status_Code, Status_DT, Pkg_No )
 VALUES ( '8033701', 'SIP', '2013-10-19 22:19', 1 )
       ,( '8033701', 'WGT', '2013-10-20 18:47', 1 )
       ,( '8033701', 'MDE', '2013-10-21 10:47', 0 )
       ,( '8033701', 'OPS', '2013-10-21 21:43', 0 )
       ,( '8033701', 'POD', '2013-10-22 02:44', 0 )
       ,( '0152037', 'SIP', '2013-10-23 00:31', 1 )
       ,( '0152037', 'WGT', '2013-10-23 17:03', 1 )
       ,( '0152037', 'MDE', '2013-10-23 20:12', 0 )
       ,( '4292860', 'SIP', '2013-10-23 21:00', 1 )
       ,( '4292860', 'MDE', '2013-10-23 22:06', 0 )
       ,( '4292860', 'POD', '2013-10-24 10:54', 0 )
       ,( '0806473', 'SIP', '2013-10-25 04:16', 1 )
       ,( '0806473', 'MDE', '2013-10-25 08:49', 0 )
       ,( '6289811', 'SIP', '2013-10-25 14:28', 1 )
       ,( '6289811', 'WGT', '2013-10-26 05:35', 1 )
       ,( '6289811', 'OPS', '2013-10-26 20:08', 0 )
       ,( '6289811', 'POD', '2013-10-27 14:45', 0 )
       ,( '0642191', 'SIP', '2013-10-28 06:06', 1 )
       ,( '0642191', 'MDE', '2013-10-28 13:11', 0 )
       ,( '8363496', 'SIP', '2013-10-29 00:36', 1 )
       ,( '7271931', 'SIP', '2013-10-29 04:01', 1 )
       ,( '7271931', 'WGT', '2013-10-29 11:37', 1 );
SELECT *
 FROM dbo.Shipment_History;
SELECT *
 FROM dbo.Shipments;

If we look at the columns inserted into the Shipments table, two of the tables’ columns (Last_Status and Last_Status_DT) are not populated.  The reason is that this data is de-normalized from the information in the Shipment_History table.  I will not argue with the pundits that will say that you should not de-normalize data in this fashion, except to say that there are cases where it is appropriate to do so and one of those cases is for performance reasons.  The latter is the case here, were we to examine the real life scenario on which this sample data is based.

We have not shown the results of only creating the rows in the Shipments table, but the important thing to know is that those two columns that were not included in the insert will be NULL for all rows.

The Wrong Way to Write a TRIGGER

Oftentimes when I look into the TRIGGERs in a database, I’ll see one that looks something like this.

CREATE TRIGGER dbo.Shipment_History_INS ON dbo.Shipment_History AFTER INSERT AS BEGIN
DECLARE   @Consignment_No VARCHAR(30)
         ,@Status_Code       VARCHAR(12)
         ,@Last_Status_DT    DATETIME;
SELECT @Consignment_No=Consignment_No
      ,@Status_Code=Status_Code
      ,@Last_Status_DT=Status_DT
 FROM INSERTED;
UPDATE dbo.Shipments
  SET Last_Status     = @Status_Code
     ,Last_Status_DT = @Last_Status_DT
  WHERE Consignment_No = @Consignment_No;
END
GO

This is what I call a “single-use” trigger, but you also may hear it referred to as a “single-row” trigger  Ultimately, the correct terminology for it is a “totally wrongly-written” TRIGGER.  It is a blatantly RBAR-esque expression of T-SQL.  In case you’ve never heard the term “RBAR” before, this stands for “Row-By-Agonizing Row” and is a Moden-ism (a term coined by SQL MVP Jeff Moden).  Its meaning should be pretty clear, but essentially what it means is that SQL queries should be set-based and not be based on processing one row at a time.

This trigger has multiple problems:

  • If status codes are not processed in the order that they are received, i.e., oldest to newest, the last status code isn’t necessarily what is put on the Shipments table record.
  • It simply doesn’t work properly if more than one status code is inserted in one SQL query.

Microsoft SQL Server fires a trigger (in this case after an INSERT) once.  When you insert many records in one SQL query, the trigger still fires only once.  In that case, the INSERTED virtual table available in the trigger and referenced in the sample code above contains many rows.  The select into the local variables then will assign only one of those rows to data captured in the local variables, and since there is no ordering inherent in the INSERTED table’s results you can’t predict which row that will be.

Let’s now see what happens when we insert the data into our Shipment_History table, using the code that we suggested to defer running.  The result of the final select looks like this:

Consignment_No  ReferenceNo  CustID   Total_Pkgs  Last_Status  Last_Status_DT
0152037         NULL         ACME     1           NULL         NULL
0642191         SO 1111      APPLE    1           NULL         NULL
0806473         INV 43523    ATLAS    1           NULL         NULL
4292860         NULL         ATLAS    1           NULL         NULL
6289811         NULL         APPLE    1           NULL         NULL
7271931         NULL         NULL     1           NULL         NULL
8033701         PO 12345     ACME     1           SIP          2013-10-19 22:19:00.000
8363496         NULL         NULL     1           NULL         NULL

The key thing to observe in the results set is that only consignment 8033701 has non-NULL value for Last_Status and Last_Status_DT, because this “totally wrongly-written” trigger was only run once and only updated a single row in the Shipments table.

The wrong assumption used when a developer created this trigger is that any inserts to the Shipment_History table will comprise a single row.  While that may be true at the beginning of an application’s life, and no issues are initially detected with a trigger of this nature, you can certainly bet that at some point in time someone’s going to come along and decide that it is more efficient to insert many history records in one insert, and break this trigger!

Right now, experienced T-SQL developers will probably be thinking “oh come on, nobody writes triggers this way!”  My answer to that is that I see it all the time in sample code posted to the forums, so yes people still do write triggers that way, most probably due to lack of understanding or the RBAR-esque thinking I alluded to earlier.  This article is intended for those folks that have never really understood triggers in this way and need an example to guide them.

The Right Way to Write a Trigger

Here is a trigger that makes use of all the rows kindly provided to us in the INSERTED virtual table.

ALTER TRIGGER dbo.Shipment_History_INS ON dbo.Shipment_History
AFTER INSERT AS
BEGIN
    WITH LastStatus AS
    (
        SELECT Consignment_No, Status_Code, Status_DT
        FROM
        (
            SELECT Consignment_No, Status_Code, Status_DT
                ,rn=ROW_NUMBER() OVER (PARTITION BY Consignment_No ORDER BY Status_DT DESC)
            FROM INSERTED
        ) a
        WHERE rn=1
    )
    UPDATE a
    SET Last_Status = Status_Code
        ,Last_Status_DT = Status_DT
    FROM dbo.Shipments a
    JOIN LastStatus b ON a.Consignment_No = b.Consignment_No;
END
GO

This trigger also ensures that the latest effective date present in the INSERTED table’s rows, is the status code and date/time that is applied to the Shipments table row.  This is identified by using the ROW_NUMBER() (where it =1) and sorting by Status_DT descending.

In order to further our understanding, below are the results returned by just the LastStatus CTE:

Consignment_No  Status_Code  Status_DT
0152037         MDE          2013-10-23 20:12
0642191         MDE          2013-10-28 13:11
0806473         MDE          2013-10-25 08:49
4292860         POD          2013-10-24 10:54
6289811         POD          2013-10-27 14:45
7271931         WGT          2013-10-29 11:37
8033701         POD          2013-10-22 02:44
8363496         SIP          2013-10-29 00:36

You may be wondering how exactly I knew that.  Well, you can easily simulate the INSERTED virtual table using a CTE to debug code in your TRIGGER, like the following query which takes the VALUES clause from the INSERT statement provided earlier.

WITH INSERTED AS
(
    SELECT Consignment_No, Status_Code, Status_DT, Pkg_No
    FROM
    (
        VALUES ( '8033701', 'SIP', '2013-10-19 22:19', 1 )   
            ,( '8033701', 'WGT', '2013-10-20 18:47', 1 )  
            ,( '8033701', 'MDE', '2013-10-21 10:47', 0 )   
            ,( '8033701', 'OPS', '2013-10-21 21:43', 0 )    
            ,( '8033701', 'POD', '2013-10-22 02:44', 0 )   
            ,( '0152037', 'SIP', '2013-10-23 00:31', 1 )   
            ,( '0152037', 'WGT', '2013-10-23 17:03', 1 )   
            ,( '0152037', 'MDE', '2013-10-23 20:12', 0 )   
            ,( '4292860', 'SIP', '2013-10-23 21:00', 1 )   
            ,( '4292860', 'MDE', '2013-10-23 22:06', 0 )   
            ,( '4292860', 'POD', '2013-10-24 10:54', 0 )   
            ,( '0806473', 'SIP', '2013-10-25 04:16', 1 )    
            ,( '0806473', 'MDE', '2013-10-25 08:49', 0 )   
            ,( '6289811', 'SIP', '2013-10-25 14:28', 1 )  
            ,( '6289811', 'WGT', '2013-10-26 05:35', 1 )   
            ,( '6289811', 'OPS', '2013-10-26 20:08', 0 )   
            ,( '6289811', 'POD', '2013-10-27 14:45', 0 )   
            ,( '0642191', 'SIP', '2013-10-28 06:06', 1 )  
            ,( '0642191', 'MDE', '2013-10-28 13:11', 0 )  
            ,( '8363496', 'SIP', '2013-10-29 00:36', 1 )   
            ,( '7271931', 'SIP', '2013-10-29 04:01', 1 )  
            ,( '7271931', 'WGT', '2013-10-29 11:37', 1 )
    ) a (Consignment_No, Status_Code, Status_DT, Pkg_No )
),
    LastStatus AS
(
    SELECT Consignment_No, Status_Code, Status_DT
    FROM
    (
        SELECT Consignment_No, Status_Code, Status_DT
            ,rn=ROW_NUMBER() OVER (PARTITION BY Consignment_No ORDER BY Status_DT DESC)
        FROM INSERTED
    ) a
    WHERE rn=1
)
SELECT *
FROM LastStatus;

That’s a trick you’ll probably want to remember.  Now after truncating the Shipment_History table and rerunning the INSERT statement, the final results in the Shipments table looks like this:

Consignment_No  ReferenceNo  CustID  Total_Pkgs  Last_Status  Last_Status_DT
0152037         NULL         ACME    1           MDE          2013-10-23 20:12:00.000
0642191         SO 1111      APPLE   1           MDE          2013-10-28 13:11:00.000
0806473         INV 43523    ATLAS   1           MDE          2013-10-25 08:49:00.000
4292860         NULL         ATLAS   1           POD          2013-10-24 10:54:00.000
6289811         NULL         APPLE   1           POD          2013-10-27 14:45:00.000
7271931         NULL         NULL    1           WGT          2013-10-29 11:37:00.000
8033701         PO 12345     ACME    1           POD          2013-10-22 02:44:00.000
8363496         NULL         NULL    1           SIP          2013-10-29 00:36:00.000

Trigger Types and the Associated Virtual Tables

There are two types of triggers in T-SQL: AFTER and INSTEAD OF.

  • AFTER triggers arguably the more common of the two) fire after the work of the query is done.
  • INSTEAD OF triggers, as the type implies, fire instead of the work being done by the query.

DML triggers in T-SQL can fire on any or all of the following events:

  • INSERT
  • UPDATE
  • DELETE

An individual trigger may fire on one, two or all three of those events.  A MERGE statement may fire a trigger more than once, depending on which of the three directives is present in its syntax (recall that you can use INSERT, UPDATE and DELETE directives in a MERGE), or it may fire various triggers if there is a different one for each event.

The two virtual tables available to you while any DML trigger is firing are:

  • INSERTED – shown in the example above.
  • DELETED – includes rows being deleted.

While both virtual tables are available in any trigger, sometimes they are empty (devoid of rows):

  • INSERTED – no rows present on DELETE events.
  • DELETED – no rows present on INSERT events.

On an UPDATE event, there will be an equivalent number of rows in each virtual table, with the DELETED table containing the old values (before the UPDATE) and the INSERTED table containing the new value (after the UPDATE).  This may sound a little counterintuitive, but trust me when I say it can be extremely handy!

Some Other Helpful Hints and Notes

Here are some other helpful hints and notations on the use of DML triggers:

  • When using the SELECT/INTO query context, T-SQL fires no triggers for the obvious reason that this creates the target table so at the time the statement executes there cannot be a trigger to fire.  Another way of looking at this is that triggers don’t fire on SELECT statements, and effectively SELECT/INTO is a special case of a SELECT statement.
  • A trigger should always be minimalistic in the sense that it should only do the work it needs to do and no more.  Overloading triggers with all kinds of stuff that can be done elsewhere can cause serious performance problems.
  • When you CREATE or ALTER a trigger, just like any other stored procedure, SQL Server performs deferred names resolution.  So if you reference a table that doesn’t exist at the time you CREATE/ALTER, you will not know about that until the trigger is actually fired.  In other words, the CREATE/ALTER will not throw an error.
  • While there may be more than one After trigger specified for a specific event, the order that the TRIGGERs fire can only be partially controlled.  You can specify which fires first and which fires last using sp_settriggerorder.
  • Triggers fire only after the underlying SQL executes successfully, including after all constraint checks have been verified.  Note that constraints checks are not done prior to running INSTEAD OF triggers.
  • Normally if you execute a standalone (“atomic”) INSERT, UPDATE, DELETE or MERGE statement you don’t really need to put it into a tranaction (you can).  However if any of those statements causes a trigger to fire, it can no longer be considered atomic and you should put it into a transaction, and use the proper error handling and rollback mechanism in the event that an error occurs during the action of the trigger.

This really only scratches the surface of all the rules for using triggers, so I suggest you consult Microsoft Books-on-Line if you’d like to know more.  It should be enough to get you started though.

Conclusions

Triggers are a remarkable tool that can make de-normalizing data when it is appropriate to do so very simple.  They can also do many other things, and they can be overused and improperly used, but our hope in this spackle article is that you’ll now understand the right and the wrong ways to write them.

Dwain Camps

SQL Enthusiast

Follow me on Twitter: @DwainCSQL

© Copyright Dwain Camps Mar 5, 2015 All Rights Reserved

Rate

4.67 (30)

Share

Share

Rate

4.67 (30)