Starting from this article, which is creating a trigger to log insert-, update- and delete-statements within the database, I’d like to create a similar trigger which doesn’t require to define the table and its columns. This would us to reduce human errors when dropping or adding columns.
I’ve had many issues (hence the extra code compared to the article) but wasn’t able to overcome accessing the table columns through a stringified name within the trigger.
-- Create the ChangeLog table CREATE TABLE ChangeLog ( ID BIGINT PRIMARY KEY IDENTITY(1,1) NOT NULL, COMMAND NCHAR(6) NOT NULL, CHANGED_DATE DATETIME2 DEFAULT GETDATE() NOT NULL, TABLE_NAME NVARCHAR(255) NOT NULL, COLUMN_NAMES TEXT NULL, COLUMN_OLD_VALUES TEXT NULL, COLUMN_NEW_VALUES TEXT NULL, USERNAME NVARCHAR(100) NOT NULL ) GO
-- Create Trigger for Table to log changes ALTER TRIGGER CHANGE_MyTableName ON MyTableName AFTER INSERT, UPDATE, DELETE AS BEGIN -- Define which command was executed DECLARE @command CHAR(6) SET @command = CASE WHEN EXISTS(SELECT * FROM inserted) AND EXISTS(SELECT * FROM deleted) THEN 'UPDATE' WHEN EXISTS(SELECT * FROM inserted) THEN 'INSERT' WHEN EXISTS(SELECT * FROM deleted) THEN 'DELETE' ELSE NULL END -- Define variables DECLARE @seperator NVARCHAR(2) SET @seperator = ', ' DECLARE @column_name NVARCHAR(255) DECLARE @column_names VARCHAR(MAX) DECLARE @column_old_values VARCHAR(MAX) DECLARE @column_new_values VARCHAR(MAX) -- Select the column names to populate @column_names separated by ', ' SELECT @column_names = COALESCE(@column_names + @seperator, '') + COLUMN_NAME FROM information_schema.columns WHERE table_name = 'MyTableName' -- Create cursor to populate @column_old_values or/and @column_new_values DECLARE CURSOR_FOR_COLUMN_NAMES CURSOR FOR -- Select the column name as a string SELECT sys.columns.name AS ColumnName FROM sys.columns JOIN sys.tables ON sys.columns.object_id = tables.object_id WHERE tables.name = 'MyTableName' -- Perform the first fetch. OPEN CURSOR_FOR_COLUMN_NAMES FETCH NEXT FROM CURSOR_FOR_COLUMN_NAMES INTO @column_name WHILE @@FETCH_STATUS = 0 BEGIN IF @command = 'UPDATE' or @command = 'DELETE' -- Select the old values to populate @column_old_values separated by ', ' SELECT @column_old_values = COALESCE(@column_old_values + @seperator, '') + @column_name FROM deleted IF @command = 'UPDATE' or @command = 'INSERT' -- Select the new values to populate @column_new_values separated by ', ' SELECT @column_new_values = COALESCE(@column_new_values + @seperator, '') + @column_name FROM inserted FETCH NEXT FROM CURSOR_FOR_COLUMN_NAMES INTO @column_name END CLOSE CURSOR_FOR_COLUMN_NAMES DEALLOCATE CURSOR_FOR_COLUMN_NAMES -- Insert into the ChangeLog table IF @command = 'UPDATE' INSERT INTO ChangeLog (COMMAND, CHANGED_DATE, TABLE_NAME, COLUMN_NAMES, COLUMN_OLD_VALUES, COLUMN_NEW_VALUES, USERNAME) VALUES (@command, GETDATE(), 'MyTableName', @column_names, @column_old_values, @column_new_values, USER_NAME()) IF @command = 'INSERT' INSERT INTO ChangeLog (COMMAND, CHANGED_DATE, TABLE_NAME, COLUMN_NAMES, COLUMN_NEW_VALUES, USERNAME) VALUES (@command, GETDATE(), 'MyTableName', @column_names, @column_new_values, USER_NAME()) IF @command = 'DELETE' INSERT INTO ChangeLog (COMMAND, CHANGED_DATE, TABLE_NAME, COLUMN_NAMES, COLUMN_OLD_VALUES, USERNAME) VALUES (@command, GETDATE(), 'MyTableName', @column_names, @column_old_values, USER_NAME()) END GO
The current code creates a row with the same values for COLUMN_NAMES
, COLUMN_OLD_VALUES
, and COLUMN_NEW_VALUES
because the code that populates the @column_old_values
and @column_new_values
actually executes SELECT 'column_name' FROM AnyTable
instead of SELECT columnn_name FROM MyTableName
but I can’t seem to figure out on how to solve this.
I’ve tried using the EXEC() command, but that seems to partially end my trigger? Even though the code compiles, I continue to receive an error about unclosed quotation marks.
Msg 102, Level 15, State 1, Line 16
Incorrect syntax near ‘=’.Msg 105, Level 15, State 1, Line 16
Unclosed quotation mark after the character string ‘) + ColumnName1 FROM deleted’.
I think this generic trigger works as soon as this issue is solved but of course, I’d be happy with alternative solutions as well.
Advertisement
Answer
Some errors with your current code include:
An error when 0 rows are affected, because all the tables would be empty but you did not handle a NULL command, and it generates an error when attempting to insert a NULL command into ChangeLog
Your cursor would string all affected rows into a strange fashion; even if you got it to work, if 4 rows were affected, you would have 1 row in your ChangeLog where the column_old_values would hold something like (col1, col1, col1, col1, col2, col2, col2, col2).
Your cursor would need dynamic SQL to use dynamic column names, but dynamic SQL is in a different scope compared to your code, so you need to make a copy of the
inserted
anddeleted
trigger-scope tables to use dynamic SQL.Your dynamic SQL is trying to use variables that don’t exist in the different scope. It’s a lot easier to debug dynamic SQL if you put it into a string, then print the string for review before attempting to
EXEC
it.
EDIT:
How about this option, which doesn’t rely on knowing the columns but does rely on knowing the table PK’s beforehand? Those shouldn’t change nearly as often as other columns, and the performance for this is vastly superior to what you were trying to do. This is a sample from one I’ve implemented on a table we weren’t sure was still being utilized by one of our few dozen users, and I needed to track it over a year.
-- Create Trigger for Table to log changes ALTER TRIGGER AUDIT_MyTableName ON bookings AFTER INSERT, UPDATE, DELETE AS BEGIN SET NOCOUNT ON; -- Grab trx type DECLARE @command char(6) SET @command = CASE WHEN EXISTS(SELECT 1 FROM inserted) AND EXISTS(SELECT 1 FROM deleted) THEN 'UPDATE' WHEN EXISTS(SELECT 1 FROM inserted) THEN 'INSERT' WHEN EXISTS(SELECT 1 FROM deleted) THEN 'DELETE' ELSE '0 ROWS' -- if no rows affected, trigger does NOT record an entry END IF @command = 'INSERT' -- Add audit entry INSERT INTO ChangeLog (COMMAND, CHANGED_DATE, TABLE_NAME, /*COLUMN_NAMES,*/ COLUMN_OLD_VALUES, COLUMN_NEW_VALUES, USERNAME) SELECT Command = @command, ChangeDate = GETDATE(), TableName = 'bookings', --ColNames = @column_names, Column_OLD_Values = NULL, Column_NEW_Values = (SELECT inserted.* for xml path('')), Username = SUSER_SNAME() FROM inserted ELSE IF @command = 'DELETE' -- Add audit entry INSERT INTO ChangeLog (COMMAND, CHANGED_DATE, TABLE_NAME, /*COLUMN_NAMES,*/ COLUMN_OLD_VALUES, COLUMN_NEW_VALUES, USERNAME) SELECT Command = @command, ChangeDate = GETDATE(), TableName = 'bookings', --ColNames = @column_names, Column_OLD_Values = (SELECT deleted.* for xml path('')), Column_NEW_Values = NULL, Username = SUSER_SNAME() FROM deleted ELSE -- is UPDATE -- Add audit entry INSERT INTO ChangeLog (COMMAND, CHANGED_DATE, TABLE_NAME, /*COLUMN_NAMES,*/ COLUMN_OLD_VALUES, COLUMN_NEW_VALUES, USERNAME) SELECT Command = @command, ChangeDate = GETDATE(), TableName = 'bookings', --ColNames = @column_names, Column_OLD_Values = (SELECT deleted.* for xml path('')), Column_NEW_Values = (SELECT inserted.* for xml path('')), Username = SUSER_SNAME() FROM inserted INNER JOIN deleted ON inserted.bookingID = deleted.bookingID -- join on w/e the PK is END
The result is perfectly functional for whatever you need:
If you’re willing to change the column types for COLUMN_OLD_VALUES
and COLUMN_NEW_VALUES
to XML, you can simply add , type
right after each for xml path('')
and the XML is click-able and easy to read in SSMS.
Column_OLD_Values = (SELECT deleted.* for xml path(''), type), Column_NEW_Values = (SELECT inserted.* for xml path(''), type),