Asked by:
Generic Audit Trigger CLR C#(Works when the trigger is attached to any Table)

General discussion
-
This Audit Trigger is Generic (i.e. non-"Table Specific") attach it to any tabel and it should work. Be sure and create the 'Audit' table first though.
The following code write audit entries to a Table called
'Audit'
with columns
'ActionType' //varchar
'TableName' //varchar
'PK' //varchar
'FieldName' //varchar
'OldValue' //varchar
'NewValue' //varchar
'ChangeDateTime' //datetime
'ChangeBy' //varcharusing System;
using System.Data;
using System.Data.SqlClient;
using Microsoft.SqlServer.Server;public
partial class Triggers
{
//A Generic Trigger for Insert, Update and Delete Actions on any Table
[Microsoft.SqlServer.Server.SqlTrigger(Name = "AuditTrigger", Event = "FOR INSERT, UPDATE, DELETE")] public static void AuditTrigger()
{
SqlTriggerContext tcontext = SqlContext.TriggerContext; //Trigger Context
string TName; //Where we store the Altered Table's Name
string User; //Where we will store the Database Username
DataRow iRow; //DataRow to hold the inserted values
DataRow dRow; //DataRow to how the deleted/overwritten values
DataRow aRow; //Audit DataRow to build our Audit entry with
string PKString; //Will temporarily store the Primary Key Column Names and Values here
using (SqlConnection conn = new SqlConnection("context connection=true"))//Our Connection
{
conn.Open();//Open the Connection
//Build the AuditAdapter and Mathcing Table
SqlDataAdapter AuditAdapter = new SqlDataAdapter("SELECT * FROM Audit WHERE 1=0", conn);
DataTable AuditTable = new DataTable();
AuditAdapter.FillSchema(AuditTable, SchemaType.Source);
SqlCommandBuilder AuditCommandBuilder = new SqlCommandBuilder(AuditAdapter);//Populates the Insert command for us
//Get the inserted values
SqlDataAdapter Loader = new SqlDataAdapter("SELECT * from INSERTED", conn);
DataTable inserted = new DataTable();
Loader.Fill(inserted);
//Get the deleted and/or overwritten values
Loader.SelectCommand.CommandText = "SELECT * from DELETED";
DataTable deleted = new DataTable();
Loader.Fill(deleted);
//Retrieve the Name of the Table that currently has a lock from the executing command(i.e. the one that caused this trigger to fire)
SqlCommand cmd = new SqlCommand("SELECT object_name(resource_associated_entity_id) FROM
ys.dm_tran_locks WHERE request_session_id = @@spid and resource_type = 'OBJECT'", conn);
TName = cmd.ExecuteScalar().ToString();
//Retrieve the UserName of the current Database User
SqlCommand curUserCommand = new SqlCommand("SELECT system_user", conn);
User = curUserCommand.ExecuteScalar().ToString();
//Adapted the following command from a T-SQL audit trigger by Nigel Rivett
//http://www.nigelrivett.net/AuditTrailTrigger.html
SqlDataAdapter PKTableAdapter = new SqlDataAdapter(@"SELECT c.COLUMN_NAME
from INFORMATION_SCHEMA.TABLE_CONSTRAINTS pk ,
INFORMATION_SCHEMA.KEY_COLUMN_USAGE c
where pk.TABLE_NAME = '" + TName + @"'
and CONSTRAINT_TYPE = 'PRIMARY KEY'
and c.TABLE_NAME = pk.TABLE_NAME
and c.CONSTRAINT_NAME = pk.CONSTRAINT_NAME", conn);
DataTable PKTable = new DataTable();
PKTableAdapter.Fill(PKTable);
switch (tcontext.TriggerAction)//Switch on the Action occuring on the Table
{
case TriggerAction.Update:
iRow = inserted.Rows[0];//Get the inserted values in row form
dRow = deleted.Rows[0];//Get the overwritten values in row form
PKString = PKStringBuilder(PKTable, iRow);//the the Primary Keys and There values as a string
foreach (DataColumn column in inserted.Columns)//Walk through all possible Table Columns
{
if (!iRow[column.Ordinal].Equals(dRow[column.Ordinal]))//If value changed
{
//Build an Audit Entry
aRow = AuditTable.NewRow();
aRow["ActionType"] = "U";//U for Update
aRow["TableName"] = TName;
aRow["PK"] = PKString;
aRow["FieldName"] = column.ColumnName;
aRow["OldValue"] = dRow[column.Ordinal].ToString();
aRow["NewValue"] = iRow[column.Ordinal].ToString();
aRow["ChangeDateTime"] = DateTime.Now.ToString();
aRow["ChangedBy"] = User;
AuditTable.Rows.InsertAt(aRow, 0);//Insert the entry
}
}
break;
case TriggerAction.Insert:
iRow = inserted.Rows[0];
PKString = PKStringBuilder(PKTable, iRow);
foreach (DataColumn column in inserted.Columns)
{
//Build an Audit Entry
aRow = AuditTable.NewRow();
aRow["ActionType"] = "I";//I for Insert
aRow["TableName"] = TName;
aRow["PK"] = PKString;
aRow["FieldName"] = column.ColumnName;
aRow["OldValue"] = null;
aRow["NewValue"] = iRow[column.Ordinal].ToString();
aRow["ChangeDateTime"] = DateTime.Now.ToString();
aRow["ChangedBy"] = User;
AuditTable.Rows.InsertAt(aRow, 0);//Insert the Entry
}
break;
case TriggerAction.Delete:
dRow = deleted.Rows[0];
PKString = PKStringBuilder(PKTable, dRow);
foreach (DataColumn column in inserted.Columns)
{
//Build and Audit Entry
aRow = AuditTable.NewRow();
aRow["ActionType"] = "D";//D for Delete
aRow["TableName"] = TName;
aRow["PK"] = PKString;
aRow["FieldName"] = column.ColumnName;
aRow["OldValue"] = dRow[column.Ordinal].ToString();
aRow["NewValue"] = null;
aRow["ChangeDateTime"] = DateTime.Now.ToString();
aRow["ChangedBy"] = User;
AuditTable.Rows.InsertAt(aRow, 0);//Insert the Entry
}
break;
default:
//Do Nothing
break;
}
AuditAdapter.Update(AuditTable);//Write all Audit Entries back to AuditTable
conn.Close(); //Close the Connection
}
}
//Helper function that takes a Table of the Primary Key Column Names and the modified rows Values
//and builds a string of the form "<PKColumn1Name=Value1>,PKColumn2Name=Value2>,......"
public static string PKStringBuilder(DataTable primaryKeysTable, DataRow valuesDataRow)
{
string temp = String.Empty;
foreach (DataRow kColumn in primaryKeysTable.Rows)//for all Primary Keys of the Table that is being changed
{
temp = String.Concat(temp, String.Concat("<", kColumn[0].ToString(), "=", valuesDataRow[kColumn[0].ToString)].ToString(), ">,"));
}
return temp;
}
}The trick was getting the Table Name and the Primary Key Columns.
I hope this code is found useful.Comments and Suggestion will be much appreciated.
Tuesday, December 5, 2006 3:53 PM
All replies
-
Hi Shane,
This can be used to extend the below code I found on google for Visual Basic generit audit as well...
http://sqljunkies.com/Article/4CD01686-5178-490C-A90A-5AEEF5E35915.scuk
Just adding this code to David Ziffer's work (after opening connection):
Dim SourceTableStr As String = ""
Dim cmd As New SqlCommand
With cmd
.CommandText = "SELECT object_name(resource_associated_entity_id) " & _
"FROM sys.dm_tran_locks WHERE " & _
"request_session_id = @@spid and " & _
"resource_type = 'OBJECT' "
.Connection = Connection
.CommandType = CommandType.Text
SourceTableStr = cmd.ExecuteScalar().ToString
End WithAnd somewhere down the code, add:
TableName = SourceTableStr
Then, also make sure you
GRANT VIEW SERVER STATE to [username]
on the master database on your server, since this permission is required to access sys.dm_tran_locks ( I suppose that's also required for your C# code unless you assume the user has such right e.g. sysadmin).
Tuesday, December 12, 2006 8:21 AM -
Hi,
This code seems to really useful and it worked well. Thanks for posting.
I have found issue where the single update statement updated more than one record. In this case only last record upated gets audited and not all the records. This seems to serious drawback of this approach.
Thanks,
Jignesh Vyas
Tuesday, November 27, 2007 1:39 AM -
hi, did anyone tested this trigger with cascaded events... i mean: you have a table A and table B, a, insert in table A fires another trigger and updates (or insert or whatever) the table B; what hapen with this:
//Retrieve the Name of the Table that currently has a lock from the executing command(i.e. the one that caused this trigger to fire)
SqlCommand cmd = new SqlCommand("SELECT object_name(resource_associated_entity_id) FROM sys.dm_tran_locks WHERE request_session_id = @@spid and resource_type = 'OBJECT'", conn);
what table name do the trigger gets on cascaded event for table B?? can the select return two tables?
cheersWednesday, November 28, 2007 12:05 AM -
Well, you could do this:
for (int i = 0; i < inserted.Rows.Count; i++) { dRow = deleted.Rows[ i ]; iRow = inserted.Rows[ i ]; ...
so it would do the same stuff with every row in the updated setTuesday, July 15, 2008 5:23 PM -
I was wondering if anyone encountered this and how they resolved it. I'm thinking that I will need to dynamically compose a list of field names when I go to select from the INSERTED and DELETED tables excluding those fields where the type is image or text etc.
Is there a better way??
thanks,
JustinTuesday, September 23, 2008 3:54 PM -
Hi guys,
This post is very helpful. Thanks for posting this stuff.
I have one query. If we want that this trigger should be applicable to some columns of the particular table then how to achieve this?
Because Audit for all columns doesn't make any sense since some columns are there for internal use of the application e.g. calculation fields.
your help on this would be greatly appreciated.
Regards,
PrabiMonday, December 29, 2008 10:16 AM -
HiIts sounds good.However i cant help thinking what would happen if i insert data in several tables simultaneously.Like if somebody runs transaction that updates several tables.What would happen then?Monday, January 25, 2010 2:43 PM
-
I'm trying to create this type of trigger but i got the following error.
"The requested operation requires a SqlClr context, which is only available when running in the Sql Server process."
Please help me.
ag.mothilalMonday, August 23, 2010 10:41 AM -
Shane,
Could you respond to this question? I tried compiling this but in VS2010/SQL2008, compile succeeded but in deploy I get error, "trigger target is not valid". (i.e. because it is missing).
Johnny
Monday, August 30, 2010 5:33 PM -
Got the same issue on my end, and dug into it a bit.
The SqlTrigger attribute used right before the declaration of AuditTrigger() only specifies two of the three properties it needs: Name and Event. The third one, Target (i.e. the table name to which the trigger will be attached), is missing. Once I added it, this deployed properly.
The MSDN says that this property is mandatory (http://msdn.microsoft.com/en-us/library/microsoft.sqlserver.server.sqltriggerattribute.target(v=VS.90).aspx), so I don't see how the code could be used as-is to be a generic trigger. You need to specifically add this trigger to every table on which you want the auditing to work.
So the question is - how did the author make this generic? or was the omission of the Target property a mistake?
Wednesday, November 30, 2011 3:54 PM -
I just happened to run across this post and I have to warn everyone because I recently ran into supposed "generic" triggers like this.
Before you implement such a thing in Production, do some performance testing especially on wider tables. The problem with these bloody generic triggers is that they have to do a whole lot of work in many areas. Many people frequently make mistakes in writing how the dynamic SQL is generated. Further, in order to keep from having to make at least one round trip per column in the table, some folks make a copy of the Inserted/Deleted tables (usually using SELECT * even in SQLCLR!!!) of the Inserted and Deleted tables and store it as a local row set. The other reason for this mistake is because the INSERTED/DELETED tables aren't available to dynamic SQL even within the trigger.
You also have to remember that the values of certain datatypes of columns aren't available in the INSERTED/DELETED tables so you also need to make a bit of special handling where those are concerned.
I could go on about all the things that can go wrong but it's easier to just say, do a performance test especially if your tables will need to suffer though batch inserts and updates. I found the exact trigger code from this post in 8 of the tables in a high profile database that my current company has. Updating just 4 columns on 10,000 rows of a 137 column table without the trigger was nearly instantaneous. With the trigger, updating those same 4 columns and 10,000 rows took almost 4 minutes.
I wrote a stored procedure that would write "hard coded" audit triggers by column for me. When those triggers were inplace, the triggers only added about 500 milliseconds to the 4 column, 10,000 row update.
Just in case I lost you somewhere above, I state it plain and simple... If you're considering implementation of ANY form of generic audit trigger eith in T-SQL or SQLCLR, you REALLY need to do some batch performance testing because these types of triggers are also general performance killers.
--Jeff Moden
- Edited by Jeff Moden Saturday, May 19, 2012 5:34 PM
Saturday, May 19, 2012 4:32 PM -
The problem of this code is that if there are more objects are modified (locked) within one transaction, the query will return more than one column. The trigger will randomly pickup one of them which might be the wrong one, then trigger may fail or get you wrong audit
SqlCommand cmd = new SqlCommand("SELECT object_name(resource_associated_entity_id) FROM ys.dm_tran_locks WHERE request_session_id = @@spid and resource_type = 'OBJECT'", conn); TName = cmd.ExecuteScalar().ToString();
you may want to use this to get the table name
select object_name(parent_id) from sys.triggers where object_id = @@procid
Second, the inserted and deleted tables may contain more than one row. the CLR does not handle that.
Regards
Friday, July 6, 2012 7:04 PM -
an also, you don't need to get the column names through SQL Server since you already the names in the data table. Since you retrieve all teh rows back to CLR, memory pressure may happen.
Regards
Friday, July 6, 2012 7:28 PM -
Yes, there is a better way: instead of using a SqlDataAdapter, use a SqlCommand and execute the statements yourself using its ExecuteReader method. When calling ExecuteReader, pass in the CommandBehavior.SequentialAccess modifier (and-ed with any other modifiers that may be usefull in your situation). Then use the returned SqlDataReader to fetch the data.
SequentialAccess means you have to read each column in sequence (i.e. for a table with 2 columns: row 0, column 0 then row 0, column 1 then row 1, column 0, row 1, column 1, etc.). You can skip rows and you can skip columns, but you can never go back: If you try to access column 0 after accessing column 1, you'll get an error. If you try to read row 0 after reading row 1, you'll get an error. It is realy that strict, so you'll have to realy think while coding... But the good thing is that you don't specify column names in your c# (which speeds up processing), and it also means you can process binary large object columns, just like 'normal' columns. But the realy best thing is that SQL server doesn't need to allocate memory for the entire resultset while you're accessing it. i.e. if a statement is executed on your audited table that updates all (or just a lot) of rows, SQL server doesn't need to allocate memory for all these rows of data twice: inserted & deleted, (remember?). Instead your CLR will at any time only need sufficient memory for a single column being processed (or some buffer size when streaming large objects). So with some more programming effort you can make sure the server doesn't slow down or even crash when (accidently) a lot of data is touched in your audited table.
I'm not going to give it all away (we need to make living too), but here's a rudimentary example how to better read the data in the trigger code. This code doesn't do anyhting other than reading the changes. It's up to you to do something with the changes found.
[Microsoft.SqlServer.Server.SqlTrigger (Name="AuditTrigger", Target="dbo.Table1", Event="FOR INSERT, UPDATE, DELETE")] public static void AuditTrigger() { SqlPipe pipe = SqlContext.Pipe; SqlTriggerContext context = SqlContext.TriggerContext; using (SqlConnection conn = new SqlConnection(@"context connection=true")) { conn.Open(); // Determine if at least one row exists in // inserted and/or deleted. // I deliberatly don't use TriggerAction: // I don't want to execute any more queries // if zero rows were affected. i.e. I need // to do an exists check for each action // anyway, so it is cheaper to just // skip that check all together.
// Also, an update may change a primary key value,
// this is treated as a delete of the old key
// plus an insert of the new key.
bool inserted = false; bool deleted = false; SqlCommand datacmd = conn.CreateCommand(); datacmd.CommandType = CommandType.Text; datacmd.CommandText = "select case when exists( select top 1 * from inserted) then 1 else 0 end as insertedcount, case when exists (select top 1 * from deleted) then 1 else 0 end as deletedcount"; using (SqlDataReader datareader = datacmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult | CommandBehavior.SingleRow)) { if (datareader.Read()) { inserted = 0 == datareader.GetSqlInt32(0) ? false : true; deleted = 0 == datareader.GetSqlInt32(1) ? false : true; } else { throw new Exception("Unable to read inserted and deleted counts."); } } int cols = context.ColumnCount; // First find any newly created rows. if (inserted) { datacmd.CommandText = "select i.ID, i.Name from inserted i where not exists (select * from deleted d where d.ID = i.ID)"; using (SqlDataReader datareader = datacmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) { int rows = 0; while (datareader.Read()) { rows++; for (int i = 0; i < cols; i++) { object val = datareader.GetSqlValue(i); // Do whatever you need to do with the inserted value. } } } } // Now see if any rows were updated. if (inserted && deleted) { datacmd.CommandText = "select i.ID, d.ID, i.Name, d.Name from inserted i inner join deleted d on (d.ID = i.ID)"; using (SqlDataReader datareader = datacmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) { int rows = 0; while (datareader.Read()) { rows++; for (int i = 0; i < cols; i += 2) { object val = datareader.GetSqlValue(i); // Do whatever you need to do with the new value. val = datareader.GetSqlValue(i + 1); // Do whatever you need to do with the old value. } } } } // And finaly find any deleted rows. if (deleted) { datacmd.CommandText = "select d.ID, d.Name from deleted d where not exists (select * from inserted i where i.ID = d.ID)"; using (SqlDataReader datareader = datacmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult)) { int rows = 0; while (datareader.Read()) { rows++; for (int i = 0; i < cols; i++) { object val = datareader.GetSqlValue(i); // Do whatever you need to do with the deleted value. } } } }
SQL expert for JF Hillebrand IT BV - The Netherlands.
- Edited by rrozema Wednesday, October 24, 2012 8:27 AM Added example code
Wednesday, October 24, 2012 7:46 AM -
Got the same issue on my end, and dug into it a bit.
The SqlTrigger attribute used right before the declaration of AuditTrigger() only specifies two of the three properties it needs: Name and Event. The third one, Target (i.e. the table name to which the trigger will be attached), is missing. Once I added it, this deployed properly.
The MSDN says that this property is mandatory (http://msdn.microsoft.com/en-us/library/microsoft.sqlserver.server.sqltriggerattribute.target(v=VS.90).aspx), so I don't see how the code could be used as-is to be a generic trigger. You need to specifically add this trigger to every table on which you want the auditing to work.
So the question is - how did the author make this generic? or was the omission of the Target property a mistake?
SQL expert for JF Hillebrand IT BV - The Netherlands.
Wednesday, October 24, 2012 8:39 AM -
Over time many people seem to have gotten stuck on the issue of finding which table a SQLCLR trigger got called for. I've found a request with a similar intent of Erland Somerskog on Connect dating back to 2006. That got rejected because Erland suggested to change the behavior of @@procid when called from a SQLCLR trigger. The issue can however with a lot less impact be resolved if MS want to add a single property to the SqlTriggerContext: ParentID. ParentID is an integer, representing the object_id of the table that got modified firing the trigger. Using this object id we should all be able to get our answer, right? And adding this property shouldn't -as far as I can see it- be much work for MS and since only a new property is added it should have virtually no risk of breaking existing code.
I have created a new Connect feature request, asking for this new property.
Please all vote for it to be implemented?
SQL expert for JF Hillebrand IT BV - The Netherlands.
Wednesday, October 24, 2012 10:37 AM -
Hi David.
The SqlTrigger attribute used right before the declaration of AuditTrigger() only specifies two of the three properties it needs: Name and Event. The third one, Target (i.e. the table name to which the trigger will be attached), is missing. Once I added it, this deployed properly.
The MSDN says that this property is mandatory....
So the question is - how did the author make this generic?
Those properties of the SqlTrigger attribute -- Name, Event, and Target -- are only used by SSDT when generating the publish script. But SSDT isn't required to do any publishing. You could create the T-SQL to create the Assembly and the T-SQL wrapper objects manually. You could use BinaryFormatter (an open-source command-line utility I wrote and is available on GitHub) to convert the DLL to the hex-bytes string needed for the CREATE ASSEMBLY statement, and then just add whatever CREATE {object} statements you need.
Take care, Solomon...
- Edited by Solomon Rutzky Sunday, June 24, 2018 10:12 PM
Sunday, June 24, 2018 10:10 PM -
Hi Jeff.
The other reason for this mistake is because the INSERTED/DELETED tables aren't available to dynamic SQL even within the trigger.
Just to be clear about this one statement: the INSERTED and DELETED tables are accessible in dynamic SQL, but only when using SQLCLR ;-). Because all T-SQL submitted by SQLCLR objects (well, outside of RPC calls) is dynamic SQL.
Of course, I am in no way condoning use of the generic audit trigger posted at the top. At the very least it does not work in nested trigger scenarios, or when an explicit transaction is being used and has locks on other objects, or possibly other multi-object scenarios. And, it unnecessarily loads the INSERT and DELETED tables into memory. I am merely clarifying an interesting ability of SQLCLR Triggers.
Take care, Solomon...
- Edited by Solomon Rutzky Sunday, June 24, 2018 10:25 PM
Sunday, June 24, 2018 10:22 PM -
Hi John.
you may want to use this to get the table name
select object_name(parent_id) from sys.triggers where object_id = @@procid
Your suggestion will not work. The @@PROCID variable is not populated with a useful value within the execution context of SQLCLR Triggers. This is the reason that the O.P. was using the error-prone method of looking in sys.dm_tran_locks (that you were pointing out the problem with) in the first place.
Take care, Solomon...
Sunday, June 24, 2018 10:32 PM -
Over time many people seem to have gotten stuck on the issue of finding which table a SQLCLR trigger got called for. The issue can ... be resolved if MS want to add a single property to the SqlTriggerContext: ParentID. ParentID is an integer, representing the object_id of the table that got modified firing the trigger. Using this object id we should all be able to get our answer, right? And adding this property shouldn't -as far as I can see it- be much work for MS and since only a new property is added it should have virtually no risk of breaking existing code.
I have created a new Connect feature request, asking for this new property.
Please all vote for it to be implemented?
Hi rrozema. At this point MS Connect has been shut down, and most of the items have been migrated (incompetently) to the differently yet equally awful UserVoice system. I searched on there but could not find your Connect item. Are you able to find it? If so, can you please post the link. If not, do you mind recreating it and then posting the link here? I would recommend suggesting that the property name be either "ParentObjectID" or "parent_object_id" since that is the name of the column in sys.objects that the value would be coming from. It might not hurt also suggesting that they add "object_id" / "ObjectID" which is the ID of the T-SQL wrapper object of the SQLCLR Trigger (which is most analogous to @@PROCID within the context of a T-SQL trigger). Thanks.
P.S. Several posts in this thread have been cross-posted to:
How does a C# CLR trigger know the table it was called for?
- Edited by Solomon Rutzky Monday, June 25, 2018 12:30 AM
Monday, June 25, 2018 12:29 AM