Finally, I found a universal solution that does not require dynamic changes to sql and logs for all columns.
No need to change the trigger if the table changes.
This is the audit log:
CREATE TABLE [dbo].[Audit]( [ID] [bigint] IDENTITY(1,1) NOT NULL, [Type] [char](1) COLLATE Latin1_General_CI_AS NULL, [TableName] [nvarchar](128) COLLATE Latin1_General_CI_AS NULL, [PK] [int] NULL, [FieldName] [nvarchar](128) COLLATE Latin1_General_CI_AS NULL, [OldValue] [nvarchar](max) COLLATE Latin1_General_CI_AS NULL, [NewValue] [nvarchar](max) COLLATE Latin1_General_CI_AS NULL, [UpdateDate] [datetime] NULL, [Username] [nvarchar](8) COLLATE Latin1_General_CI_AS NULL, CONSTRAINT [PK_AuditB] PRIMARY KEY CLUSTERED ( [ID] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
This is a trigger for one table:
INSERT INTO ILSe.dbo.Audit ([Type], TableName, PK, FieldName, OldValue, NewValue, Username) SELECT CASE WHEN NOT EXISTS (SELECT ID FROM deleted WHERE ID = ISNULL(ins.PK,del.PK)) THEN 'I' WHEN NOT EXISTS (SELECT ID FROM inserted WHERE ID = ISNULL(ins.PK,del.PK)) THEN 'D' ELSE 'U' END as [Type], 'AGB' as TableName, ISNULL(ins.PK,del.PK) as PK, ISNULL(ins.FieldName,del.FieldName) as FieldName, del.FieldValue as OldValue, ins.FieldValue as NewValue, ISNULL(ins.Username,del.Username) as Username FROM (SELECT insRowTbl.PK, insRowTbl.Username, attr.insRow.value('local-name(.)', 'nvarchar(128)') as FieldName, attr.insRow.value('.', 'nvarchar(max)') as FieldValue FROM (Select i.ID as PK, i.LastModifiedBy as Username, convert(xml, (select i.* for xml raw)) as insRowCol from inserted as i ) as insRowTbl CROSS APPLY insRowTbl.insRowCol.nodes('/row/@*') as attr(insRow) ) as ins FULL OUTER JOIN (SELECT delRowTbl.PK, delRowTbl.Username, attr.delRow.value('local-name(.)', 'nvarchar(128)') as FieldName, attr.delRow.value('.', 'nvarchar(max)') as FieldValue FROM (Select d.ID as PK, d.LastModifiedBy as Username, convert(xml, (select d.* for xml raw)) as delRowCol from deleted as d ) as delRowTbl CROSS APPLY delRowTbl.delRowCol.nodes('/row/@*') as attr(delRow) ) as del on ins.PK = del.PK and ins.FieldName = del.FieldName WHERE isnull(ins.FieldName,del.FieldName) not in ('LastModifiedBy', 'ID', 'TimeStamp') and ((ins.FieldValue is null and del.FieldValue is not null) or (ins.FieldValue is not null and del.FieldValue is null) or (ins.FieldValue != del.FieldValue))
This trigger is for a single table named AGB. A table named AGB has a primary key column with a name identifier and a column named LastModifiedBy that contains the name of the user who made the last change.
The trigger consists of two parts, first it converts the columns of the inserted and deleted tables into rows. This is described in detail here: stack overflow
Then it concatenates the rows (one row per column) of the inserted and deleted tables using the primary key and the field name and writes a row for each changed column. It does NOT register changes to ID, TimeStamp or LastModifiedByColumn.
You can insert your own names TableName, Columns.
You can also create the following stored procedure, and then call this stored procedure to generate your triggers:
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[_create_audit_trigger]') AND type in (N'P', N'PC')) BEGIN EXEC dbo.sp_executesql @statement = N'CREATE PROCEDURE [dbo].[_create_audit_trigger] AS' END ALTER PROCEDURE [dbo].[_create_audit_trigger] @TableName varchar(max), @IDColumnName varchar(max) = 'ID', @LastModifiedByColumnName varchar(max) = 'LastModifiedBy', @TimeStampColumnName varchar(max) = 'TimeStamp' AS BEGIN PRINT 'start ' + @TableName + ' (' + @IDColumnName + ', ' + @LastModifiedByColumnName + ', ' + @TimeStampColumnName + ')' IF EXISTS (SELECT * FROM sys.objects WHERE [type] = 'TR' AND [name] = 'tr_audit_'+@TableName) EXEC ('DROP TRIGGER [dbo].tr_audit_'+@TableName) EXEC (' CREATE TRIGGER [dbo].[tr_audit_'+@TableName+'] ON [ILSe].[dbo].['+@TableName+'] FOR INSERT, UPDATE, DELETE AS BEGIN SET NOCOUNT ON; INSERT INTO ILSe.dbo.Audit ([Type], TableName, PK, FieldName, OldValue, NewValue, Username) SELECT CASE WHEN NOT EXISTS (SELECT '+@IDColumnName+' FROM deleted WHERE '+@IDColumnName+' = ISNULL(ins.PK,del.PK)) THEN ''I'' WHEN NOT EXISTS (SELECT '+@IDColumnName+' FROM inserted WHERE '+@IDColumnName+' = ISNULL(ins.PK,del.PK)) THEN ''D'' ELSE ''U'' END as [Type], '''+@TableName+''' as TableName, ISNULL(ins.PK,del.PK) as PK, ISNULL(ins.FieldName,del.FieldName) as FieldName, del.FieldValue as OldValue, ins.FieldValue as NewValue, ISNULL(ins.Username,del.Username) as Username FROM (SELECT insRowTbl.PK, insRowTbl.Username, attr.insRow.value(''local-name(.)'', ''nvarchar(128)'') as FieldName, attr.insRow.value(''.'', ''nvarchar(max)'') as FieldValue FROM (Select i.'+@IDColumnName+' as PK, i.'+@LastModifiedByColumnName+' as Username, convert(xml, (select i.* for xml raw)) as insRowCol from inserted as i) as insRowTbl CROSS APPLY insRowTbl.insRowCol.nodes(''/row/@*'') as attr(insRow)) as ins FULL OUTER JOIN (SELECT delRowTbl.PK, delRowTbl.Username, attr.delRow.value(''local-name(.)'', ''nvarchar(128)'') as FieldName, attr.delRow.value(''.'', ''nvarchar(max)'') as FieldValue FROM (Select d.'+@IDColumnName+' as PK, d.'+@LastModifiedByColumnName+' as Username, convert(xml, (select d.* for xml raw)) as delRowCol from deleted as d) as delRowTbl CROSS APPLY delRowTbl.delRowCol.nodes(''/row/@*'') as attr(delRow)) as del on ins.PK = del.PK and ins.FieldName = del.FieldName WHERE isnull(ins.FieldName,del.FieldName) not in ('''+@LastModifiedByColumnName+''', '''+@IDColumnName+''', '''+@TimeStampColumnName+''') and ((ins.FieldValue is null and del.FieldValue is not null) or (ins.FieldValue is not null and del.FieldValue is null) or (ins.FieldValue != del.FieldValue)) END ') PRINT 'end ' + @TableName PRINT '' END