5

I have implemented an audit trail framework based on the information provided by the first answer to the following post:

SQL Server history table - populate through SP or Trigger?

Ultimately, the framework that I have implemented uses three triggers per table that insert audit information based on changes to the tables.

My insert and delete audit triggers are fairly simple. However, the update triggers are far more complex because the trigger has to check to determine whether or not each column is under audit control and then perform an insert based on whether or not the column values in the Inserted and Deleted columns are equal or not since I don't want to write unnecessary audit records. Ultimately, I want to know if there is a way to write a stored procedure that will reduce the amount of code in my trigger by allowing me to dynamically perform the insert statement below. Basically, I envision the trigger firing the sproc with each column name that is under audit control and then the stored procedure will used the column name to perform the code snippet below. Currently, I have the code below for every column under audit control which unfortunately results in lots of redundant code.

Revised Trigger After Suggested Changes

CREATE TRIGGER [dbo].[Audit_Customers_Update] ON [dbo].[Customers]
FOR UPDATE AS

select FirstName,LastName into #deleted from deleted;

declare /*const*/ @TABLE_NAME sysname = '[table name]';

declare f cursor
local
forward_only
read_only
for
  select c.name, quotename(c.name, '[')
  from
    sys.columns c
    inner join sys.types t on c.system_type_id = t.system_type_id
  where
    c.object_id = object_id(@TABLE_NAME)
    and c.is_computed = 0
    and c.is_identity = 0
    and t.name not in ('text', 'image', 'timestamp', 'xml')
    and (substring(COLUMNS_UPDATED(), ((c.column_id - 1) / 8) + 1, 1) & power(2, (c.column_id - 1) % 8)) > 0
  ;

declare @field_name sysname, @field_name_sanitised sysname;
create table #results (row_id int not null,
                       field_name sysname not null,
                       oldval nvarchar(150) null,
                       newval nvarchar(150) null);

-- For each changed field, insert what exactly changed into #results

open f;

fetch next from f into @field_name, @field_name_sanitised;
while @@fetch_status = 0
begin
  declare @query nvarchar(4000);

  set @query =  N'insert into #results(row_id, field_name, oldval, newval)
                  select d.row_id, @field_name, d.' + @field_name_sanitised + N', i.' + @field_name_sanitised + N'
                  from
                    #deleted d inner join ' + @TABLE_NAME + N' i on d.row_id = i.row_id
                  where
                    (d.' + @field_name_sanitised + N' <> i.' + @field_name_sanitised + N')
                    or
                    (case when d.' + @field_name_sanitised + N' is null then 1 else 0 end <> case when i.' + @field_name_sanitised + N' is null then 1 else 0 end);'
                ;    

  exec sp_executesql
    @stmt = @query,
    @params = N'@field_name sysname',
    @field_name = @field_name
  ;

  fetch next from f into @field_name, @field_name_sanitised;
end;

close f;
deallocate f;

-- Do something meaningful to #results here

How do I access #results? Do I have to use a cursor?

Community
  • 1
  • 1
Grasshopper
  • 4,717
  • 9
  • 36
  • 62
  • Many comparison techniques often do not catch edge cases. Tricky logic is needed to handle null/non-null comparisons while still treating null = null. (The new `DISTINCT FROM` comparison operator helps with that.) Still, some comparisons ignore trailing spaces and are may be case or accent insensitive. If you run a clean up operation to trim whitespace or to proper-case all-uppercase names, do you want to log those changes? The best compare I can think of offhand is `CONVERT(VARBINARY(MAX), D1.Value) IS DISTINCT FROM CONVERT(VARBINARY(MAX), D2.Value)`, but I haven't 100% tested this. – T N Feb 17 '23 at 04:11
  • Here is a [db<>fiddle](https://dbfiddle.uk/OWCqHEnQ) that shows the above mentioned comparison issues, – T N Feb 17 '23 at 04:23

2 Answers2

7

We've solved that problem in the following way.

select <list of tracked columns here> into #deleted from deleted;

declare /*const*/ @TABLE_NAME sysname = '[table name]';

declare f cursor
local
forward_only
read_only
for
  select c.name, quotename(c.name, '[')
  from
    sys.columns c
    inner join sys.types t on c.system_type_id = t.system_type_id
  where
    c.object_id = object_id(@TABLE_NAME)
    and c.is_computed = 0
    and c.is_identity = 0
    and t.name not in ('text', 'image', 'timestamp', 'xml')
    and (substring(COLUMNS_UPDATED(), ((c.column_id - 1) / 8) + 1, 1) & power(2, (c.column_id - 1) % 8)) > 0
  ;

declare @field_name sysname, @field_name_sanitised sysname;
create table #results (row_id int not null, field_name sysname not null, oldval nvarchar(150) null, newval nvarchar(150) null);

-- For each changed field, insert what exactly changed into #results

open f;

fetch next from f into @field_name, @field_name_sanitised;
while @@fetch_status = 0
begin
  declare @query nvarchar(4000);

  set @query =  N'insert into #results(row_id, field_name, oldval, newval)
                  select d.row_id, @field_name, d.' + @field_name_sanitised + N', i.' + @field_name_sanitised + N'
                  from
                    #deleted d inner join ' + @TABLE_NAME + N' i on d.row_id = i.row_id
                  where
                    (d.' + @field_name_sanitised + N' <> i.' + @field_name_sanitised + N')
                    or
                    (case when d.' + @field_name_sanitised + N' is null then 1 else 0 end <> case when i.' + @field_name_sanitised + N' is null then 1 else 0 end);'
                ;    

  exec sp_executesql
    @stmt = @query,
    @params = N'@field_name sysname',
    @field_name = @field_name
  ;

  fetch next from f into @field_name, @field_name_sanitised;
end;

close f;
deallocate f;

-- Do something meaningful to #results here

Related reading:

GSerg
  • 76,472
  • 17
  • 159
  • 346
  • I consider myself to be a novice at creating stored procedures and triggers so I apologize in advance for not fully understanding this solution. Ultimately, all I really want to do is write a stored procedure that allows me to pass in the Inserted and Deleted Records from a trigger along with a column name so that I can dynamically build a SQL statement to perform an insert on a known table name. Is all of this code necessary to perform this task? I am just trying to prevent from having a bunch of redundant code in my trigger and I don't think that I am trying to do anything too complicated – Grasshopper Jan 15 '12 at 22:07
  • @Grasshopper This is pretty much the whole body of the trigger. It creates a temporaty table, `#results`, which includes information about fields that changed. You can pass this information to a stored procedure, or do something else to it. The point is, the loop is generic and will automatically pick up required number of fields. This little snippet pretty much replaces the wall of code you posted in your question. – GSerg Jan 15 '12 at 23:56
  • I have replaced the body of my trigger with the code above and it compiles and appears to be inserting the rows into the #deleted table. In the I have put two of the columns that I want to track. I have two test rows on the column that I added the trigger to and query analyzer indicates that after performing an update to the column on both rows that 2 rows were affected on one table and 2 on another table. Obviously, the first two rows were the table under audit and the other two were probably the #deleted. Any idea why the #results table is not populated? – Grasshopper Jan 17 '12 at 03:04
  • I put the revised version of my trigger above. I am still trying to study the documentation on triggers and cursors to figure out how to get more fields into the #results temp table and then access it to insert rows into my Audit Table – Grasshopper Jan 17 '12 at 03:08
  • @Grasshopper You don't seem to change `declare /*const*/ @TABLE_NAME sysname = '[table name]'` to reflect your table name. Should be `declare /*const*/ @TABLE_NAME sysname = N'[dbo].[Customers]'`. As for what you can do to `#results`, you can do `insert into AuditLog(ChangeDate, RowGUID, ChangeType, Username, HostName, AppName, UserGUID, TableName, FieldName, TagGUID, Tag, OldValue, NewValue) select getdate(), row_id, 'UPDATED', USER_NAME(), HOST_NAME(), APP_NAME(), AddBy, 'Nodes', 'ParentNodeGUID', null, null, oldval, newval from #results;`. – GSerg Jan 17 '12 at 08:44
  • Obviously, you'll have to add `AddBy` to the list of fields captured by the dynamic SQL inside the cursor. – GSerg Jan 17 '12 at 08:46
  • You could optimise this by removing the cursor and creating one big piece of dynamic SQL to run. You can use `select @query = isnull(@query, '') + ...` to build it and then its just one call to sp_executesql. – BJury Oct 01 '13 at 10:43
1

Ran into a similar problem... figured it out this way... may not be the most elegant solution but works for the compliance guys... So here goes...

Based on the solution given here

The xml is extracted with FOR XML from the trigger that updated the table... The "OldValues" come from the DELETED table and the "NewValues" from the INSERTED table... so the final xml looks like this...

            DECLARE @x XML= '<FieldData>
              <UpdatedColumns>
                <trType>OldValues</trType>
                <ID>5</ID>
                <def_label>TEST_TIE</def_label>
                <def_code />
              </UpdatedColumns>
              <UpdatedColumns>
                <trType>OldValues</trType>
                <ID>4</ID>
                <def_label>RP_TIE</def_label>
                <def_code />
              </UpdatedColumns>
              <UpdatedColumns>
                <trType>OldValues</trType>
                <ID>3</ID>
                <def_label>ERR_TIE</def_label>
                <def_code />
              </UpdatedColumns><UpdatedColumns>
                <trType>NewValues</trType>
                <ID>5</ID>
                <def_label>TEST_TIE</def_label>
                <def_code>A</def_code>
              </UpdatedColumns>
              <UpdatedColumns>
                <trType>NewValues</trType>
                <ID>4</ID>
                <def_label>RP_TIE</def_label>
                <def_code>A</def_code>
              </UpdatedColumns>
              <UpdatedColumns>
                <trType>NewValues</trType>
                <ID>3</ID>
                <def_label>ERR_TIE</def_label>
                <def_code>A</def_code>
              </UpdatedColumns>
            </FieldData>'

            declare @timestamp datetime2= SYSDATETIME()

            select 
                     ID = identity(int,1,1), 
                     T.N.value('local-name(.)', 'nvarchar(100)') as NodeName,
                     T.N.value('../ID[1]','nvarchar(100)') AS table_ID,
                     T.N.value('.', 'nvarchar(100)') as OldValue
            INTO #old
            from @x.nodes('//UpdatedColumns/*') as T(N)
            WHERE T.N.value('../trType[1]', 'nvarchar(100)') ='OldValues'


            select 
                     ID = identity(int,1,1),
                     T.N.value('local-name(.)', 'nvarchar(100)') as NodeName,
                     T.N.value('../ID[1]','nvarchar(100)') AS Table_ID,
                     T.N.value('.', 'nvarchar(100)') as NewValue
            into #new
            from @x.nodes('//UpdatedColumns/*') as T(N)
            WHERE T.N.value('../trType[1]', 'nvarchar(100)') ='NewValues'



            SELECT n.table_ID, n.NodeName, o.OldValue, n.NewValue,@timestamp as transation_time FROM #new n
            left outer JOIN #old o ON n.NodeName = o.NodeName AND n.ID = o.ID 
            WHERE isnull(o.[OldValue],'') <> isnull(n.[newValue],'') AND n.NodeName <> 'trType'



            DROP TABLE #new,#old 
            GO
Community
  • 1
  • 1