Search code examples
sql-servertemporal

Workaround to achieve the benefits combining INSTEAD OF triggers with SQL Server Temporal tables


I would like to achieve the benefits combining INSTEAD OF INSERT and INSTEAD OF UPDATE triggers with Temporal tables.

The INSTEAD OF triggers enforce uniqueness across multiple columns one column of which is in a different but related table. They throw an exception if the user attempts to insert or update a record that breaks the uniqueness invariant.

Temporal tracking documents what, when, and (potentially) who.

Hence, both INSTEAD OF triggers and Temporal tracking provide benefits. And I'm curious if there might be a workaround to achieve the benefits of both.

The Temporal Table Considerations and Limitations docs page indicates that

INSTEAD OF triggers are not permitted on either the current or the history table to avoid invalidating the DML logic.

But perhaps there is a clever workaround.

In the following code sample I would like to add Temporal tracking to the [HubAssembly].[AftermarketParts] table. But its INSTEAD OF triggers prevent turning it into a Temporal table.

CREATE SCHEMA [Part] AUTHORIZATION [dbo];
GO

CREATE SCHEMA [HubAssembly] AUTHORIZATION [dbo];
GO

CREATE TABLE [Part].[Types] (
    [Id]   INT IDENTITY (1, 1) NOT NULL,
    [Name] NVARCHAR (50)       NOT NULL,
    CONSTRAINT [PK_Part.Types] PRIMARY KEY CLUSTERED ([Id] ASC),
    CONSTRAINT [Part.Types.Name_Unique] UNIQUE NONCLUSTERED ([Name] ASC)
);
GO

CREATE TABLE [Part].[Parts] (
    [PartNumber]             NVARCHAR (18)  NOT NULL,
    [PartTypeId]             INT            NOT NULL,
    CONSTRAINT [PK_Part.Parts] PRIMARY KEY CLUSTERED ([PartNumber] ASC),
    CONSTRAINT [FK_Part.Parts_Part.Types_PartTypeId] FOREIGN KEY ([PartTypeId]) REFERENCES [Part].[Types] ([Id])
);
GO

CREATE TABLE [HubAssembly].[AftermarketPartUsages] (
    [Id]   INT IDENTITY (0, 1) NOT NULL,
    [Name] NVARCHAR (50)       NOT NULL,
    CONSTRAINT [PK_HubAssembly.AftermarketPartUsages] PRIMARY KEY CLUSTERED ([Id] ASC),
    CONSTRAINT [IX_HubAssembly_AftermarketPartUsages_Name_Unique] UNIQUE NONCLUSTERED ([Name] ASC),
);
GO

CREATE FUNCTION [Part].[f_PartTypeId] (@PartNumber AS NVARCHAR(18))
RETURNS INT
AS  
BEGIN
  DECLARE @Return AS INT
  SET @Return = (SELECT PartTypeId FROM Part.Parts WHERE PartNumber = @PartNumber)
  RETURN @Return
END
GO

CREATE TABLE [HubAssembly].[AftermarketParts] (
    [Id]                    INT          IDENTITY (0, 1) NOT NULL,
    [HubAssemblyNumber]     NVARCHAR(18) NOT NULL,
    [AftermarketPartNumber] NVARCHAR(18) NOT NULL,
    [Ranking]               INT          CONSTRAINT [DF_HubAssembly_AftermarketParts_Ranking] DEFAULT ((1)) NOT NULL,
    [UsageId]               INT          DEFAULT ((0)) NOT NULL,
    CONSTRAINT [PK_HubAssembly.AftermarketParts] PRIMARY KEY NONCLUSTERED ([Id] ASC),
    CONSTRAINT [FK_HubAssembly.AftermarketParts_HubAssembly.AftermarketPartUsages_UsageId] FOREIGN KEY ([UsageId]) REFERENCES [HubAssembly].[AftermarketPartUsages] ([Id]),

    -- FK_HubAssembly.AftermarketParts_Part.Parts_HubAssemblyNumber is simplified here. It actually is a foreign key to HubAssembly.HubAssemblies.HubAssemblyNumber which is a FK to Part.Parts.PartNumber
    CONSTRAINT [FK_HubAssembly.AftermarketParts_Part.Parts_HubAssemblyNumber] FOREIGN KEY ([HubAssemblyNumber]) REFERENCES [Part].[Parts] ([PartNumber]),

    CONSTRAINT [FK_HubAssembly.AftermarketParts_Part.Parts_AftermarketPartNumber] FOREIGN KEY ([AftermarketPartNumber]) REFERENCES [Part].[Parts] ([PartNumber]),
);
GO

CREATE TRIGGER [HubAssembly].[Trigger_AftermarketParts_InsteadOf_Update] ON [HubAssembly].[AftermarketParts]
    INSTEAD OF UPDATE
    AS
    BEGIN
        SET NOCOUNT ON;
        DECLARE @AnyDuplicates AS BIT = 0;
        WITH cte AS (
            SELECT HubAssemblyNumber, Ranking, Part.f_PartTypeId(AftermarketPartNumber) AS PartTypeId, AftermarketPartNumber, UsageId
            FROM INSERTED
            UNION 
            SELECT HubAssemblyNumber, Ranking, Part.f_PartTypeId(AftermarketPartNumber), AftermarketPartNumber, UsageId
            FROM [HubAssembly].[AftermarketParts]
            WHERE HubAssemblyNumber IN (SELECT HubAssemblyNumber FROM INSERTED)
              AND Id NOT IN (SELECT Id FROM DELETED)
        )
        SELECT TOP 1 @AnyDuplicates = 1
        FROM cte
        GROUP BY HubAssemblyNumber, PartTypeId, Ranking, UsageId
        HAVING (COUNT(AftermarketPartNumber) > 1)
        IF @AnyDuplicates = 1
            THROW 50001, N'Performing this update would result in duplicate values for ([HubAssemblyNumber], [Ranking], [Part].[f_PartTypeId](AftermarketPartNumber), [UsageId]). This tuple should have distinct values for all rows in the table.', 1;
        ELSE
            BEGIN
                DELETE FROM [HubAssembly].[AftermarketParts] WHERE Id IN (SELECT Id FROM DELETED);
                INSERT INTO [HubAssembly].[AftermarketParts] ([HubAssemblyNumber], [AftermarketPartNumber], [Ranking], [UsageId])
                    SELECT [HubAssemblyNumber], [AftermarketPartNumber], [Ranking], [UsageId]
                    FROM INSERTED;
            END;
    END
GO

CREATE TRIGGER [HubAssembly].[Trigger_AftermarketParts_InsteadOf_Insert] ON [HubAssembly].[AftermarketParts]
    INSTEAD OF INSERT
    AS
    BEGIN
        SET NOCOUNT ON;
        DECLARE @AnyDuplicates AS BIT = 0;
        WITH cte AS (
            SELECT HubAssemblyNumber, Ranking, Part.f_PartTypeId(AftermarketPartNumber) AS PartTypeId, AftermarketPartNumber, UsageId
            FROM INSERTED
            UNION 
            SELECT HubAssemblyNumber, Ranking, Part.f_PartTypeId(AftermarketPartNumber), AftermarketPartNumber, UsageId
            FROM [HubAssembly].[AftermarketParts]
            WHERE HubAssemblyNumber IN (SELECT HubAssemblyNumber FROM INSERTED)
        )
        SELECT TOP 1 @AnyDuplicates = 1
        FROM cte
        GROUP BY HubAssemblyNumber, PartTypeId, Ranking, UsageId
        HAVING (COUNT(AftermarketPartNumber) > 1)
        IF @AnyDuplicates = 1
            THROW 50001, N'Performing this insert would result in duplicate values for ([HubAssemblyNumber], [Ranking], [Part].[f_PartTypeId](AftermarketPartNumber), [UsageId]). This tuple should have distinct values for all rows in the table.', 1;
        ELSE
            INSERT INTO [HubAssembly].[AftermarketParts] ([HubAssemblyNumber], [AftermarketPartNumber], [Ranking], [UsageId])
                SELECT [HubAssemblyNumber], [AftermarketPartNumber], [Ranking], [UsageId]
                FROM INSERTED;
    END
GO

CREATE UNIQUE CLUSTERED INDEX [HubAssemblyNumber_AftermarketPartNumber_UsageId_Unique]
    ON [HubAssembly].[AftermarketParts]([HubAssemblyNumber] ASC, [AftermarketPartNumber] ASC, [UsageId] ASC);
GO

Here's some data to populate these tables:

SET IDENTITY_INSERT Part.Types ON;
GO
INSERT INTO Part.Types (Id, Name) VALUES (5, N'Wheel Stud');
INSERT INTO Part.Types (Id, Name) VALUES (7, N'ABS Tone Ring');
INSERT INTO Part.Types (Id, Name) VALUES (8, N'Port Plug');
INSERT INTO Part.Types (Id, Name) VALUES (14, N'Seal');
INSERT INTO Part.Types (Id, Name) VALUES (217, N'PreSet Complete Hub Rebuild Kit (Keyway)');
INSERT INTO Part.Types (Id, Name) VALUES (221, N'Wheel Seal and Spacer Kit');
INSERT INTO Part.Types (Id, Name) VALUES (115, N'Hub Assembly');
INSERT INTO Part.Types (Id, Name) VALUES (6, N'Double-Ended Stud');
INSERT INTO Part.Types (Id, Name) VALUES (101, N'Bearing Cup & Cone Assembly');
GO
SET IDENTITY_INSERT Part.Types OFF;
GO
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'103705', 7);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'105272', 8);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10001405', 5);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10009780', 7);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10018436', 5);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10045871', 217);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10080124', 14);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10081518', 221);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10082204', 115);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10083796', 115);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10018442', 6);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10041905', 101);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10041906', 101);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10041915', 101);
INSERT INTO Part.Parts (PartNumber, PartTypeId) VALUES (N'10041916', 101);
GO

SET IDENTITY_INSERT HubAssembly.AftermarketPartUsages ON;
INSERT INTO HubAssembly.AftermarketPartUsages(Id, Name) VALUES (0, N'—');
INSERT INTO HubAssembly.AftermarketPartUsages(Id, Name) VALUES (1, N'Inboard Bearing Set');
INSERT INTO HubAssembly.AftermarketPartUsages(Id, Name) VALUES (2, N'Outboard Bearing Set');
INSERT INTO HubAssembly.AftermarketPartUsages(Id, Name) VALUES (3, N'Axle Stud');
GO
SET IDENTITY_INSERT HubAssembly.AftermarketPartUsages OFF;
GO

INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10082204', N'10001405', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10082204', N'10009780', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10082204', N'10041905', 1, 1);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10082204', N'10041906', 1, 2);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10082204', N'10045871', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10082204', N'10081518', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'10018436', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'10018442', 1, 3);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'10041915', 1, 1);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'10041916', 1, 2);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'10080124', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'103705', 1, 0);
INSERT INTO HubAssembly.AftermarketParts (HubAssemblyNumber, AftermarketPartNumber, Ranking, UsageId) VALUES (N'10083796', N'105272', 1, 0);
GO

The INSTEAD OF triggers enforce uniqueness spanning four columns (HubAssemblyNumber, Ranking, AftermarketPartNumber, UsageId) in the [HubAssembly].[AftermarketParts] table and one column in the related [Part].[Parts] table via the Part.f_PartTypeId() function.

Having the two-table/five-column uniqueness validation enforced here in the database upon inserts and updates is ideal. Unfortunately the triggers that do the enforcing prohibit transforming the table into a temporal table. But since automatic change tracking is also quite useful, I would like to add temporal tracking to the table as well.

Is there another way that these dual benefits can be achieved?


Solution

  • The answer is quite simple. Merely replacing the INSTEAD OF triggers with AFTER triggers did the trick.