Jan Schreuder on .Net

.Net code samples, experiences, observations

View my professional profile on LinkedIn

Recent Posts

Tags

News

  • Inappropriate comments will be deleted at my discretion.

    The information and code samples in this weblog is provided "AS IS" without warranty of any kind, either expressed or implied, including but not limited to the merchantability and/or fitness for a particular purpose.

Community

Email Notifications

Tool suppliers

Tools

General

Microsoft

Favorite blogs

Archives


Keep track of data changes using a SQL trigger

In my current project, we calculate the status of tasks that a user needs to do and store the status in a table. This happens on a daily basis. Once every week, we calculate which items are overdue and inform the user by email to take appropriate action. However, sometimes our users complain that they receive such an email and that there aren't any tasks that require immediate action.

We know that this happens, but since we calculate the status once per day and when the user logs on to the application, we cannot really see why this happens. What we needed was a way to see when a tasks moves to another state. We decided to create a history table which contains the information in our status table and update that history table using a trigger. But the examples we found in Books on Line and other resources on the web just didn't give us the information we needed.

So I called my good friend Patrick Wellink who I know to be a SQL guru. He had written a generic trigger which can be used to audit changes and posted this on SqlServerCentral.Com. The script looks like this:

CREATE TRIGGER TRG_##YOUR_TABLE##
ON [DBO].[##YOUR_TABLE##]
FOR DELETE,INSERT,UPDATE
AS
-- JUST CHANGE ##YOUR_TABLE## INTO YOUR OWN TABLENAME TO MAKE IT WORK 
DECLARE @ACT CHAR(6)
DECLARE @DEL BIT
DECLARE @INS BIT 
DECLARE @SQLSTRING VARCHAR(2000)
 
SET @DEL = 0
SET @INS = 0
 
IF EXISTS (SELECT TOP 1 1 FROM DELETED) SET @DEL=1
IF EXISTS (SELECT TOP 1 1 FROM INSERTED) SET @INS = 1 
 
IF @INS = 1 AND @DEL = 1 SET @ACT = 'UPDATE'
IF @INS = 1 AND @DEL = 0 SET @ACT = 'INSERT'
IF @DEL = 1 AND @INS = 0 SET @ACT = 'DELETE'
 
IF @INS = 0 AND @DEL = 0 RETURN
 
IF NOT EXISTS (SELECT * FROM SYSOBJECTS WHERE ID = OBJECT_ID(N'[DBO].[AUDIT_##YOUR_TABLE##]') AND OBJECTPROPERTY(ID, N'ISUSERTABLE') = 1)
BEGIN
    -- CREATE A MEMORY TABLE CONTAINING THE FIELDS AND TYPES OF THE TABLE
    DECLARE @MEMTABLE TABLE
    ( 
        ID INT IDENTITY
        ,COLUMNAME SYSNAME
        ,TYPENAME VARCHAR(20)
     )
    -- INSERT THE COLUMNAMES AND THE DATATYPES
    INSERT @MEMTABLE 
        (COLUMNAME,TYPENAME) 
        SELECT NAME,TYPE_NAME(XTYPE) 
        FROM SYSCOLUMNS 
        WHERE ID = OBJECT_ID('[DBO].[##YOUR_TABLE##]') 
        ORDER BY COLID
 
    DECLARE @CUR INTEGER
    DECLARE @MAX INTEGER
    DECLARE @SQLSTR AS VARCHAR(8000)
    DECLARE @CURCOL SYSNAME
    DECLARE @COLTYPE AS VARCHAR(10)
 
    -- SETUP VARIABLES
    SET @SQLSTR = ''
    SET @CUR=1
    SELECT @MAX = MAX(ID) FROM @MEMTABLE
 
    -- LOOP EVEY FIELD
    WHILE @CUR <= @MAX
    BEGIN
        -- GET VALUES FROM THE MEMTABLE    
        SELECT @CURCOL = COLUMNAME,@COLTYPE = TYPENAME FROM @MEMTABLE WHERE ID = @CUR
        IF @COLTYPE = 'INT' OR @COLTYPE = 'BIGINT' OR @COLTYPE='UNIQUEIDENTIFIER'
            -- WE DO WANT TO COPY INT/BIGINT/UNIQUEIDENTIFIER FIELDS BUT IF THEY ARE AN 
            -- IDENTITY OR A ROWGUIDCOLUMN WE DO NOT WANT TO COPY THAT ATTRIBUTES 
            SET @SQLSTR = @SQLSTR + ' CAST('+@CURCOL + ' AS '+@COLTYPE+') AS [' + @CURCOL +'] '
        ELSE
            -- ANOTHER FIELD DO NOTHING JUST COPY IT AS IT IS
            SET @SQLSTR = @SQLSTR + ' '+@CURCOL + ' AS [' + @CURCOL +'] '
        IF @CUR <= @MAX - 1 SET @SQLSTR=@SQLSTR + ','
        SET @CUR = @CUR + 1
    END
    -- ADD THE AUDIT FIELDS
    SET @SQLSTR = @SQLSTR +',CAST(''      '' AS CHAR(6)) AS TRG_ACTION,CAST(GETDATE() AS DATETIME) AS TRG_DATE'
    -- SET UP THE SELECT FOR CREATING THE AUDIT TABLE
    SET @SQLSTR = 'SELECT TOP 0 ' + @SQLSTR + ' INTO [DBO].[AUDIT_##YOUR_TABLE##] FROM [DBO].[##YOUR_TABLE##]'
    EXEC(@SQLSTR)
END
 
IF @ACT = 'INSERT' INSERT [DBO].[AUDIT_##YOUR_TABLE##] SELECT *,'INSERT' ,GETDATE() FROM INSERTED
IF @ACT = 'DELETE' INSERT [DBO].[AUDIT_##YOUR_TABLE##] SELECT *,'DELETE' ,GETDATE() FROM DELETED
IF @ACT = 'UPDATE' INSERT [DBO].[AUDIT_##YOUR_TABLE##] SELECT *,'UPDATE' ,GETDATE() FROM INSERTED

It does almost everything we needed. It copies the information to a new table when something happens to the row in the original table. Thanks Patrick for helping us with this script.

We modified the code for the update section, because we only wanted to store the old information in the AUDIT table when certain fields are modified:

INSERT [DBO].[AUDIT_##YOUR_TABLE##] 
SELECT DELETED.*,GETDATE()
FROM INSERTED
INNER JOIN DELETED ON DELETED.StatusID = INSERTED.StatusID 
WHERE (INSERTED.Status <> DELETED.STATUS) 
OR (INSERTED.CountAlerts <> DELETED.CountAlerts) 
OR (INSERTED.AlertViewed <> DELETED.AlertViewed)

This way, we get the information as it was before the update and only when the fields that we want to monitor are changed. Works great!

Comments

Gary said:

This is great!  Thanks

# March 5, 2008 7:17 PM

Larz said:

Wonderful, just wonderful. Thanks for sharing this, it's been a great help.

# July 25, 2008 9:28 PM

Kuhl said:

Just wanted to know...what is this "SELECT TOP 1 1 FROM DELETED" in the trigger....what does it do......

# June 19, 2009 4:45 PM

Jan Schreuder said:

What that does is returning 1 when there is at least one entry in the Delete table. It's an indication that data is removed from the table on which you have created the trigger.

# June 23, 2009 2:54 PM

Alex Paranko said:

What if a new field is added to a table?

# August 13, 2009 4:30 PM

Jan Schreuder said:

Then this version of the trigger is going to break. You have to consider two options here.

You could modify the script to select the columns by name, rather then a simple *. When you add a column, the trigger will not break, but changes made to the new column will not be audited.

Or leave it as it is, and ensure you add the new column to both tables. You'd have to make sure the columns in both tables are in identical order.

# August 13, 2009 9:36 PM

Alex Paranko said:

Having to maintain two set of tables could be a headache.

I just modified the trigger so now it can create and audit new columns on the fly. I removed both "update" and "insert" logic since I'm only using on-delete

There are still some issues, like if TRG_ACTION and TRG_DATE are removed from the audit table, or if the datatype is changed in a column.  But that could be easily added.

Thank you very much for the code.

CREATE TRIGGER ##YOUR_TABLE##_Delete

ON [DBO].[##YOUR_TABLE##]

AFTER DELETE

AS

-- JUST CHANGE ##YOUR_TABLE## INTO YOUR OWN TABLENAME TO MAKE IT WORK

DECLARE @SQLSTRING VARCHAR(2000)

DECLARE @SQLColumns VARCHAR(2000)

DECLARE @CUR INTEGER

DECLARE @MAX INTEGER

DECLARE @SQLSTR AS VARCHAR(8000)

DECLARE @CURCOL SYSNAME

DECLARE @COLUMN_NAME SYSNAME

DECLARE @COLUMN_TYPE AS VARCHAR(10)

DECLARE @COLUMN_LENGTH INT

DECLARE @MEMTABLE TABLE

(

    ID INT

   ,COLUMN_NAME SYSNAME

   ,COLUMN_TYPE VARCHAR(20)

,COLUMN_LENGTH int

)

IF NOT EXISTS (SELECT * FROM SYSOBJECTS WHERE ID = OBJECT_ID(N'[DBO].[AUDIT_##YOUR_TABLE##]') AND OBJECTPROPERTY(ID,N'ISUSERTABLE') = 1)

BEGIN

-- CREATE A MEMORY TABLE CONTAINING THE FIELDS AND TYPES OF THE TABLE

-- INSERT THE COLUMNAMES AND THE DATATYPES

INSERT @MEMTABLE

   (ID,COLUMN_NAME,COLUMN_TYPE)

   SELECT row_number() OVER (ORDER BY SYS.ID), SYS.NAME,TYPE_NAME(SYS.XTYPE)

   FROM  SYSCOLUMNS SYS WHERE SYS.ID = OBJECT_ID('##YOUR_TABLE##')

   ORDER BY SYS.COLID

-- SETUP VARIABLES

SET @SQLSTR = ''

SET @CUR=1

SELECT @MAX = ISNULL(MAX(ID),0) FROM @MEMTABLE

-- LOOP EVEY FIELD

WHILE @CUR <= @MAX

BEGIN

   -- GET VALUES FROM THE MEMTABLE    

   SELECT @COLUMN_NAME = COLUMN_NAME,@COLUMN_TYPE = COLUMN_TYPE FROM @MEMTABLE WHERE ID = @CUR

   IF @COLUMN_TYPE = 'INT' OR @COLUMN_TYPE = 'BIGINT' OR @COLUMN_TYPE='UNIQUEIDENTIFIER'

       -- WE DO WANT TO COPY INT/BIGINT/UNIQUEIDENTIFIER FIELDS BUT IF THEY ARE AN

       -- IDENTITY OR A ROWGUIDCOLUMN WE DO NOT WANT TO COPY THAT ATTRIBUTES

       SET @SQLSTR = @SQLSTR + ' CAST('+@COLUMN_NAME + ' AS '+@COLUMN_TYPE+') AS [' + @COLUMN_NAME +'] '

   ELSE

       -- ANOTHER FIELD DO NOTHING JUST COPY IT AS IT IS

       SET @SQLSTR = @SQLSTR + ' '+@COLUMN_NAME + ' AS [' + @COLUMN_NAME +'] '

   IF @CUR <= @MAX - 1

SET @SQLSTR=@SQLSTR + ','

   SET @CUR = @CUR + 1

END

-- ADD THE HIST FIELDS

SET @SQLSTR = @SQLSTR +',CAST(''      '' AS CHAR(6)) AS TRG_ACTION,CAST(GETDATE() AS DATETIME) AS TRG_DATE'

-- SET UP THE SELECT FOR CREATING THE HIST TABLE

SET @SQLSTR = 'SELECT TOP 0 ' + @SQLSTR + ' INTO [DBO].[AUDIT_##YOUR_TABLE##] FROM [DBO].[##YOUR_TABLE##]'

EXEC(@SQLSTR)

--SELECT @SQLSTR

END

--FIND MISSING COLUMNS IN AUDIT TABLE AND STORE THEM IN @MEMTABLE

IF     (SELECT COUNT(*) FROM  INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '##YOUR_TABLE##'

AND COLUMN_NAME NOT IN (SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AUDIT_##YOUR_TABLE##' )) > 0

BEGIN

DELETE @MEMTABLE

INSERT @MEMTABLE

   (ID,COLUMN_NAME,COLUMN_TYPE,COLUMN_LENGTH)

   SELECT row_number() OVER (ORDER BY SYS.ID),SYS.NAME,TYPE_NAME(SYS.XTYPE),SYS.[LENGTH]

   FROM  SYSCOLUMNS SYS WHERE SYS.ID = OBJECT_ID('##YOUR_TABLE##')

AND SYS.NAME NOT IN (SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AUDIT_##YOUR_TABLE##' )

   ORDER BY SYS.COLID

SET @SQLSTR = ''

SET @CUR=1

SELECT @MAX = ISNULL(MAX(ID),0) FROM @MEMTABLE

-- LOOP EVEY FIELD

WHILE @CUR <= @MAX

BEGIN

SELECT @COLUMN_NAME = COLUMN_NAME,@COLUMN_TYPE = COLUMN_TYPE, @COLUMN_LENGTH = COLUMN_LENGTH FROM @MEMTABLE WHERE ID = @CUR

IF @COLUMN_TYPE = 'varchar' OR @COLUMN_TYPE ='char' OR @COLUMN_TYPE='nvarchar'

SET @SQLSTR = @SQLSTR + @COLUMN_NAME + ' ' + @COLUMN_TYPE + '(' + CAST(@COLUMN_LENGTH AS VARCHAR(10)) + ')'

ELSE

SET @SQLSTR = @SQLSTR + @COLUMN_NAME + ' ' + @COLUMN_TYPE

IF @CUR <= @MAX - 1

SET @SQLSTR = @SQLSTR + ','

SET @CUR = @CUR + 1  

END

--SELECT N'ALTER TABLE AUDIT_##YOUR_TABLE## ADD ' + @SQLSTR

EXEC(N'ALTER TABLE AUDIT_##YOUR_TABLE## ADD ' + @SQLSTR )

--SELECT 'ALTER AUDIT_##YOUR_TABLE## ADD (' + @SQLSTR + ')'

END

--COPY DELETED RECORDS TO MEMORY TABLE SO IT CAN BE USED WITH DYMANIC QUERY

IF OBJECT_ID('###YOUR_TABLE##_DELETED') IS NOT NULL

DROP TABLE ###YOUR_TABLE##_DELETED

SELECT * INTO ###YOUR_TABLE##_DELETED FROM ##YOUR_TABLE## WHERE TABLEID = (SELECT MAX(TABLEID) FROM ##YOUR_TABLE##)

--GET STRING FOR COLUMNS EXISTING IN BOTH ##YOUR_TABLE## AND AUDIT_##YOUR_TABLE##

SELECT DISTINCT @SQLColumns = STUFF(

(SELECT ',' + COLUMN_NAME FROM

(SELECT DISTINCT COLUMN_NAME FROM

INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AUDIT_##YOUR_TABLE##' AND

COLUMN_NAME IN

(SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '##YOUR_TABLE##') )

AS SC FOR XML PATH('')),1,1,'')

EXEC(N'INSERT INTO [DBO].[AUDIT_##YOUR_TABLE##] (' + @SQLColumns + ',TRG_DATE) SELECT ' + @SQLColumns + ',GETDATE() FROM ###YOUR_TABLE##_DELETED')

DROP TABLE ###YOUR_TABLE##_DELETED

# August 14, 2009 8:04 PM

Alex Paranko said:

In the query I recently submitted,

SELECT * INTO ###YOUR_TABLE##_DELETED FROM ##YOUR_TABLE## WHERE TABLEID = (SELECT MAX(TABLEID) FROM ##YOUR_TABLE##)

needs to be changed by:

SELECT * INTO ###YOUR_TABLE##_DELETED FROM DELETED

I was testing :)

# August 14, 2009 8:08 PM

Jan Schreuder said:

I wonder how this performs. It's a lot more database actions, I think, compared to the original code. Especially since you need to check for changes in columns every time the code is triggered.

# August 14, 2009 9:56 PM

Evan Richardson said:

This may just be exactly what I'm looking for, but I don't know much about SQL, so let me run this by you for clarification so that I fully understand what this does, and if it's what we need.    I've got an application I wrote that manages our site installation data, and that syncs with a central SQL server over the internet whenever a user hits a "Sync" button. (bi directional).   One of the features that our field engineers asked for was to have a log of any changes made, and by who made them.  Am I correct in understanding that this trigger will copy ANY column updated in your table to a new table?  so say for example, I update device serial number, and maybe device description on one item... and then update device location, and device IP for a different device, will it record just that data in the audit table?  or doe this copy the whole row?    I need something that just logs a date/time someone updated something, and then records old value/new value.

Thanks.

# August 27, 2009 5:59 PM

Jan Schreuder said:

This trigger will simply copy the entire row into an audit table. If you're looking at logging only changed data, then this is not for you.

# August 28, 2009 8:20 AM

Tejas said:

Thank you for the time to post this article.

I am new to Sql. I tried using the above code and made the changes for the update section as I want to record the updated fields. I am not able to insert the code that you provided at the end of the main code. I am getting errors related to columns not found. StatusID, STATUS, Alerted, etc...

Thanks again

# January 19, 2010 4:00 PM

Robert Malsbury said:

I changed this one line because I kept getting a error.

Server: Msg 213, Level 16, State 5, Procedure TRG_Pinpad, Line 83

Insert Error: Column name or number of supplied values does not match table definition.

Changed IF @ACT = 'UPDATE' INSERT [DBO].[AUDIT_Pinpad] SELECT *,'UPDATE', GETDATE() FROM INSERTED

TO

IF @ACT = 'UPDATE' INSERT [DBO].[AUDIT_Pinpad] SELECT inserted.*,'UPDATE', GETDATE() FROM INSERTED

# February 18, 2011 5:42 PM
Leave a Comment

(required) 

(required) 

(optional)

(required) 


Please add 4 and 3 and type the answer here: