Announcement

Collapse
No announcement yet.

Universeller History-Trigger

Collapse
X
  • Filter
  • Time
  • Show
Clear All
new posts

  • Universeller History-Trigger

    Hallo zusammen,

    für diverse Tabellen einer Datenbank sollen Datensätze in einer History-Tabelle erzeugt werden (der Datenbankserver ist ein SQL 2000 Server).

    Nun gibt es ja die praktischen Trigger und man kann mit der temporären Tabelle "inserted" im Trigger die eingefügten Daten auslesen und in eine identisch aufgebaute History-Tabelle schreiben.

    Das wäre aber viel zu aufwendig, da sich die Struktur der Tabellen bisweilen verändert und außerdem jede Tabelle, die eine History benötigt, eine strukturidentische Tabelle für die Aufnahme der History-Daten benötigen würde. Vom Trigger her ist dies natürlich die einfachste und performanteste Lösung.

    Ich habe mir dazu die folgende Lösung ausgedacht (die auch funktioniert):

    1. Erstellen einer universellen History-Tabelle, die für jede Zeile ein Feld der Tabelle speichert, deren geänderte Werte man sichern möchte. Die Struktur sieht so aus:

    Code:
    HF_DateTime	smalldatetime
    HF_UserID         int
    HF_TableName	varchar(80)
    HF_FieldName	varchar(100)
    HF_FieldValue	ntext, NULL allowed
    HF_Action	         char(6)
    HF_ChangeID	int
    t_timestamp	timestamp
    2. Ein INSERT-Trigger über der Tabelle (hier "tblPlan") sieht dann so aus:

    Code:
    -- =============================================
    -- Author:		Christian Coppes
    -- Create date: 16.07.2009
    -- Description:	History Trigger für "tblPlan"
    -- =============================================
    ALTER TRIGGER [dbo].[tblPlanTrigger_Insert] 
       ON  [dbo].[tblPlan]
       AFTER INSERT
    AS 
    BEGIN
    	-- SET NOCOUNT ON added to prevent extra result sets from
    	-- interfering with SELECT statements.
    	SET NOCOUNT ON;
    
    --  @Cmd muß als nvarchar definiert werden
        DECLARE @UserID      AS int,
                @IDField     AS int,
    			@ColumnName  AS nvarchar(100),
                @FieldValue  AS nvarchar(4000),
                @Cmd         AS nvarchar(500),
                @TableName   AS nvarchar(7),
                @CurrentDate AS smalldatetime
    	
    	SET @TableName='tblPlan'
    	SET @CurrentDate=GetDate()
    
    --  Es müssen alle Spalten ausgeklammert werden, die Timestamp-Werte
    --  oder vom Typ ntext/text/image sind
    	DECLARE TableColumnname CURSOR FOR
    		SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
    		WHERE TABLE_NAME = @TableName
    			AND DATA_TYPE<>'text' 
                AND DATA_TYPE<>'ntext' 
    			AND DATA_TYPE<>'image'
    			AND DATA_TYPE<>'timestamp'
    		ORDER BY COLUMN_NAME
    
    	-- ein zweiter Cursor ist nötig, da die Tabelle "inserted" mehr als einen
    	-- Eintrag enthalten kann, wenn beispielsweise ein INSERT INTO tblPlan SELECT...
    	-- durchgeführt wird.
    	DECLARE TableInserted CURSOR FOR
    		SELECT IdentityCol, n_lastchange_userid FROM inserted
    
    	OPEN TableInserted
    	-- "IDField" enthält den Wert des ID-Feldes, um die eingefügte Zeile in der
    	-- Tabelle im EXECUTE-SELECT-Befehl wiederzufinden.
    	-- @UserID stammt aus einer Spalte "n_lastchange_userid", die vom Frontend aus
    	-- die ID des Benutzers enthält, der den Datensatz angelegt hat.
    	-- Hier kann nicht der SQL User verwendet werden, da die Anwendung über eine
    	-- eigene Benutzerverwaltung verfügt.
    	FETCH NEXT FROM TableInserted INTO @IDField,@UserID
    	WHILE @@FETCH_STATUS<>-1
    	BEGIN
    		OPEN TableColumnname
    		FETCH NEXT FROM TableColumnname INTO @ColumnName
    		WHILE @@FETCH_STATUS<>-1
    		BEGIN
    			-- da der Datensatz bereits vor der Triggerausführung eingefügt
    			-- wurde, kann das dynamische SQL auf die echte Tabelle zurückgreifen.
    			-- Im EXECUTE-Gültigkeitsbereich ist die temporäre Tabelle "inserted"
    			-- nicht verfügbar!
    			SET @Cmd='SET @ColValue=CONVERT(nvarchar(4000),' + 
    					 '(SELECT ' +             CAST(@ColumnName AS nvarchar(100)) +
    					 '   FROM ' +                  @TableName + 
    					 '  WHERE IdentityCol=' + CAST(@IDField AS nvarchar(50)) + '))'
    			-- nur mit sp_executesql ist es möglich, Variablen aus der
    			-- EXECUTE-Umgebung wieder zurückzubekommen.
    			EXECUTE sp_executesql @Cmd,
    								N'@ColValue nvarchar(4000) out',
    								@FieldValue out;
    			-- für jedes Feld in der Tabelle wird eine Zeile in die Tabelle
    			-- "HistoryFields" geschrieben mit dem entsprechenden Feldinhalt
    			INSERT dbo.tblHistoryFields
    				(HF_DateTime,HF_UserID,HF_TableName,HF_FieldName,HF_FieldValue,HF_Action,HF_ChangeID)
    				VALUES (@CurrentDate,@UserID,@TableName,@ColumnName,@FieldValue,'INSERT',@IDField);
    			FETCH NEXT FROM TableColumnname INTO @ColumnName
    		END -- while
    	    CLOSE TableColumnname
    		FETCH NEXT FROM TableInserted INTO @IDField,@UserID
    	END -- while
    	DEALLOCATE TableColumnname
    
    	CLOSE TableInserted
    	DEALLOCATE TableInserted
    END
    Dieser Trigger funktioniert insofern universal, als man für eine weitere Tabelle diesen Trigger lediglich kopieren und den Namen der Tabelle in der Variablen "@TableName" sowie ggf. den Spaltennamen für "n_lastchange_userid" ändern muß.

    Der Trigger berücksichtigt ebenfalls mehrere gleichzeitige INSERTs, etwa über einen INSERT ...SELECT.

    Wie gesagt, der Trigger funktioniert. Natürlich wird abhängig von der Anzahl der Spalten in der Tabelle, die getriggert werden soll, die Anzahl Zeilen in der History-Tabelle auf die Dauer schnell recht groß, so daß sicherlich ein Aufräumen der History irgendwann nötig wird.

    Was ich gerne wissen würde ist, ob jemand eine Idee hat, wie man die Performance dieses Triggers irgendwie steigern kann. Beim Speichern eines einzelnen Datensatzes im Formular (Frontend Access) ist das kein Problem, da muß man lediglich ein kleines bißchen länger warten als sonst.
    Fügt man allerdings mit einem INSERT..SELECT größere Mengen Daten in die Tabelle ein, geht die Performance sehr schnell in die Knie, was ja auch nicht wundert, da hier doch eine Reihe Operationen erledigt werden, die von Haus aus ja schon nicht als performant gelten, wie die Verwendung von CURSOR und dynamischem SQL.
    Mir ist lediglich keine bessere Variante eingefallen, bin für Ideen aber immer dankbar.

    Gruß

    Christian

    PS.: Der Code oben darf selbstverständlich beliebig in eigene Entwicklungen eingebaut werden.
    Zuletzt editiert von Bitsqueezer; 16.07.2009, 14:54.

  • #2
    Nur mal als Idee: Daten aus inserted/deleted in ein XML transferieren, dieses durch eine stored proc mittels XQuery verarbeiten. Weiß nicht, ob das schneller wäre als die Cursor-Version, bin auch nicht der XQuery-Spezialist, der sowas schnell mal programmieren und testen könnte - würde mich aber interessieren, also bitte Feedback, falls du es probierst
    Und beim feldweisen Schreiben könnte man sich bei einem update überlegen, ob man nicht statt aller Felder besser nur die Änderungen schreibt. Ich denke da an einen Datensatz mit 200 Feldern und nur ein einziges Feld wird geändert...

    bye,
    Helmut

    Comment


    • #3
      Hallo Helmut,

      danke für Deine Antwort.
      Mit XML-Queries habe ich leider noch nicht gearbeitet, daher wüßte ich jetzt auch nicht, wie man da einen Lösungsansatz hinbekommen könnte. Aber die Idee ist auf jeden Fall wert, mal überdacht zu werden.

      Problem ist höchstens, daß es sich um einen SQL Server 2000 handelt, bei dem XML bei weitem nicht so ausgereift ist wie bei 2005.

      Der oben gezeigte Trigger war nur der erste Schritt, in diesem Fall für den INSERT-Fall. Mittlerweile habe ich zwei weitere, eine für DELETE (fast das gleiche) und einen für UPDATE. Im letzteren habe ich in der Tat nur die geänderten Felder gespeichert, schon, damit der Benutzer der History nicht mühsam einen Vergleich ziehen muß, was sich denn nun verändert hat.

      Allerdings war diese Variante auch recht mühsam hinzubekommen, da SQL Server hier zwei Tabellen liefert, eine mit den Deleted-Records und eine mit den Insert-Records. Ein Vergleich ist nur mit UPDATED() oder mit COLUMNS_UPDATED möglich. UPDATED setzt aber wieder die Spaltennamen voraus, womit sich das gleiche Problem ergibt, während die andere Methode mit ihren Bitfeldern so kompliziert und fehlerträchtig ist (welche Reihenfolge haben wohl die Spalten, welche Felder müssen dann in die History? usw.), habe ich selbst einen Vergleich zwischen beiden Records gemacht. Da der Gültigkeitsbereich von EXECUTE die beiden virtuellen Tabellen "deleted" und "inserted" nicht mehr kennt, habe ich weiterexperimentiert, bis mir aufgefallen ist, daß im EXECUTE-Bereich zwar keine Variablen und virtuelle Tabellen aus der aufrufenden Prozedur verwendet werden können, dagegen aber sehr wohl temporäre Tabellen. So habe ich dann eine Kopie der beiden virtuellen Tabellen in je eine temporäre geschrieben und diese dann der EXECUTE-Geschichte übergeben und einen Vergleich hier durchgeführt. Das funktioniert, bis auf ein kleines Problem beim Vergleich von Feldern, die NULL-Werte enthalten, aber es ist natürlich noch etwas langsamer als die anderen beiden Trigger.
      Sehr schade, daß man im SQL Server einfach keine Möglichkeit vorgesehen hat, Spaltennamen direkt in Variablen übergeben zu können oder wenigstens den Gültigkeitsbereich der EXEC-Prozedur in den Gültigkeitsbereich der aufrufenden Prozedur gestellt hat. Das schafft die ganze Problematik. Könnte man dort direkt auf inserted und deleted zugreifen, wäre alles kein Problem und sicherlich um einiges performanter.

      Ich poste am Montag mal den Source zum Update-Trigger, habe ich gerade nicht zur Hand.

      Gruß

      Christian

      Comment


      • #4
        ...hier noch der Source zum UPDATE-Trigger.

        Gruß

        Christian

        ----------------------------------------------------------------

        Code:
        -- =============================================
        -- Author:		Christian Coppes
        -- Create date: 17.07.2009
        -- Last change: 17.07.2009
        -- Version:		1.0
        -- Description:	History Trigger für Masterplan
        -- =============================================
        ALTER TRIGGER [dbo].[tblPlanTrigger_Update] 
           ON  [dbo].[tblPlan]
           AFTER UPDATE
        AS 
        BEGIN
        	-- SET NOCOUNT ON added to prevent extra result sets from
        	-- interfering with SELECT statements.
        	SET NOCOUNT ON;
        
        --  @Cmd muß als nvarchar definiert werden
            DECLARE @UserID        AS int,
                    @IDField       AS int,
        			@ColumnName    AS nvarchar(100),
                    @FieldValue    AS nvarchar(4000),
                    @Cmd           AS nvarchar(500),
                    @TableName     AS nvarchar(7),
                    @CurrentDate   AS smalldatetime,
        			@TransactionID AS bigint
        	
        	SET @TableName='tblPlan'
        	SET @CurrentDate=GetDate()
        
        --  Es müssen alle Spalten ausgeklammert werden, die Timestamp-Werte
        --  oder vom Typ ntext/text/image sind
        	DECLARE TableColumnname CURSOR FOR
        		SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
        		WHERE TABLE_NAME = @TableName
        			AND DATA_TYPE<>'text' 
                    AND DATA_TYPE<>'ntext' 
        			AND DATA_TYPE<>'image'
        			AND DATA_TYPE<>'timestamp'
        		ORDER BY COLUMN_NAME
        
        	-- ein zweiter Cursor ist nötig, da die Tabelle "inserted" mehr als einen
        	-- Eintrag enthalten kann, wenn beispielsweise ein UPDATE-Befehl mehrere
        	-- Zeilen gleichzeitig ändert
        	DECLARE TableUpdated CURSOR FOR
        		SELECT IdentityCol, n_lastchange_userid FROM inserted
        
        	OPEN TableUpdated
        	-- "IDField" enthält den Wert des ID-Feldes, um die eingefügte Zeile in der
        	-- Tabelle im EXECUTE-SELECT-Befehl wiederzufinden.
        	-- @UserID stammt aus einer Spalte "n_lastchange_userid", die vom Frontend aus
        	-- die ID des Benutzers enthält, der den Datensatz angelegt hat.
        	-- Hier kann nicht der SQL User verwendet werden, da die Anwendung über eine
        	-- eigene Benutzerverwaltung verfügt.
        	FETCH NEXT FROM TableUpdated INTO @IDField,@UserID
        
        	WHILE @@FETCH_STATUS<>-1
        	BEGIN
        		SELECT * INTO #TempUpdated FROM inserted WHERE IdentityCol=@IDField
        		SELECT * INTO #TempDeleted FROM deleted WHERE IdentityCol=@IDField
        
        		INSERT dbo.tblHistoryGeneral
        					(HG_DateTime ,HG_UserID,HG_TableName,HG_Action,HG_ChangeID,HG_ActionText)
        			VALUES	(@CurrentDate,@UserID  ,@TableName  ,'UPDATE' ,@IDField   ,' ')
        		SET @TransactionID=(SELECT Scope_Identity())
        
        		OPEN TableColumnname
        		FETCH NEXT FROM TableColumnname INTO @ColumnName
        		WHILE @@FETCH_STATUS<>-1
        		BEGIN
        			-- da der Datensatz bereits vor der Triggerausführung eingefügt
        			-- wurde, kann das dynamische SQL auf die echte Tabelle zurückgreifen.
        			-- Im EXECUTE-Gültigkeitsbereich ist die temporäre Tabelle "inserted"
        			-- nicht verfügbar!
        			SET @Cmd=N'DECLARE @Before AS nvarchar(4000),@After AS nvarchar(4000); ' +
        					 N' SELECT @Before=(SELECT ' + CAST(@ColumnName AS nvarchar(100)) + ' FROM #TempDeleted); ' + 
        					 N' SELECT @After =(SELECT ' + CAST(@ColumnName AS nvarchar(100)) + ' FROM #TempUpdated); ' + 
        					 N' IF @Before IS NULL SET @Before=0; ' +
        					 N' IF @After IS NULL SET @After=0; ' +
        					 N' IF @Before <> @After ' +
        					 N' BEGIN ' +
        					 N'   SET @ColValue=CONVERT(nvarchar(4000),' + 
        					 N'   (SELECT ' +             CAST(@ColumnName AS nvarchar(100)) +
        					 N'      FROM #TempDeleted' + 
        					 N'     WHERE IdentityCol=' + CAST(@IDField AS nvarchar(50)) + '))' +
        					 N' END' +
        					 N' ELSE ' +
        					 N'   SET @ColValue=''#NoHistoryField#''  '
        			-- nur mit sp_executesql ist es möglich, Variablen aus der
        			-- EXECUTE-Umgebung wieder zurückzubekommen.
        			EXECUTE sp_executesql @Cmd,
        								N'@ColValue nvarchar(4000) out',
        								@FieldValue out;
        			-- für jedes Feld in der Tabelle wird eine Zeile in die Tabelle
        			-- "HistoryFields" geschrieben mit dem entsprechenden Feldinhalt
        			IF @FieldValue<>'#NoHistoryField#'
        			  BEGIN
        			  INSERT dbo.tblHistoryFields
        						(HF_DateTime ,HF_UserID,HF_TableName,HF_FieldName,HF_FieldValue,HF_Action,HF_ChangeID,HF_TransactionID)
        				VALUES	(@CurrentDate,@UserID  ,@TableName  ,@ColumnName ,@FieldValue  ,'UPDATE' ,@IDField   ,@TransactionID);
        			  SET @FieldValue=NULL
        			  END
        			FETCH NEXT FROM TableColumnname INTO @ColumnName
        		END -- while
        
        	    CLOSE TableColumnname
        		FETCH NEXT FROM TableUpdated INTO @IDField,@UserID
        
        		DROP TABLE #TempUpdated
        		DROP TABLE #TempDeleted
        
        		UPDATE dbo.tblHistoryGeneral
        			SET	HG_ActionText='Updated entry ' + CAST(@IDField AS nvarchar(50))
        			WHERE HG_TransactionID=@TransactionID
        	END -- while
        	DEALLOCATE TableColumnname
        
        	CLOSE TableUpdated
        	DEALLOCATE TableUpdated
        
        END

        Comment

        Working...
        X