2

I posted a question about my Dynamic Trigger yesterday and it turns out my last issue was due to including one too many fields in my unpivot. That being said, my creation is working! (evil laugh)

A lot of you expressed your concerns that this could be bad for the overall health of my database and I am curious why that would be... Please elaborate after reviewing what I have come up with.

This is a trigger that can be placed on ANY TABLE on ANY SERVER (once you have the necessary things in place). You either need to follow the model below or tweak it to your liking. (i.e. your own log table, your own way of tracking who made a change, etc.)

All you need: The trigger, a logging table (see mine below), and a field in your tables that tracks the user that made the change (in our case all connections go through one centralized SQL profile so this is the only way we can track which user made the change (by passing it as a parameter at the time of update)).

tblChangeLog:

  • ID - int - NOT NULL (Identitity (1, 1))
  • Message - varchar(max) - NOT NULL
  • TableName - varchar(50) - NOT NULL
  • PrimaryKey - varchar(50) - NOT NULL
  • Activity - varchar(50) - NOT NULL
  • CreatedByUser - varchar(30) - NOT NULL - DEFAULT ('System')
  • CreatedDate - datetime - NOT NULL - DEFAULT (getdate())

Trigger: (Haven't added delete/insert yet, may only do this for updates)...

CREATE TRIGGER (your name here) ON (your table here)

AFTER UPDATE

AS

BEGIN

SET NOCOUNT ON;

DECLARE @tableName  sysname
DECLARE @tableId    INT
DECLARE @activity   VARCHAR(50)
DECLARE @sql        nvarchar(MAX)

-- DETECT AN UPDATE (Records present in both inserted and deleted)
IF EXISTS(SELECT * FROM inserted) AND EXISTS(SELECT * FROM deleted)
    BEGIN

        SET @activity = 'UPDATE'

        -- Gets TableName and TableId
        SELECT @tableName = OBJECT_NAME(parent_object_id)
            , @tableId = parent_object_id
        FROM sys.objects 
        WHERE sys.objects.name = OBJECT_NAME(@@PROCID)

        -- Get the user who made the change
        DECLARE @LastUpdUser VARCHAR(50)
        SELECT @LastUpdUser = LastUpdUser FROM inserted

        -- Stores possible column names
        CREATE TABLE #Columns (
            name varchar(100)
        )

        -- Stores only updated columns
        CREATE TABLE #Changes (
            Id              sql_variant,
            FieldName       sysname,
            FieldValue_OLD  sql_variant,
            FieldValue_NEW  sql_variant,
            DateChanged     datetime DEFAULT (GETDATE()),
            LastUpdUser     varchar(50),
            GroupNumber     int
        )

        -- Gathers names of all possible updated columns (excluding generic)
        INSERT INTO #columns
        SELECT Name
        FROM sys.columns
        WHERE object_id = @tableId
            AND Name NOT IN ('LastUpdUser', 'LastUpdDate', 'CreatedByUser', 'CreatedDate', 'ConcurrencyId')

        -- Builds 2 dynamic strings of columns to use in pivot
        DECLARE @castFields nvarchar(max) -- List of columns being cast to sql_variant
        DECLARE @listOfFields nvarchar(max) -- List of columns for unpivot
        SELECT @castFields = COALESCE(@castFields + ', ', '') + ('CAST(' + QUOTENAME(Name) + ' AS sql_variant) [' + Name + ']') FROM #columns
        SELECT @listOfFields = COALESCE(@listOfFields + ', ', '') + QUOTENAME(Name) FROM #columns WHERE Name <> 'Id'

        -- Inserting deleted/inserted data into temp tables
        SELECT * into #deleted FROM deleted
        SELECT * into #inserted FROM inserted

        SELECT @sql = ';WITH unpvt_deleted AS (
                SELECT Id, FieldName, FieldValue
                FROM
                    (SELECT ' + @castFields + '
                    FROM #deleted) p
                UNPIVOT
                    (FieldValue FOR FieldName IN
                        (' + @listOfFields + ')
                ) AS deleted_unpivot
            ),

            unpvt_inserted AS (
                SELECT Id, FieldName, FieldValue
                FROM
                    (SELECT ' + @castFields + '
                     FROM #inserted) p
                UNPIVOT
                    (FieldValue FOR FieldName IN
                        (' + @listOfFields + ')
                ) AS inserted_unpivot
            )

            INSERT INTO #Changes (Id, FieldName, FieldValue_OLD, FieldValue_NEW, LastUpdUser, GroupNumber)
            SELECT COALESCE(D.Id, I.Id) Id
                , COALESCE(D.FieldName, I.FieldName) FieldName
                , D.FieldValue AS FieldValue_OLD
                , I.FieldValue AS FieldValue_NEW
                , ''' + @LastUpdUser + '''
                , DENSE_RANK() OVER(ORDER BY I.Id) AS GroupNumber
            FROM unpvt_deleted D
                FULL OUTER JOIN unpvt_inserted I ON D.Id = I.Id AND D.FieldName = I.FieldName
            WHERE D.FieldValue <> I.FieldValue

            DECLARE @i INT = 1
            DECLARE @lastGroup INT
            SELECT @lastGroup = MAX(GroupNumber) FROM #Changes

            WHILE @i <= @lastGroup
            BEGIN

                DECLARE @Changes VARCHAR(MAX)
                SELECT @Changes = COALESCE(@Changes + ''; '', '''')
                    + UPPER(CAST(FieldName AS VARCHAR)) + '': ''
                    + '''''''' + CAST(FieldValue_OLD AS VARCHAR) + '''''' to ''
                    + '''''''' + CAST(FieldValue_NEW AS VARCHAR) + ''''''''
                FROM #Changes
                WHERE GroupNumber = @i
                ORDER BY GroupNumber

                INSERT INTO tblChangeLog (Message, TableName, PrimaryKey, Activity, CreatedByUser, CreatedDate)
                SELECT Distinct @Changes, ''' + @tableName + ''', CONVERT(VARCHAR, Id), ''' + @activity + ''', LastUpdUser, DateChanged
                FROM #Changes
                WHERE GroupNumber = @i

                SET @Changes = NULL
                SET @i += 1

            END         

            DROP TABLE #Changes
            DROP TABLE #columns
            DROP TABLE #deleted
            DROP TABLE #inserted'

        exec sp_executesql @sql

    END

END

To make this even more universal, my DBA has come up with a nifty script and dummy table that will store this trigger as a row in the dummy table. The script uses a cursor and finds every table on a database and creates a trigger for each table on the fly. We have tested that as well and it works like a charm, not bothering posting that here at this time.

1
  • Comments are not for extended discussion; this conversation has been moved to chat. Commented Mar 22, 2019 at 21:50

2 Answers 2

2

Some things I noticed:

  1. There can be more than one record in inserted or deleted. Sql Server will call a trigger only once for a statement, even if it impacts many rows. It's possible the application is built where this is extremely unlikely, but I've seen this come back to bite people in triggers many times.
  2. Order is not preserved between when inserted and deleted are processed.
  3. I would use a parameter name with sq_executesql for the LastUpdUser. I know you're unlikely to have any problems caused by an organization-assigned username as is, but I always feel better about using parameters as much as possible, and it's possible here. The same goes for @activity.
  4. Performance. You're doing this on every single change, and it involves a considerable amount of string processing. String processing in Sql Server is surprisingly expensive, such that this is likely to create a meaningful performance penalty.
  5. Why? This ability is already built-in to Sql Server, and as of Sql Server 2016 sp2 it's included with Standard Edition (no need to get Enterprise just for this).
Sign up to request clarification or add additional context in comments.

Comments

1

The main reason to be considered bad use, is that you're adding a good amount of additional writes to each transaction. You're writing to #inserted, #deleted, and #changes when all those steps are unnecessary.

Since a table structure is fairly static, a trigger like this should be just as static. The idea of creating the triggers for the whole database in a dynamic way is understandable, but the support for these triggers and tables should follow proper source control.

Of course, SQL injection is something that people will mention with dynamic code. But if you have column names that will create SQL injection, you might have bigger problems.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.