Tablas con jerarquía: cree una restricción para evitar la circularidad a través de claves externas


10

Supongamos que tenemos una tabla que tiene una restricción de clave externa para sí misma, como tal:

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

Esta tabla tendrá los siguientes registros:

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

Hay casos en los que este tipo de diseño podría tener sentido (por ejemplo, la típica relación "empleado-y-jefe-empleado"), y en cualquier caso: estoy en una situación en la que tengo esto en mi esquema.

Desafortunadamente, este tipo de diseño permite circularidad en los registros de datos, como se muestra en el ejemplo anterior.

Mi pregunta entonces es:

  1. ¿Es posible escribir una restricción que verifique esto? y
  2. ¿Es factible escribir una restricción que verifique esto? (si es necesario solo hasta una cierta profundidad)

Para la parte (2) de esta pregunta, puede ser relevante mencionar que espero solo cientos o quizás en algunos casos miles de registros en mi tabla, normalmente no anidados a más de 5 a 10 niveles.

PD. MS SQL Server 2008


Actualización 14 de marzo de 2012
Hubo varias buenas respuestas. Ahora he aceptado el que me ayudó a comprender la posibilidad / factibilidad mencionada. Sin embargo, hay varias otras respuestas excelentes, algunas con sugerencias de implementación, por lo que si aterrizó aquí con la misma pregunta, eche un vistazo a todas las respuestas;)

Respuestas:


6

Está utilizando el modelo de Lista de adyacencia , donde es difícil hacer cumplir dicha restricción.

Puede examinar el modelo de conjunto anidado , donde solo se pueden representar jerarquías verdaderas (sin rutas circulares). Sin embargo, esto tiene otros inconvenientes, como inserciones / actualizaciones lentas.


Haz +1 enlaces geniales, y me gustaría poder probar el modelo de conjunto anidado y luego aceptar esta respuesta como la que más me funcionó.
Jeroen

Estoy aceptando esta respuesta, porque fue la que me ayudó a comprender la posibilidad y la viabilidad , es decir, me respondió la pregunta. Sin embargo, cualquiera que aterrice en esta pregunta debería echar un vistazo a la respuesta de @ a1ex07 para una restricción que funciona en casos simples, y la respuesta de @ JohnGietzen para los excelentes enlaces a los HIERARCHYIDque parece ser una implementación MSSQL2008 nativa del modelo de conjunto anidado.
Jeroen

7

He visto 2 formas principales de hacer cumplir esto:

1, la antigua manera:

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

La columna FooHierarchy contendría un valor como este:

"|1|27|425"

Donde los números se asignan a la columna FooId. A continuación, debería exigir que la columna Jerarquía termine con "| id" y que el resto de la cadena coincida con la FooHieratchy del PADRE.

2, la NUEVA forma:

SQL Server 2008 tiene un nuevo tipo de datos llamado HierarchyID , que hace todo esto por usted.

Opera en el mismo principio que en el modo ANTIGUO, pero es manejado eficientemente por SQL Server, y es adecuado para usarse como REEMPLAZO para su columna "ParentID".

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

1
¿Tiene una fuente o una demostración breve que demuestre que HIERARCHYIDimpide la creación de bucles de jerarquía?
Nick Chammas

6

Es posible: puede invocar un UDF escalar desde su restricción CHECK, y puede detectar ciclos de cualquier longitud. Desafortunadamente, este enfoque es extremadamente lento y poco confiable: puede tener falsos positivos y falsos negativos.

En cambio, usaría el camino materializado.

Otra forma de evitar ciclos es tener un CHECK (ID> ParentID), que probablemente tampoco sea muy factible.

Otra forma de evitar ciclos es agregar dos columnas más, LevelInHierarchy y ParentLevelInHierarchy, hacer que (ParentID, ParentLevelInHierarchy) haga referencia a (ID, LevelInHierarchy) y tener un CHECK (LevelInHierarchy> ParentLevelInHierarchy).


Las UDF en restricciones CHECK NO funcionan. No puede obtener una imagen coherente a nivel de tabla del estado propuesto posterior a la actualización de una función que se ejecuta en una fila a la vez. Debe usar un disparador DESPUÉS y retroceder o un disparador EN LUGAR DE y rechazar la actualización.
ErikE

Pero ahora veo los comentarios en la otra respuesta sobre actualizaciones de varias filas.
ErikE

@ErikE es cierto, las UDF en las restricciones CHECK NO funcionan.
AK

@Alex acordó. Me tomé unas horas para probar esto una vez.
ErikE

4

Creo que es posible:

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)

Podría haber pasado algo por alto (lo siento, no puedo probarlo a fondo), pero parece funcionar.


1
Estoy de acuerdo en que "parece funcionar", pero puede fallar en las actualizaciones de varias filas, fallar en el aislamiento de instantáneas y es muy lento.
AK

@AlexKuznetsov: Me doy cuenta de que la consulta recursiva es relativamente lenta, y estoy de acuerdo en que las actualizaciones de varias filas pueden ser un problema (aunque pueden deshabilitarse).
a1ex07

@ a1ex07 Thx por esta sugerencia. Lo intenté, y en casos simples parece funcionar bien. Todavía no estoy seguro si el error en las actualizaciones de varias filas es un problema (aunque probablemente lo sea). Sin embargo, no estoy seguro de lo que quieres decir con "se pueden desactivar".
Jeroen

Según tengo entendido, la tarea implica una lógica basada en el cursor (o fila). Por lo tanto, tiene sentido deshabilitar las actualizaciones que modifican más de 1 fila (simple en lugar del activador de actualización que genera un error si la tabla insertada tiene más de 1 fila).
a1ex07

Si no puede rediseñar la tabla, crearía un procedimiento que verifique todas las restricciones y agregue / actualice el registro. Entonces me aseguraré de que nadie, excepto este sp, pueda insertar / actualizar esta tabla.
a1ex07

3

Aquí hay otra opción: un disparador que permite actualizaciones de varias filas y no impone ciclos. Funciona atravesando la cadena ancestral hasta que encuentra un elemento raíz (con NULL padre), lo que demuestra que no hay ciclo. Está limitado a 10 generaciones ya que, por supuesto, un ciclo es interminable.

Solo funciona con el conjunto actual de filas modificadas, por lo que siempre que las actualizaciones no toquen una gran cantidad de elementos muy profundos en la tabla, el rendimiento no debería ser tan malo. Tiene que subir toda la cadena por cada elemento, por lo que tendrá un impacto en el rendimiento.

Un disparador verdaderamente "inteligente" buscaría ciclos directamente verificando si un artículo se alcanzaba a sí mismo y luego rescatando. Sin embargo, esto requiere verificar el estado de todos los nodos encontrados previamente durante cada ciclo y, por lo tanto, toma un ciclo WHILE y más codificación de la que quería hacer en este momento. Esto no debería ser realmente más costoso porque la operación normal sería no tener ciclos y en este caso será más rápido trabajar solo con la generación anterior en lugar de todos los nodos anteriores durante cada ciclo.

Me encantaría la opinión de @AlexKuznetsov o de cualquier otra persona sobre cómo le iría en el aislamiento de instantáneas. Sospecho que no sería muy bueno, pero me gustaría entenderlo mejor.

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;

Actualizar

Descubrí cómo evitar una unión adicional a la tabla Inserted. Si alguien ve una mejor manera de hacer GROUP BY para detectar aquellos que no contienen NULL, hágamelo saber.

También agregué un interruptor para LEER COMPROMETIDO si la sesión actual está en el nivel de AISLAMIENTO INSTANTÁNEO. Esto evitará inconsistencias, aunque desafortunadamente causará un mayor bloqueo. Eso es inevitable para la tarea en cuestión.


Debe usar la sugerencia WITH (READCOMMITTEDLOCK). Hugo Kornelis escribió un ejemplo: sqlblog.com/blogs/hugo_kornelis/archive/2006/09/15/…
AK

Gracias @Alex esos artículos fueron dinamita y me ayudaron a comprender el aislamiento de instantáneas mucho mejor. He agregado un modificador condicional para leer sin comprometer mi código.
ErikE

2

Si sus registros están anidados en más de 1 nivel, una restricción no funcionará (supongo que quiere decir, por ejemplo, el registro 1 es el padre del registro 2 y el registro 3 es el padre del registro 1). La única forma de hacerlo sería en el código principal o con un activador, pero si está mirando una tabla grande y varios niveles, esto sería bastante intenso.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.