Search code examples
sql-serverconstraintscrosstabdatabase-agnostictemporal

Difficult Temporal Cross-Table Database Constraint


I have a particularly difficult business constraint that I would like to enforce at the database level. The data is financial in nature, and so must be protected from inconsistencies to the nth degree – no trusting the business layer with this stuff. I use the word "temporal" somewhat loosely, meaning that I intend to control how an entity can and cannot change over time.

Glossing over the details, here's the design:

  • An invoice can feature several fees.
  • Fees are assigned to an invoice shortly after creation of the invoice.
  • The invoice reaches a stage in the process after which it is "locked."
  • From this point on, no fee may be added to or removed from this invoice.

Here's a stripped down data definition:

CREATE TABLE Invoices
(
    InvoiceID INT IDENTITY(1,1) PRIMARY KEY,
)

CREATE TABLE Fees
(
    FeeID INT IDENTITY(1,1) PRIMARY KEY,
    InvoiceID INT REFERENCES Invoices(InvoiceID),
    Amount MONEY
)

You'll notice that the "lockable" nature of the invoice isn't represented here; how to represent it – and whether it needs to be directly represented at all – is still an open question.

I have come to believe that this is one of those arrangements that cannot be translated into domain-key normal form, though I may be wrong. (There really is no way to tell, after all.) That said, I still hold out hope for a highly-normalized solution.

I happen to be implementing this on SQL Server 2008 (the syntax may have been a hint), but I'm a curious guy, so if there are solutions that work on other DBMS's, I'd love to hear about those as well.


Solution

  • Don't complicate it, I'd go with triggers. There is no shame in using them, this is what they are there for.

    To avoid lots of logic in the triggers, I add an "Editable" bit column into the header table, then basically use a divide with Editable to either work or cause a divide by zero error, which I CATCH and convert to a Invoice is not editable, no changes permitted message. There are no EXISTS used to eliminate extra overhead. Try this:

    CREATE TABLE testInvoices
    (
         InvoiceID   INT      not null  IDENTITY(1,1) PRIMARY KEY
        ,Editable    bit      not null  default (1)  --1=can edit, 0=can not edit
        ,yourData    char(2)  not null  default ('xx')
    )
    go
    
    CREATE TABLE TestFees
    (
        FeeID     INT IDENTITY(1,1) PRIMARY KEY
       ,InvoiceID INT REFERENCES testInvoices(InvoiceID)
       ,Amount    MONEY
    )
    go
    
    CREATE TRIGGER trigger_testInvoices_instead_update
    ON testInvoices
    INSTEAD OF UPDATE
    AS
    BEGIN TRY
        --cause failure on updates when the invoice is not editable
        UPDATE t 
            SET Editable =i.Editable
               ,yourData =i.yourData
            FROM testInvoices            t
                INNER JOIN INSERTED      i ON t.InvoiceID=i.InvoiceID
            WHERE 1=CONVERT(int,t.Editable)/t.Editable    --div by zero when not editable
    END TRY
    BEGIN CATCH
    
        IF ERROR_NUMBER()=8134 --catch div by zero error
            RAISERROR('Invoice is not editable, no changes permitted',16,1)
        ELSE
        BEGIN
            DECLARE @ErrorMessage nvarchar(400), @ErrorNumber int, @ErrorSeverity int, @ErrorState int, @ErrorLine int
            SELECT @ErrorMessage = N'Error %d, Line %d, Message: '+ERROR_MESSAGE(),@ErrorNumber = ERROR_NUMBER(),@ErrorSeverity = ERROR_SEVERITY(),@ErrorState = ERROR_STATE(),@ErrorLine = ERROR_LINE()
            RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState, @ErrorNumber,@ErrorLine)
        END
    
    END CATCH
    GO
    
    
    CREATE TRIGGER trigger_testInvoices_instead_delete
    ON testInvoices
    INSTEAD OF DELETE
    AS
    BEGIN TRY
        --cause failure on deletes when the invoice is not editable
        DELETE t
        FROM testInvoices            t
            INNER JOIN DELETED       d ON t.InvoiceID=d.InvoiceID
            WHERE 1=CONVERT(int,t.Editable)/t.Editable    --div by zero when not editable
    END TRY
    BEGIN CATCH
    
        IF ERROR_NUMBER()=8134 --catch div by zero error
            RAISERROR('Invoice is not editable, no changes permitted',16,1)
        ELSE
        BEGIN
            DECLARE @ErrorMessage nvarchar(400), @ErrorNumber int, @ErrorSeverity int, @ErrorState int, @ErrorLine int
            SELECT @ErrorMessage = N'Error %d, Line %d, Message: '+ERROR_MESSAGE(),@ErrorNumber = ERROR_NUMBER(),@ErrorSeverity = ERROR_SEVERITY(),@ErrorState = ERROR_STATE(),@ErrorLine = ERROR_LINE()
            RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState, @ErrorNumber,@ErrorLine)
        END
    
    END CATCH
    GO
    
    CREATE TRIGGER trigger_TestFees_instead_insert
    ON TestFees
    INSTEAD OF INSERT
    AS
    BEGIN TRY
        --cause failure on inserts when the invoice is not editable
        INSERT INTO TestFees
                (InvoiceID,Amount)
            SELECT
                f.InvoiceID,f.Amount/i.Editable  --div by zero when invoice is not editable
                FROM INSERTED                f
                    INNER JOIN testInvoices  i ON f.InvoiceID=i.invoiceID
    END TRY
    BEGIN CATCH
    
        IF ERROR_NUMBER()=8134 --catch div by zero error
            RAISERROR('Invoice is not editable, no changes permitted',16,1)
        ELSE
        BEGIN
            DECLARE @ErrorMessage nvarchar(400), @ErrorNumber int, @ErrorSeverity int, @ErrorState int, @ErrorLine int
            SELECT @ErrorMessage = N'Error %d, Line %d, Message: '+ERROR_MESSAGE(),@ErrorNumber = ERROR_NUMBER(),@ErrorSeverity = ERROR_SEVERITY(),@ErrorState = ERROR_STATE(),@ErrorLine = ERROR_LINE()
            RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState, @ErrorNumber,@ErrorLine)
        END
    
    END CATCH
    GO
    
    CREATE TRIGGER trigger_TestFees_instead_update
    ON TestFees
    INSTEAD OF UPDATE
    AS
    BEGIN TRY
        --cause failure on updates when the invoice is not editable
        UPDATE f 
            SET InvoiceID =ff.InvoiceID
               ,Amount    =ff.Amount/i.Editable --div by zero when invoice is not editable
            FROM TestFees                f
                INNER JOIN INSERTED     ff ON f.FeeID=ff.FeeID
                INNER JOIN testInvoices  i ON f.InvoiceID=i.invoiceID
    END TRY
    BEGIN CATCH
    
        IF ERROR_NUMBER()=8134 --catch div by zero error
            RAISERROR('Invoice is not editable, no changes permitted',16,1)
        ELSE
        BEGIN
            DECLARE @ErrorMessage nvarchar(400), @ErrorNumber int, @ErrorSeverity int, @ErrorState int, @ErrorLine int
            SELECT @ErrorMessage = N'Error %d, Line %d, Message: '+ERROR_MESSAGE(),@ErrorNumber = ERROR_NUMBER(),@ErrorSeverity = ERROR_SEVERITY(),@ErrorState = ERROR_STATE(),@ErrorLine = ERROR_LINE()
            RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState, @ErrorNumber,@ErrorLine)
        END
    
    END CATCH
    GO
    
    CREATE TRIGGER trigger_TestFees_instead_delete
    ON TestFees
    INSTEAD OF DELETE
    AS
    BEGIN TRY
        --cause failure on deletes when the invoice is not editable
        DELETE f
        FROM TestFees                f
            INNER JOIN DELETED      ff ON f.FeeID=ff.FeeID
            INNER JOIN testInvoices  i ON f.InvoiceID=i.invoiceID AND 1=CONVERT(int,i.Editable)/i.Editable --div by zero when invoice is not editable
    END TRY
    BEGIN CATCH
    
        IF ERROR_NUMBER()=8134 --catch div by zero error
            RAISERROR('Invoice is not editable, no changes permitted',16,1)
        ELSE
        BEGIN
            DECLARE @ErrorMessage nvarchar(400), @ErrorNumber int, @ErrorSeverity int, @ErrorState int, @ErrorLine int
            SELECT @ErrorMessage = N'Error %d, Line %d, Message: '+ERROR_MESSAGE(),@ErrorNumber = ERROR_NUMBER(),@ErrorSeverity = ERROR_SEVERITY(),@ErrorState = ERROR_STATE(),@ErrorLine = ERROR_LINE()
            RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState, @ErrorNumber,@ErrorLine)
        END
    
    END CATCH
    GO
    

    here is a simple test script to test out the different combinations:

    INSERT INTO testInvoices VALUES(default,default) --works
    INSERT INTO testInvoices VALUES(default,default) --works
    INSERT INTO testInvoices VALUES(default,default) --works
    
    INSERT INTO TestFees (InvoiceID,Amount) VALUES (1,111)  --works
    INSERT INTO TestFees (InvoiceID,Amount) VALUES (1,1111) --works
    INSERT INTO TestFees (InvoiceID,Amount) VALUES (2,22)   --works
    INSERT INTO TestFees (InvoiceID,Amount) VALUES (2,222)  --works
    INSERT INTO TestFees (InvoiceID,Amount) VALUES (2,2222) --works
    
    update testInvoices set Editable=0 where invoiceid=3 --works
    INSERT INTO TestFees (InvoiceID,Amount) VALUES (3,333) --error<<<<<<<
    
    UPDATE TestFees SET Amount=1 where feeID=1 --works
    UPDATE testInvoices set Editable=0 where invoiceid=1 --works
    UPDATE TestFees SET Amount=11111 where feeID=1 --error<<<<<<<
    UPDATE testInvoices set Editable=1 where invoiceid=1 --error<<<<<<<
    
    UPDATE testInvoices set Editable=0 where invoiceid=2 --works
    DELETE TestFees WHERE invoiceid=2 --error<<<<<
    
    DELETE FROM testInvoices where invoiceid=2 --error<<<<<
    
    UPDATE testInvoices SET Editable='A' where invoiceid=1 --error<<<<<<< Msg 245, Level 16, State 1, Line 1 Conversion failed when converting the varchar value 'A' to data type bit.