【问题标题】:Difficult Temporal Cross-Table Database Constraint困难的时间跨表数据库约束
【发布时间】:2010-12-03 01:45:44
【问题描述】:

我有一个特别困难的业务约束,我想在数据库级别强制执行。数据本质上是财务数据,因此必须保护其免受第 n 级的不一致——不要用这些东西信任业务层。我使用“时间”这个词有点松散,意思是我打算控制一个实体如何随时间变化和不能变化。

修饰细节,这是设计:

  • 一张发票可以包含多项费用。
  • 在发票创建后不久,费用将分配给发票。
  • 发票到达流程中的某个阶段,之后它被“锁定”。
  • 从此时起,不得在此发票中添加或删除任何费用。

这是一个精简的数据定义:

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
)

您会注意到发票的“可锁定”性质并未在此处体现;如何表示它——以及它是否需要直接表示——仍然是一个悬而未决的问题。

我开始相信这是无法转换为域密钥范式的安排之一,尽管我可能错了。 (毕竟真的没有办法说出来。)也就是说,我仍然对高度规范化的解决方案抱有希望。

我碰巧在 SQL Server 2008 上实现了这个(语法可能是一个提示),但我是个好奇的人,所以如果有适用于其他 DBMS 的解决方案,我很想听听这些也是。

【问题讨论】:

  • 您运行的是 SQL Server 2005 还是 2008?
  • 我使用的是 SQL Server 2008,因此我对依赖新功能的解决方案持开放态度。
  • 编辑抛出完整和正确的错误

标签: sql-server constraints crosstab database-agnostic temporal


【解决方案1】:

我想不出一种通过标准化来做到这一点的方法。但是,如果我想将其限制在数据库中,我会采用以下两种方式之一:

首先,我会在 Invoices 中添加一个“锁定”列,这有点像,只是将其键入为锁定的一种方式。

那么,两种方式:

  1. “插入前”触发器,如果​​引用的发票被锁定,则会在插入之前引发错误。
  2. 在创建费用的存储过程中执行此逻辑。

编辑:我找不到一篇很好的 MSDN 文章来说明如何执行这些操作,但 IBM 有一篇在 SQL Server 中运行良好的文章:http://publib.boulder.ibm.com/infocenter/iseries/v5r3/index.jsp?topic=/sqlp/rbafybeforesql.htm

【讨论】:

  • 我已经考虑过这个解决方案,它似乎是最透明的实现。也就是说,它至少需要五个触发器:您建议的一个,发票上的两个以防止解锁和删除。还有两个关于更新和删除的费用。不过,我不认为这是个问题。
  • 该死,我不小心取消了我的投票。显然我撤消它的速度太慢了。对不起。
  • 这是正确的回答。希望我更冗长的回复能为讨论增加一些价值。
【解决方案2】:

您不能只使用 FK 约束等——至少不能以任何有意义的方式使用。我建议在 SQL Server 中使用INSTEAD OF 触发器来强制执行此约束。它应该很容易编写并且非常简单。

【讨论】:

    【解决方案3】:

    您可以通过更改要使用的数据模型来限制对 FEES 表的添加:

    发票

    • INVOICE_ID
    • INVOICE_LOCKED_DATE,空

    费用

    • FEE_ID (pk)
    • INVOICE_ID (pk, fk INVOICES.INVOICE_ID)
    • INVOICE_LOCKED_DATE (pk, fk INVOICES.INVOICE_LOCKED_DATE)
    • 金额

    乍看之下,这是多余的,但只要 FEES 表的 INSERT 语句不包括对锁定日期的 INVOICES 表的查找(默认为空) - 它可以确保新记录具有发票日期被锁定了。

    另一种选择是有两个关于费用处理的表格 - PRELIMINARY_FEESCONFIRMED_FEES

    虽然发票费用仍可编辑,但它们位于 PRELIMINIARY_FEES 表中,一旦确认 - 将移至 CONFIRMED_FEES。我不太喜欢这个,因为必须维护两个相同的表以及查询含义,但它允许使用GRANTs(基于角色,而不是用户)只允许 SELECT 访问@987654333 @ 同时允许在 PRELIMINARY_FEES 表上插入、更新、删除。您不能在单个 FEES 表设置中限制授权,因为授权不支持数据 - 您无法检查给定状态。

    【讨论】:

    • 除非我遗漏了什么,否则仅在外键中添加一个日期并不能阻止使用旧日期向旧发票添加新费用。如果日期被明确设置在不应该的地方,它会在应用程序代码中抛出一个危险信号,尽管仍然可能出现细微的错误。您的单独表的想法确实表明,也许可以使用单独的视图(一个可更新,一个不可更新)以及对源表的适当权限来强制执行约束。
    • @WDWedin:是的,这就是我在该设置中对 INSERTS 的解释。您的用户对数据库的访问权限是多少?
    • 表格优先于视图。如果数据仍然驻留在同一个表中,那么它最终仍然是可编辑的。
    • 关于表与视图和权限:如果触发器更新他们无权更新的表,是否会禁止有权更新视图的用户这样做?我的第一个想法是更新会通过,但我越想越有可能触发器以与用户相同的权限执行。
    • 没有什么是完美的——触发器可以被禁用,就像约束一样。人也是人。
    【解决方案4】:

    我认为您最好将发票的“锁定/解锁”状态显式存储在发票表中,然后在 INSERT 和 DELETE(以及 UPDATE,尽管您实际上并没有说您想要费用)上应用触发器发票冻结)以防止在发票处于锁定状态时进行修改。

    除非有可靠的算法方法来确定发票何时被锁定(可能在发票生成后 2 小时),否则锁定标志是必要的。当然,您必须更新发票行以锁定它 - 所以算法方法更好(更新更少)。

    【讨论】:

    • 我也欣赏算法方法的优雅,但现实阻止了它为我们工作;发票锁定本质上是一个用户启动的过程。你是对的,我没有说费用也需要锁定。在解决这个问题几天后,我似乎很明显,所以我完全忘了提及它。这个想法是发票在发送给客户后不应该改变 - 如果他们发送支票,却发现应付金额已经改变,他们会有什么感觉?无论如何,感谢您的帮助!
    【解决方案5】:

    为什么不只使用一个布尔值(或单个字符、'y'、'n')的“锁定”列,然后调整更新查询以使用子查询:

    INSERT INTO Fees (InvoiceID, Amount) VALUES ((SELECT InvoiceID FROM Invoices WHERE InvoiceID = 3 AND NOT Locked), 13.37);
    

    假设您在 InvoiceID 列上有一个非空约束,当发票被锁定时插入将失败。您可以在代码中处理异常,从而防止在发票锁定时增加费用。您还可以避免编写和维护复杂的触发器和存储过程。

    PS。上面的插入查询使用的是 MySQL 语法,恐怕我对 SQL Server 的 TQL 变种不是很熟悉。

    【讨论】:

    • 我喜欢这种优雅的方法,但它确实将数据完整性的责任牢牢地放在了应用程序上。我可以将逻辑嵌入到 ORM 中,但我担心这可能比基于触发器的方法更容易出错且难以维护。也就是说,如果这个特定查询被证明比 EXISTS 子句更有效,那么它可以在触发器中使用。
    【解决方案6】:

    不要复杂化,我会使用触发器。使用它们并不丢人,这就是它们的用途。

    为了避免触发器中的大量逻辑,我在标题表中添加了一个“可编辑”位列,然后基本上使用可编辑的除法来工作或导致除以零错误,我捕获并转换为Invoice is not editable, no changes permitted 消息。没有用于消除额外开销的 EXISTS。试试这个:

    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
    

    这里有一个简单的测试脚本来测试不同的组合:

    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.
    

    【讨论】:

    • 感谢您将所有内容放在一起;测试数据特别好。我唯一要反对的是这会返回给程序员(或更糟的是,最终用户)的神秘错误。从数据库中得到一个被零除的错误很容易让一个不知情的初级程序员在所有错误的地方嗅探几个小时。它的效率较低,但我可能只会使用明确的 EXISTS 检查和类似 RAISERROR 50003 'Cannot update a complete invoice' 之类的东西。不过,干得不错。
    • @WCWedin,我已更改触发器以在适当的时候发出错误发票不可编辑,不允许更改。正如您所提到的,我认为我的方法比使用 EXISTS 增加的开销更少。您真的希望在触发器中添加尽可能少的开销。另请注意,这些触发器是 INSTEAD OF,因此对表的命中数少于 AFTER 并且也没有 EXISTS 检查。
    • 做得很好。我喜欢你拉到那里的错误fu。有点切线,但是 ERROR_X() 函数的范围是什么?有一个“Rethrow”存储过程会很酷。不幸的是,当它重新引发原始错误时,它确实会丢弃消息 ID,这可能会使应用程序中的错误处理复杂化。
    • ERROR_..() 函数仅在 BEGIN-END CATCH 块中有效。我已经编辑了代码,使触发器在不是“可编辑”错误时抛出完全正确的错误。我添加了一个新的测试用例来测试这些新的完整错误,它显示确实重新抛出了完整的消息:Msg 245, Level 16, State 1, Line 1 Conversion failed when convert the varchar value 'A' to data type bit .
    • 事实证明,您可以将 rethrow 推送到 sproc:msdn.microsoft.com/en-us/library/ms179296.aspx。我会争辩说,如果错误很少见(它们确实应该在部署之前被捕获),那么最好是偶尔影响性能,而不是通过非常复杂的错误处理来永久污染应用程序代码。我想我将对 TRY/CATCH 和 EXISTS 方法进行一些性能测试。毕竟,如果随着行数的增加,直接的方法变得太慢,那么将 ALTER TRIGGER 语句放入新的迁移中是非常简单的。谢谢!
    【解决方案7】:

    我同意应在发票表中添加锁定位以指示是否可以添加费用的普遍共识。然后有必要添加 TSQL 代码来执行与锁定发票相关的业务规则。您的原始帖子似乎没有包含有关发票锁定条件的详细信息,但可以合理地假设可以适当设置锁定位(问题的这方面可能会变得复杂,但让我们在另一个方面解决线程)。

    鉴于这一共识,有 2 种实现选择可以有效地执行数据层中的业务规则:触发器和标准存储过程。要使用标准存储过程,当然会拒绝对 Invoices 和 Fees 表进行更新、删除和插入,并要求使用存储过程完成所有数据修改。

    使用触发器的优点是可以简化应用程序客户端代码,因为可以直接访问表。例如,如果您使用 LINQ to SQL,这可能是一个重要的优势。

    我可以看到使用存储过程的几个优点。一方面,我认为使用存储过程层更直接,因此对维护程序员来说更容易理解。他们,或者几年后的你,可能不记得你创建的那个聪明的触发器,但是存储过程层是明白无误的。在相关的一点上,我认为存在意外掉落触发器的危险。不太可能有人会意外更改这些表的权限以使它们直接可写。虽然这两种情况都是可能的,但如果有很多需要考虑的话,为了安全起见,我会选择存储过程选项。

    应该注意,本次讨论与数据库无关:我们讨论的是 SQL Server 实现选项。我们可以对 Oracle 或任何其他为 SQL 提供过程支持的服务器使用类似的方法,但是这个业务规则不能使用静态约束来强制执行,也不能以数据库中立的方式强制执行。

    【讨论】:

    • 这是关于 DB 不可知标签的一个公平点。当时,我希望正确的转换可以使业务规则的时间维度变平,使其适应静态约束。关于触发器与存储过程,我们在数据层中使用 LINQ,因此触发器是自然的选择。尽管使用 LINQ 以直观的方式使用存储过程也很容易。
    猜你喜欢
    • 2017-12-23
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-04-09
    • 2012-12-03
    相关资源
    最近更新 更多