具有层次结构的表:创建约束以防止通过外键实现圆度


10

假设我们有一个对其自身具有外键约束的表,如下所示:

CREATE TABLE Foo 
    (FooId BIGINT PRIMARY KEY,
     ParentFooId BIGINT,
     FOREIGN KEY([ParentFooId]) REFERENCES Foo ([FooId]) )

INSERT INTO Foo (FooId, ParentFooId) 
VALUES (1, NULL), (2, 1), (3, 2)

UPDATE Foo SET ParentFooId = 3 WHERE FooId = 1

该表将具有以下记录:

FooId  ParentFooId
-----  -----------
1      3
2      1
3      2

在某些情况下,这种设计可能是有道理的(例如,典型的“雇员与老板-雇员”关系),无论如何:在我的方案中有这种情况。

不幸的是,这种设计允许数据记录中具有圆形性,如上面的示例所示。

我的问题是:

  1. 是否可以编写一个检查该约束的约束?和
  2. 编写检查该约束的方法是否可行?(如果仅需要一定深度)

对于该问题的第(2)部分,可能需要提及的是,我期望表中只有数百个记录记录,或者在某些情况下可能有成千上万条记录,通常不会嵌套超过5到10个级别。

PS。MS SQL Server 2008年


更新2012年3月14日
有几个不错的答案。我现在已经接受了一个可以帮助我理解所提到的可能性/可行性的方法。不过,还有其他几个很好的答案,其中一些还提供了实现建议,因此,如果您遇到相同的问题,请查看所有答案;)

Answers:


6

您正在使用“ 邻接表”模型,在该模型中很难实施这种约束。

您可以检查嵌套集模型,在该模型中只能表示真实的层次结构(没有圆形路径)。但是,这还有其他缺点,例如插入/更新速度慢。


+1个很棒的链接,我希望我能继续尝试嵌套套模型,然后接受此答案作为对我有用的答案。
Jeroen 2012年

我接受这个答案,因为它是帮助我理解可能性可行性的答案,即它为我回答了这个问题。但是,任何遇到此问题的人都应该看看@ a1ex07给出的在简单情况下有效的约束的答案,以及@JohnGietzen给出的巨大链接的答案HIERARCHYID,似乎是嵌套集模型的本机MSSQL2008实现。
耶隆

7

我已经看到了两种主要的执行方法:

1,OLD方式:

CREATE TABLE Foo 
    (FooId BIGINT PRIMARY KEY,
     ParentFooId BIGINT,
     FooHierarchy VARCHAR(256),
     FOREIGN KEY([ParentFooId]) REFERENCES Foo ([FooId]) )

FooHierarchy列将包含这样的值:

"|1|27|425"

数字在哪里映射到FooId列。然后,您将强制Hierarchy列以“ | id”结尾,并且字符串的其余部分与PARENT的FooHieratchy匹配。

2,NEW方式:

SQL Server 2008具有一个称为HierarchyID的新数据类型,可以为您完成所有这些操作。

它与OLD方法使用相同的主体,但是SQL Server可以有效地对其进行处理,并且适合用作“ ParentID”列的REPLACEMENT。

CREATE TABLE Foo 
    (FooId BIGINT PRIMARY KEY,
     FooHierarchy HIERARCHYID )

1
您是否有来源或简短的演示来HIERARCHYID阻止创建层次结构循环?
Nick Chammas 2012年

6

这是可能的:您可以从CHECK约束中调用标量UDF,并且可以检测任何长度的循环。不幸的是,这种方法极其缓慢且不可靠:您可能会有误报和误报。

相反,我将使用物化路径。

避免循环的另一种方法是拥有CHECK(ID> ParentID),这也可能不太可行。

避免循环的另一种方法是再添加两列,即LevelInHierarchy和ParentLevelInHierarchy,让(ParentID,ParentLevelInHierarchy)引用(ID,LevelInHierarchy),并具有CHECK(LevelInHierarchy> ParentLevelInHierarchy)。


CHECK约束中的UDF不起作用。您无法从一次运行在一行上的函数中获得更新后建议状态的表级一致图片。您必须使用AFTER触发器并回滚或INSTEAD OF触发器并拒绝更新。
ErikE 2012年

但是现在我看到了关于多行更新的其他答案的评论。
ErikE 2012年

@ErikE是正确的,CHECK约束中的UDF不起作用。
AK 2012年

@Alex同意。我花了几个小时来证实一次。
ErikE 2012年

4

我相信这是可能的:

create function test_foo (@id bigint) returns bit
as
begin
declare @retval bit;

with t1 as (select @id as FooId, 0 as lvl  
union all 
 select f.FooId , t1.lvl+1 from t1 
 inner join Foo f ON (f.ParentFooId = t1.FooId)
 where lvl<11) -- you said that max nested level 10, so if there is any circular   
-- dependency, we don't need to go deeper than 11 levels to detect it

 select @retval =
 CASE(COUNT(*)) 
 WHEN 0 THEN 0 -- for records that don't have children
 WHEN 1 THEN 0 -- if a record has children
  ELSE 1 -- recursion detected
 END
 from t1
 where t1.FooId = @id ;

return @retval; 
end;
GO
alter table Foo add constraint CHK_REC1 CHECK (dbo.test_foo(ParentFooId) = 0)

我可能错过了一些东西(对不起,我无法全面测试它),但它似乎可以正常工作。


1
我同意“它似乎可以工作”,但是它可能无法进行多行更新,在快照隔离下失败,而且速度很慢。
AK

@AlexKuznetsov:我意识到递归查询相对较慢,并且我同意多行更新可能是个问题(尽管可以禁用它们)。
a1ex07'3

@ a1ex07 Thx的建议。我尝试过,在简单的情况下,它似乎确实可以正常工作。尚不确定多行更新失败是否是一个问题(尽管可能是)。我不确定您所说的“可以被禁用”是什么意思?
Jeroen 2012年

以我的理解,该任务暗含基于游标(或行)的逻辑。因此,禁用修改多于1行的更新是有意义的(如果插入的表多于1行,则简单更新而不是引发错误的更新触发器)。
a1ex07 2012年

如果您不能重新设计表,我将创建一个检查所有约束并添加/更新记录的过程。然后,我将确保除此sp之外没有其他人可以插入/更新此表。
2012年

3

这是另一个选项:允许多行更新并且不强制执行任何周期的触发器。它通过遍历祖先链直到找到根元素(父NULL)来工作,从而证明没有循环。它被限制为10代,因为当然一个循环是无限的。

它仅适用于当前的一组修改后的行,因此,只要更新不涉及表中大量非常深的项目,性能就不会太差。对于每个元素,它确实必须一直沿链向上移动,因此会对性能产生一定的影响。

真正的“智能”触发器将通过检查项目是否到达自身然后进行保释来直接查找周期。但是,这需要在每个循环期间检查所有先前找到的节点的状态,因此需要一个WHILE循环和比我现在想要做的更多的编码。这实际上应该不会花费太多,因为正常的操作将是没有周期,并且在这种情况下,在每个循环中,仅与上一代而不是所有先前的节点一起工作会更快。

我很乐意从@AlexKuznetsov或其他任何人那里获得有关快照隔离如何工作的信息。我怀疑效果不是很好,但想更好地理解它。

CREATE TRIGGER TR_Foo_PreventCycles_IU ON Foo FOR INSERT, UPDATE
AS
SET NOCOUNT ON;
SET XACT_ABORT ON;

IF EXISTS (
   SELECT *
   FROM sys.dm_exec_session
   WHERE session_id = @@SPID
   AND transaction_isolation_level = 5
)
BEGIN;
  SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
END;
DECLARE
   @CycledFooId bigint,
   @Message varchar(8000);

WITH Cycles AS (
   SELECT
      FooId SourceFooId,
      ParentFooId AncestorFooId,
      1 Generation
   FROM Inserted
   UNION ALL
   SELECT
      C.SourceFooId,
      F.ParentFooId,
      C.Generation + 1
   FROM
      Cycles C
      INNER JOIN dbo.Foo F
         ON C.AncestorFooId = F.FooId
   WHERE
      C.Generation <= 10
)
SELECT TOP 1 @CycledFooId = SourceFooId
FROM Cycles C
GROUP BY SourceFooId
HAVING Count(*) = Count(AncestorFooId); -- Doesn't have a NULL AncestorFooId in any row

IF @@RowCount > 0 BEGIN
   SET @Message = CASE WHEN EXISTS (SELECT * FROM Deleted) THEN 'UPDATE' ELSE 'INSERT' END + ' statement violated TRIGGER ''TR_Foo_PreventCycles_IU'' on table "dbo.Foo". A Foo cannot be its own ancestor. Example value is FooId ' + QuoteName(@CycledFooId, '"') + ' with ParentFooId ' + Quotename((SELECT ParentFooId FROM Inserted WHERE FooID = @CycledFooId), '"');
   RAISERROR(@Message, 16, 1);
   ROLLBACK TRAN;   
END;

更新资料

我想出了如何避免多余的联接回到Inserted表的方法。如果有人发现使用GROUP BY来检测不包含NULL的更好的方法,请告诉我。

如果当前会话处于SNAPSHOT ISOLATION级别,我还添加了一个切换到READ COMMITTED的选项。这将防止不一致,尽管不幸的是会导致阻塞增加。这对于手头的任务来说是不可避免的。


您应该使用WITH(READCOMMITTEDLOCK)提示。雨果·科尼利斯(Hugo Kornelis)举了一个例子:sqlblog.com/blogs/hugo_kornelis/archive/2006/09/15/…–
AK

感谢@Alex,这些文章是炸药,使我更好地了解了快照隔离。我添加了一个条件开关以读取未提交的代码。
ErikE 2012年

2

如果您的记录嵌套超过1级,则约束将不起作用(我假设您的意思是,例如,记录1是记录2的父级,而记录3是记录1的父级)。唯一的方法是在父代码中或使用触发器,但是如果您正在查看一个大型表和多个级别,则这将非常耗费精力。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.