How to create a generic trigger

  • Hi,

    I am in no way a db expert, but I am required to create a generic trigger to track changes to a few tables. We don't want to write a specific trigger for each table. We would like to enter the data that has been updated, inserted, deleted into a "generic" table that looks like this:

    id (sequence number)

    batch_id (identifies a set of related changes; i.e. multiple values were modified in one record with the same action)

    table (modified)

    column (modified)

    original_value

    new_value

    user_id

    timestamp

    My problem is to figure out which column has changes in a generic way to just insert the new value and the old value. We ONLY want the column(s) that has been updated,inserted or deleted.

    This is what I have so far, but it does not work. Does not seem like I can use computed values in a SELECT statement:

    set ANSI_NULLS ON

    set QUOTED_IDENTIFIER ON

    go

    ALTER TRIGGER [TRG_author]

    ON [dbo].[author]

    FOR DELETE,INSERT,UPDATE

    AS

    DECLARE @ins BIT

    DECLARE @del BIT

    DECLARE @ACT varchar(6)

    DECLARE @ColumnCount int

    DECLARE @Counter int

    DECLARE @TableName varchar(30)

    DECLARE @StringColumn varchar (50)

    DECLARE @OLD_VALUE varchar(500)

    DECLARE @NEW_VALUE varchar(500)

    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

    SELECT @ColumnCount =

    (

    SELECT count(COLUMN_NAME)

    FROM INFORMATION_SCHEMA.COLUMNS

    WHERE TABLE_NAME = 'author'

    );

    SET @TableName = 'author';

    SET @Counter = 0;

    WHILE @Counter < @ColumnCount BEGIN

    Set @Counter = @Counter + 1

    -- find the first column name and subsequent column names

    SELECT @StringColumn = (SELECT COL_NAME(OBJECT_ID(@TableName), @Counter) AS 'Column Name')

    IF @ACT = 'UPDATE' BEGIN

    --UPDATE

    PRINT 'SELECT VALUE FROM COLUMN : ' + @StringColumn

    SELECT @OLD_VALUE = (SELECT @StringColumn FROM DELETED)

    SELECT @NEW_VALUE = (SELECT @StringColumn FROM INSERTED)

    PRINT 'OLD_VALUE : ' + @OLD_VALUE

    PRINT 'NEW_VALUE : ' + @NEW_VALUE

    END

    ELSE IF @ACT = 'INSERT'

    --INSERT

    RETURN

    ELSE

    --DELETE

    RETURN

    END

    Is there an easier way than looping through the table columns?

    I tried

    IF UPDATE(columnname) BEGIN

    END

    But, columnname can't be a computed value...

    Any ideas? I have searched and searched for an answer without luck.

    Thanks

  • hengert (7/18/2008)


    Hi,

    I am in no way a db expert, but I am required to create a generic trigger to track changes to a few tables. We don't want to write a specific trigger for each table. We would like to enter the data that has been updated, inserted, deleted into a "generic" table that looks like this:

    May I advise, if you want to go that kind of route, write a procedure to generate triggers for each table based on the system tables.

    Looping through the system tables within the trigger is going to increase the time the triggers take to execute, lengthening the transactions and making it more likely that you'll have blocking problems.

    So, you would have a proc that generated and executed the CREATE TRIGGER .... sstatement based on the table name and the columns in it.

    Gail Shaw
    Microsoft Certified Master: SQL Server, MVP, M.Sc (Comp Sci)
    SQL In The Wild: Discussions on DB performance with occasional diversions into recoverability

    We walk in the dark places no others will enter
    We stand on the bridge and no one may pass
  • Sounds like maybe that is the only way to go then.

    Now I have to figure out how to write a procedure for that 🙂

    Thanks.

  • The table we want to have triggers for will probably not be updated very often.

  • Lots of dynamic SQL. 😉 If you get stuck, we can help.

    One other thing I noticed looking over your trigger code. Be careful of constructs like this in trigger code

    SELECT @SomeVariable = SomeColumn FROM inserted

    Triggers fire once for a data modification and the inserted and deleted rows will contain all the rows affected by the operation. So if you run an update that affects 25 rows, the update trigger will fire once and both inserted and deleted will contain 25 rows.

    The code construct above assumes there is only 1 row in teh inserted table and if there are more, retrieves the value of one of them.

    Gail Shaw
    Microsoft Certified Master: SQL Server, MVP, M.Sc (Comp Sci)
    SQL In The Wild: Discussions on DB performance with occasional diversions into recoverability

    We walk in the dark places no others will enter
    We stand on the bridge and no one may pass
  • You may be able to use the COLUMNS_UPDATED() function to determine which column(s) have been updated. Check out BOL for details.

    As Gail has already mentioned you would be better off from a performance perspective if you wrote a procedure that created a specific audit trigger for each table.

  • Thanks.

    What is LOB? Sorry 🙁

    Yeah, I know the code I have does not work for many rows in the updated/deleted table. The code I have I have pieced together from different examples.

    How come thi statemetent does not work:

    SELECT @StringColumn = (SELECT COL_NAME(OBJECT_ID(@TableName), @Counter) AS 'Column Name')

    IF @ACT = 'UPDATE' BEGIN

    --UPDATE

    PRINT 'SELECT VALUE FROM COLUMN : ' + @StringColumn

    SELECT @OLD_VALUE = (SELECT @StringColumn FROM DELETED)

    SELECT @NEW_VALUE = (SELECT @StringColumn FROM INSERTED)

    PRINT 'OLD_VALUE : ' + @OLD_VALUE

    PRINT 'NEW_VALUE : ' + @NEW_VALUE

    END

    It just returns this:

    SELECT VALUE FROM COLUMN : ID

    OLD_VALUE : ID

    NEW_VALUE : ID

    SELECT VALUE FROM COLUMN : firstname

    OLD_VALUE : firstname

    NEW_VALUE : firstname

    SELECT VALUE FROM COLUMN : lastname

    OLD_VALUE : lastname

    NEW_VALUE : lastname

    How would you go about to write a procedure to generate a trigger? I am so new to T-SQL....

    Thank you for your help.

  • hengert (7/18/2008)


    Thanks.

    What is LOB? Sorry 🙁

    It is BOL not LOB and stands for Books On Line.

    How come thi statemetent does not work:

    SELECT @StringColumn = (SELECT COL_NAME(OBJECT_ID(@TableName), @Counter) AS 'Column Name')

    IF @ACT = 'UPDATE' BEGIN

    --UPDATE

    PRINT 'SELECT VALUE FROM COLUMN : ' + @StringColumn

    SELECT @OLD_VALUE = (SELECT @StringColumn FROM DELETED)

    SELECT @NEW_VALUE = (SELECT @StringColumn FROM INSERTED)

    PRINT 'OLD_VALUE : ' + @OLD_VALUE

    PRINT 'NEW_VALUE : ' + @NEW_VALUE

    END

    The code above does not work because SQL Server does not recognize a variable as a column name. In order for that to work you would need to use dynamic sql (which I do not think will have access to the Inserted or Deleted tables) like this:

    [font="Courier New"]DECLARE @test VARCHAR(100),

                @column VARCHAR(50),

                @sql NVARCHAR(MAX)

    SET @column = 'name'

    SET @sql = 'Set @test-2 = (select top 1 ' + @column + ' from sys.objects);'

    EXEC sp_executesql @sql, N'@test varchar(100) Output' , @test = @test Output

    SELECT @test

    [/font]

    How would you go about to write a procedure to generate a trigger? I am so new to T-SQL....

    You would do something that goes through the Information_Schema views for tables and columns that builds a trigger. Something like:

    [font="Courier New"]DECLARE @schema SYSNAME,

                @table SYSNAME,

                @sql VARCHAR(MAX),

                @column SYSNAME

                

    DECLARE c_tables CURSOR FOR

        SELECT

            table_schema,

            table_name

        FROM

            information_schema.tables

        WHERE

            table_schema = 'HumanResources'  AND

            table_type = 'Base table'

    OPEN c_tables

    FETCH Next FROM c_tables INTO

        @schema,

        @table

    WHILE @@Fetch_Status = 0

        BEGIN

            DECLARE @primary_key_column SYSNAME

            -- assumes you have a single column primary key

            --if not you would need to handle differently

            -- also assumes each table has a primary key

            SELECT

                @primary_key_column = CCU.column_name

            FROM

               INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE CCU JOIN

               INFORMATION_SCHEMA.KEY_COLUMN_USAGE KCU ON

                   CCU.COLUMN_NAME = KCU.COLUMN_NAME AND

                   CCU.TABLE_NAME = KCU.TABLE_NAME AND

                   CCU.TABLE_SCHEMA = KCU.TABLE_SCHEMA AND

                   CCU.TABLE_CATALOG = KCU.TABLE_CATALOG AND

                   CCU.CONSTRAINT_NAME = KCU.CONSTRAINT_NAME JOIN

               INFORMATION_SCHEMA.TABLE_CONSTRAINTS TC ON

                   KCU.TABLE_NAME = TC.TABLE_NAME AND

                   KCU.TABLE_SCHEMA = TC.TABLE_SCHEMA AND

                   KCU.TABLE_CATALOG = TC.TABLE_CATALOG AND

                   KCU.CONSTRAINT_NAME = TC.CONSTRAINT_NAME

            WHERE

               TC.CONSTRAINT_TYPE = 'PRIMARY KEY' AND

                CCU.Table_Schema = @Schema AND

                CCU.Table_Name = @Table

            

            SET @sql = 'Create Trigger ' + @schema + '.trg_' + @table + '_ins_upd_del on ' +

                            @schema + '.' + @table + ' After ' + 'Insert, Update, Delete As If ' +

                            'Exists(Select * From inserted) and exists(select * from deleted) Begin '

            

            DECLARE c_columns CURSOR FOR

                SELECT

                    column_name

                FROM

                    information_schema.columns

                WHERE

                    table_schema = @schema AND

                    table_name = @table

                

                OPEN c_columns

                

                FETCH Next FROM c_columns INTO

                    @column

                

                WHILE @@Fetch_Status = 0

                    BEGIN

                         SET @sql = @sql + 'If Update(' + @column + ') Begin Insert into audit ' +

                                           'Select I.' + @column + ' as new_val, D.' + @column +

                                           ' as old_val from inserted I Join deleted D On I.' +

                                          @primary_key_column + ' = D.' + @primary_key_column +

                                          ' End ' -- need a space

                        

                        FETCH Next FROM c_columns INTO

                            @column

                    END

                

                CLOSE c_columns

                DEALLOCATE c_columns

                

                SET @sql = @sql + ' End'

                SELECT (@sql)

                --Exec (@sql)

                

                FETCH Next FROM c_tables INTO

                    @schema,

                    @table

        END

    CLOSE c_tables

    DEALLOCATE c_tables

    [/font]

    This is not 100% tested nor does it do exactly what you want, but it should set you on the right path.

  • Thank you for your answer.

    This is what I came up with (Keep in mind that this is the first time I write T-SQL)

    All I am missing now is how to figure out which user did the delete/insert/update. We have to capture the logged in user and not the database user. I think the issue will be solved if we add the user_id to each table, then the trigger will capture that column change....

    CREATE PROC createGenericTrigger (@TableName Varchar(100))

    AS

    BEGIN

    --=== PROC Declarations

    DECLARE @ColumnCount int

    DECLARE @TriggerName varchar(50)

    DECLARE @Counter int

    DECLARE @UpdateStatement varchar(MAX)

    DECLARE @StringColumn varchar(100)

    DECLARE @Declarations varchar(400)

    DECLARE @Initializations varchar(400)

    -- Fetch the column count for the table to create the trigger for

    SELECT @ColumnCount =

    (

    SELECT count(COLUMN_NAME)

    FROM INFORMATION_SCHEMA.COLUMNS

    WHERE TABLE_NAME = @TableName

    );

    SET @Counter = 0;

    Set @UpdateStatement = '';

    --== Loop though all the columns in the table to track changes for. For each column

    --== create a IF UPDATE(columnname)

    WHILE @Counter < @ColumnCount

    BEGIN

    Set @Counter = @Counter + 1

    --== find the first column name and subsequent column names

    SELECT @StringColumn = (SELECT COL_NAME(OBJECT_ID(@TableName), @Counter) AS 'Column Name')

    --== Create the IF UPDATED statement

    Set @UpdateStatement = @UpdateStatement +

    --== Make sure we catch which table and column that is updated/deleted/inserted

    ' SET @COLUMN_NAME = ' + '''' + @StringColumn + '''' +

    ' SET @TABLE_NAME = ' + '''' + @TableName + '''' +

    --== A trigger uses two tables to keep track of changes, DELETED and INSERTED tables

    --== If we have an update, then fetch the new and old value

    'IF @ACT = ''UPDATE''' +

    ' BEGIN ' +

    ' IF UPDATE (' + @StringColumn + ')

    BEGIN ' +

    ' SELECT @OLD_VALUE = (SELECT ' + @StringColumn + ' from deleted) ' +

    ' SELECT @NEW_VALUE = (SELECT ' + @StringColumn + ' from inserted) ' +

    ' IF @OLD_VALUE <> @NEW_VALUE ' +

    ' BEGIN ' +

    'EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, @NEW_VALUE' +

    ' END ' +

    'END ' +

    'END ' +

    --== If we have an insert, then just fetch the new value

    'ELSE IF @ACT = ''INSERT''' +

    ' BEGIN ' +

    ' SELECT @NEW_VALUE = (SELECT ' + @StringColumn + ' from inserted) ' +

    'EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, '''', @NEW_VALUE' +

    ' END ' +

    --== If we have a delete, then just fetch the old value

    'ELSE IF @ACT = ''DELETE''' +

    ' BEGIN ' +

    ' SELECT @OLD_VALUE = (SELECT ' + @StringColumn + ' from deleted) ' +

    'EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, ''''' +

    ' END '

    END

    PRINT @UpdateStatement

    SET @TriggerName = 'TRG_' + @TableName

    --== Declarations for the trigger

    SET @Declarations = 'DECLARE @OLD_VALUE varchar(500) ' +

    'DECLARE @NEW_VALUE varchar(500) ' +

    'DECLARE @TABLE_NAME varchar(500) ' +

    'DECLARE @COLUMN_NAME varchar(500) ' +

    'DECLARE @ACT CHAR(6) ' +

    'DECLARE @del BIT ' +

    'DECLARE @ins BIT ' +

    'DECLARE @BATCH_ID Binary(16) ' +

    'SET @BATCH_ID = NEWID() '

    --== Determine what type of query fired the trigger

    SET @Initializations ='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 @ins = 0 AND @del = 1 Set @ACT = ''DELETE'' '

    -- == This will execute the sql to create the trigger

    EXEC ('CREATE TRIGGER ' + @TriggerName +

    ' ON ' + @TableName +

    ' AFTER DELETE, INSERT, UPDATE

    AS BEGIN '

    + @Declarations + @Initializations + @UpdateStatement + ' END' )

    END

  • Two questions:

    1. How many tables are you adding triggers to? Dozens? Hundreds? Five?

    2. Why a "generic" trigger? Why not a specific trigger per table?

    - Gus "GSquared", RSVP, OODA, MAP, NMVP, FAQ, SAT, SQL, DNA, RNA, UOI, IOU, AM, PM, AD, BC, BCE, USA, UN, CF, ROFL, LOL, ETC
    Property of The Thread

    "Nobody knows the age of the human race, but everyone agrees it's old enough to know better." - Anon

  • hengert (7/21/2008)


    Thank you for your answer.

    This is what I came up with (Keep in mind that this is the first time I write T-SQL)

    The code looks like it will create a trigger alright. Can you post the code from an auto-generated trigger so we can try to tune it?

    All I am missing now is how to figure out which user did the delete/insert/update. We have to capture the logged in user and not the database user. I think the issue will be solved if we add the user_id to each table, then the trigger will capture that column change....

    If you are using Windows Authentication or a specific SQL User for each user then SUSER_NAME() which will return domain\user_name for windows and just user_name for SQL Logins.

  • I have to create a trigger for 9 tables. So, depending on how many columns each table has, it would be a nightmare to write.

  • Is there a way to format this better? The generated trigger looks ugly to say the least.

    Here is a generated trigger:

    set ANSI_NULLS ON

    set QUOTED_IDENTIFIER ON

    go

    ALTER TRIGGER [TRG_author] ON [dbo].[author] AFTER DELETE, INSERT, UPDATE

    AS BEGIN DECLARE @OLD_VALUE varchar(500) DECLARE @NEW_VALUE varchar(500) DECLARE @TABLE_NAME varchar(500) DECLARE @COLUMN_NAME varchar(500) DECLARE @ACT CHAR(6) DECLARE @del BIT DECLARE @ins BIT DECLARE @BATCH_ID Binary(16) SET @BATCH_ID = NEWID() 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 @ins = 0 AND @del = 1 Set @ACT = 'DELETE' SET @COLUMN_NAME = 'ID' SET @TABLE_NAME = 'author'IF @ACT = 'UPDATE' BEGIN IF UPDATE (ID)

    BEGIN SELECT @OLD_VALUE = (SELECT ID from deleted) SELECT @NEW_VALUE = (SELECT ID from inserted) IF @OLD_VALUE <> @NEW_VALUE BEGIN EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, @NEW_VALUE END END END ELSE IF @ACT = 'INSERT' BEGIN SELECT @NEW_VALUE = (SELECT ID from inserted) EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, '', @NEW_VALUE END ELSE IF @ACT = 'DELETE' BEGIN SELECT @OLD_VALUE = (SELECT ID from deleted) EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, '' END SET @COLUMN_NAME = 'firstname' SET @TABLE_NAME = 'author'IF @ACT = 'UPDATE' BEGIN IF UPDATE (firstname)

    BEGIN SELECT @OLD_VALUE = (SELECT firstname from deleted) SELECT @NEW_VALUE = (SELECT firstname from inserted) IF @OLD_VALUE <> @NEW_VALUE BEGIN EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, @NEW_VALUE END END END ELSE IF @ACT = 'INSERT' BEGIN SELECT @NEW_VALUE = (SELECT firstname from inserted) EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, '', @NEW_VALUE END ELSE IF @ACT = 'DELETE' BEGIN SELECT @OLD_VALUE = (SELECT firstname from deleted) EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, '' END SET @COLUMN_NAME = 'lastname' SET @TABLE_NAME = 'author'IF @ACT = 'UPDATE' BEGIN IF UPDATE (lastname)

    BEGIN SELECT @OLD_VALUE = (SELECT lastname from deleted) SELECT @NEW_VALUE = (SELECT lastname from inserted) IF @OLD_VALUE <> @NEW_VALUE BEGIN EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, @NEW_VALUE END END END ELSE IF @ACT = 'INSERT' BEGIN SELECT @NEW_VALUE = (SELECT lastname from inserted) EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, '', @NEW_VALUE END ELSE IF @ACT = 'DELETE' BEGIN SELECT @OLD_VALUE = (SELECT lastname from deleted) EXEC logEvent @BATCH_ID, @ACT, @TABLE_NAME, @COLUMN_NAME, @OLD_VALUE, '' END END

  • hengert (7/21/2008)


    I have to create a trigger for 9 tables. So, depending on how many columns each table has, it would be a nightmare to write.

    Unless you're talking about hundreds of columns per table (which is unusual), writing nine triggers should take less than half an hour.

    create table #Inserted (

    ID int primary key,

    Col1 varchar(100),

    Col2 int)

    create table #Deleted (

    ID int primary key,

    Col1 varchar(100),

    Col2 int)

    insert into #Inserted (ID, Col1, Col2)

    select 1, 'x', 1 union all

    select 2, 'y', 2

    insert into #Deleted (ID, Col1, Col2)

    select 2, 'z', 2 union all

    select 3, 'w', 3

    insert into dbo.LogTable (LoggedData)

    select isnull(i.ID, d.ID) as ID,

    i.Col1 as Col1_To, i.Col2 as Col2_To,

    d.Col1 as Col1_From, d.Col2 as Col2_From,

    case

    when i.id is not null and d.id is not null then 'U'

    when i.id is null and d.id is not null then 'D'

    when i.id is not null and d.id is null then 'I'

    end [Action]

    from #inserted i

    full outer join #deleted d

    on i.id = d.id

    for xml path

    I used temp tables because a script can't directly access the inserted and deleted tables, but this script can be easily modified into quite an efficient logging trigger.

    Actually, though, you really don't need to log inserts. If the data is never updated or deleted, the actual row is the "log", and if it is updated or deleted, the "from" columns of the first logged action are the original inserted values.

    If you don't want to log colums that aren't changed, use NullIf in the XML query. Like:

    NullIf(i.Col1, d.Col1) as Col1_To, NullIf(d.Col1, i.Col1) as Col1_From

    Since the XML data type doesn't include Nulls unless you tell it to, it will not log the columns that aren't changed in an update action. (This also means it won't log a null value that's inserted or deleted, but that's easy enough to extract from the log, where it doesn't have that column.)

    If your log table has an ID column, that becomes your transaction ID. It includes all rows that were inserted/updated/deleted, in an easy-to-query XML format.

    (Using the "Drag the columns" method in Management Studio, and some simple copy-and-paste, I can create a trigger for a 100-column table, using this trigger template, in about 3-5 minutes. A ten-column table takes less than a minute.)

    - Gus "GSquared", RSVP, OODA, MAP, NMVP, FAQ, SAT, SQL, DNA, RNA, UOI, IOU, AM, PM, AD, BC, BCE, USA, UN, CF, ROFL, LOL, ETC
    Property of The Thread

    "Nobody knows the age of the human race, but everyone agrees it's old enough to know better." - Anon

  • Sure you can make it format better by adding Carriage Returns, Line Feeds, and Tabs. You would do this using the CHAR() function. I believe it is CHAR(10) for CR, CHAR(13) for LF, and CHAR(9) for tabs, but you should look it up in BOL.

    I just went back over your code that creates the trigger and you should really use the schema as well as the table name when finding the columns as it is possible to have the same table name in 2 schemas. Also if you do use the primary key finding section I provided you can simplify your code for determining the action by joining the inserted and deleted tables.

Viewing 15 posts - 1 through 15 (of 21 total)

You must be logged in to reply to this topic. Login to reply