1

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

4

7 回答 7

9

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

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

然后,两种方式:

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

编辑:我找不到一篇关于如何执行其中一项的好的 MSDN 文章,但 IBM 有一篇在 SQL Server 中运行良好的文章:http: //publib.boulder.ibm.com/infocenter/iseries/v5r3/index .jsp?topic=/sqlp/rbafybeforesql.htm

于 2009-10-01T16:58:27.457 回答
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.
于 2009-10-07T20:08:12.120 回答
1

您可以通过更改数据模型来限制对 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. 我不太喜欢这个,因为必须维护两个相同的表以及查询含义,但它允许使用GRANT s(基于角色,而不是用户)只允许 SELECT 访问,CONFIRMED_FEES同时允许 INSERT, UPDATE,DELETE 对PRELIMINARY_FEES表。您不能在单个 FEES 表设置中限制授权,因为授权不支持数据 - 您无法检查给定状态。

于 2009-10-05T01:54:05.813 回答
1

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

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

于 2009-10-05T02:26:23.653 回答
1

为什么不只拥有一个布尔值(或单个字符、'y'、'n')的 'Locked' 列,并调整更新查询以使用子查询:

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

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

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

于 2009-10-07T19:10:58.863 回答
1

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

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

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

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

It should be noted that this discussion is not database agnostic: we're discussing SQL Server implementation options. We could use a similar approach with Oracle or any other server that provides procedural support for SQL, but this business rule can't be enforced using static constraints, nor can it be enforced in a database neutral manner.

于 2009-10-09T20:11:07.143 回答
0

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

于 2009-10-05T00:15:37.180 回答