困难的时态跨表数据库约束

时间:2009-10-01 16:54:27

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

我有一个特别困难的业务约束,我想在数据库级别强制执行。这些数据本质上属于财务数据,因此必须保护其不存在与第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的解​​决方案,我很想听听那些同样。

7 个答案:

答案 0 :(得分:9)

我想不出用标准化做到这一点的方法。但是,如果我想在数据库上限制它,我会用两种方法中的一种方法:

首先,我会在发票上添加一个“锁定”列,这有点类似,只是将其锁定为锁定的方式。

然后,两种方式:

  1. “插入前”触发器,如果​​所引用的发票被锁定,则会在插入之前抛出错误。
  2. 在创建费用的存储过程中执行此逻辑。
  3. 编辑:我找不到一篇关于如何做其中一篇的MSDN文章,但IBM有一篇在SQL Server中运行良好的文章:http://publib.boulder.ibm.com/infocenter/iseries/v5r3/index.jsp?topic=/sqlp/rbafybeforesql.htm

答案 1 :(得分:4)

不要复杂化,我会选择触发器。使用它们并不羞耻,这就是它们的用途。

为了避免触发器中的大量逻辑,我在头表中添加了一个“可编辑”位列,然后基本上使用带可编辑的除法来工作或导致除零错误,我捕获并转换为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.

答案 2 :(得分:1)

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

发票

  • INVOICE_ID
  • INVOICE_LOCKED_DATE,null

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

一目了然,它是多余的,但只要FEES表的INSERT语句不包含查找INVOICES表的锁定日期(默认为null) - 它确保新记录具有发票的日期被锁了。

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

虽然发票费用仍然可以编辑,但它们位于PRELIMINIARY_FEES表格中,一旦确认,就会移至CONFIRMED_FEES。我不喜欢这个,因为必须维护两个相同的表以及查询含义,但它允许使用GRANT s(在角色上,而不是用户的基础上)仅允许SELECT访问在CONFIRMED_FEES表上允许INSERT,UPDATE,DELETE时PRELIMINARY_FEES。您无法在单个FEES表设置中限制授权,因为授权不能识别数据 - 您无法检查给定状态。

答案 3 :(得分:1)

我认为您最好在发票表中明确存储发票的“锁定/解锁”状态,然后在INSERT和DELETE上应用触发器(和更新,尽管您实际上并不是说您想要费用如果发票处于锁定状态,则在冻结发票上)以防止修改。

锁定标志是必要的,除非有一个可靠的算法方法来确定发票何时被锁定 - 可能是在生产后2小时。当然,您必须更新发票行才能将其锁定 - 因此算法方法更好(更新更少)。

答案 4 :(得分:1)

为什么不只有一个'Locked'列是一个布尔值(或单个字符,'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变体。

答案 5 :(得分:1)

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

鉴于此共识,有两种实现选择可以有效地强制执行数据层中的业务规则:触发器和标准存储过程。要使用标准存储过程,当然可以对发票和费用表进行更新,删除和插入,并要求使用存储过程完成所有数据修改。

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

我可以看到使用存储过程的一些优点。首先,我认为使用存储过程层更直接,因此维护程序员更容易理解。他们,或者你几年后,可能不记得你创建的聪明的触发器,但存储过程层是明确无误的。在一个相关的观点上,我认为存在意外掉落触发器的危险;不太可能有人不小心改变这些表的权限,使它们直接可写。虽然任何一种情况都是可能的,但如果有很多这样的话,我会选择存储过程选项以保证安全。

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

答案 6 :(得分:0)

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