Tabelle con gerarchia: creare un vincolo per impedire la circolarità attraverso chiavi esterne


10

Supponiamo di avere una tabella che ha un vincolo di chiave esterna su se stessa, come tale:

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

Questa tabella avrà i seguenti record:

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

Ci sono casi in cui questo tipo di design potrebbe avere senso (ad esempio la tipica relazione "impiegato-e-capo-impiegato"), e in ogni caso: mi trovo in una situazione in cui ho questo nel mio schema.

Questo tipo di design purtroppo consente la circolarità nei set di dati, come mostrato nell'esempio sopra.

La mia domanda quindi è:

  1. È possibile scrivere un vincolo che controlla questo? e
  2. È possibile scrivere un vincolo che controlli questo? (se necessario solo per una certa profondità)

Per la parte (2) di questa domanda può essere rilevante menzionare che mi aspetto solo centinaia o forse in alcuni casi migliaia di record nella mia tabella, normalmente non nidificati più in profondità di circa 5-10 livelli.

PS. MS SQL Server 2008


Aggiornamento 14 marzo 2012
Ci sono state diverse buone risposte. Ora ho accettato quello che mi ha aiutato a capire la possibilità / fattibilità menzionata. Ci sono molte altre grandi risposte, alcune anche con suggerimenti di implementazione, quindi se sei arrivato qui con la stessa domanda dai un'occhiata a tutte le risposte;)

Risposte:


6

Stai utilizzando il modello Elenco adiacenze , in cui è difficile applicare un tale vincolo.

È possibile esaminare il modello di set nidificato , in cui è possibile rappresentare solo le gerarchie vere (senza percorsi circolari). Ciò ha altri inconvenienti, come inserimenti / aggiornamenti lenti.


+1 ottimi collegamenti, e dannatamente vorrei poter andare in giro e provare il modello di set nidificato, quindi accettare questa risposta come quella che ha funzionato per me.
Jeroen,

Accetto questa risposta, perché è stata quella che mi ha aiutato a capire la possibilità e la fattibilità , cioè ha risposto alla domanda per me. Tuttavia, chiunque atterri a questa domanda dovrebbe dare un'occhiata alla risposta di @ a1ex07 per un vincolo che funziona in casi semplici, e alla risposta di @ JohnGietzen per i grandi collegamenti a HIERARCHYIDcui sembra essere un'implementazione MSSQL2008 nativa del modello di set nidificato.
Jeroen,

7

Ho visto 2 modi principali per far rispettare questo:

1, alla VECCHIA via:

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

La colonna FooHierarchy conterrebbe un valore come questo:

"|1|27|425"

Dove i numeri sono associati alla colonna FooId. Dovresti quindi applicare che la colonna Gerarchia termina con "| id" e il resto della stringa corrisponde a FooHieratchy del GENITORE.

2, il NUOVO modo:

SQL Server 2008 ha un nuovo tipo di dati chiamato HierarchyID , che fa tutto questo per te.

Funziona sullo stesso principio del modo OLD, ma è gestito in modo efficiente da SQL Server ed è adatto per l'uso come SOSTITUZIONE per la colonna "ParentID".

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

1
Hai una demo o una breve dimostrazione dimostrativa che HIERARCHYIDimpedisce la creazione di loop gerarchici?
Nick Chammas,

6

È un po 'possibile: puoi invocare un UDF scalare dal tuo vincolo CHECK e può in qualche modo rilevare cicli di qualsiasi lunghezza. Sfortunatamente, questo approccio è estremamente lento e inaffidabile: puoi avere falsi positivi e falsi negativi.

Invece, userei il percorso materializzato.

Un altro modo per evitare cicli è quello di avere un CHECK (ID> ParentID), che probabilmente non è neanche molto fattibile.

Un altro modo per evitare cicli è quello di aggiungere altre due colonne, LevelInHierarchy e ParentLevelInHierarchy, con (ParentID, ParentLevelInHierarchy) fare riferimento a (ID, LevelInHierarchy) e avere un CHECK (LevelInHierarchy> ParentLevelInHierarchy).


Gli UDF nei vincoli CHECK NON funzionano. Non è possibile ottenere un'immagine coerente a livello di tabella dello stato proposto post-aggiornamento da una funzione che viene eseguita su una riga alla volta. È necessario utilizzare un trigger AFTER e rollback o un trigger INSTEAD OF e rifiutare di aggiornare.
ErikE

Ma ora vedo i commenti sull'altra risposta sugli aggiornamenti multi-riga.
ErikE

@ErikE, esatto, le UDF nei vincoli CHECK NON funzionano.
AK,

@Alex concordato. Ho impiegato alcune ore per dimostrarlo solidamente una volta.
ErikE

4

Credo sia possibile:

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)

Potrei aver perso qualcosa (mi dispiace, non sono in grado di testarlo fino in fondo), ma sembra funzionare.


1
Concordo sul fatto che "sembra funzionare", ma potrebbe non riuscire per gli aggiornamenti multi-riga, fallire con l'isolamento dello snapshot ed è molto lento.
AK

@AlexKuznetsov: mi rendo conto che le query ricorsive sono relativamente lente e concordo sul fatto che gli aggiornamenti multi-riga possono essere un problema (possono essere disabilitati però).
a1ex07

@ a1ex07 Thx per questo suggerimento. L'ho provato, e in casi semplici sembra funzionare davvero bene. Non sono ancora sicuro se l'errore sugli aggiornamenti multi-riga sia un problema (anche se probabilmente lo è). Non sono sicuro di cosa intendi per "possono essere disabilitati"?
Jeroen,

Nella mia comprensione, l'attività implica una logica basata su cursore (o riga). Quindi ha senso disabilitare gli aggiornamenti che modificano più di 1 riga (trigger semplice anziché di aggiornamento che genera un errore se la tabella inserita ha più di 1 riga).
a1ex07

Se non riesci a ridisegnare la tabella, creerei una procedura che controlla tutti i vincoli e aggiunge / aggiorna il record. Quindi mi assicurerò che nessuno, tranne questo sp, possa inserire / aggiornare questa tabella.
a1ex07,

3

Ecco un'altra opzione: un trigger che consente aggiornamenti multi-riga e non applica cicli. Funziona attraversando la catena degli antenati fino a quando non trova un elemento radice (con il padre NULL), dimostrando così che non vi è alcun ciclo. È limitato a 10 generazioni poiché ovviamente un ciclo è infinito.

Funziona solo con l'attuale set di righe modificate, quindi fino a quando gli aggiornamenti non toccano un numero enorme di elementi molto profondi nella tabella, le prestazioni non dovrebbero essere troppo cattive. Deve andare fino in fondo per ogni elemento, quindi avrà un impatto sulle prestazioni.

Un trigger veramente "intelligente" cercherebbe i cicli direttamente controllando se un oggetto ha raggiunto se stesso e quindi eseguirà il salvataggio. Tuttavia, ciò richiede il controllo dello stato di tutti i nodi trovati in precedenza durante ciascun ciclo e quindi richiede un ciclo WHILE e più codice di quanto volessi fare in questo momento. Questo non dovrebbe essere davvero più costoso perché il normale funzionamento sarebbe quello di non avere cicli e in questo caso sarà più veloce lavorare solo con la generazione precedente piuttosto che con tutti i nodi precedenti durante ciascun ciclo.

Mi piacerebbe un contributo di @AlexKuznetsov o di chiunque altro su come questo andrebbe isolato in un'istantanea. Sospetto che non andrebbe molto bene, ma vorrei capirlo meglio.

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;

Aggiornare

Ho capito come evitare un join aggiuntivo alla tabella inserita. Se qualcuno vede un modo migliore per fare GROUP BY per rilevare quelli che non contengono un NULL, per favore fatemelo sapere.

Ho anche aggiunto un passaggio a READ COMMITTED se la sessione corrente è nel livello ISOLATION SNAPSHOT. Ciò eviterà incoerenze, sebbene sfortunatamente causerà un maggiore blocco. Questo è inevitabile per il compito da svolgere.


Dovresti usare il suggerimento WITH (READCOMMITTEDLOCK). Hugo Kornelis ha scritto un esempio: sqlblog.com/blogs/hugo_kornelis/archive/2006/09/15/…
AK

Grazie @Alex quegli articoli erano dinamite e mi hanno aiutato a capire molto meglio l'isolamento dell'istantanea. Ho aggiunto un interruttore condizionale per leggere senza impegno il mio codice.
ErikE

2

Se i tuoi record sono nidificati a più di 1 livello, un vincolo non funzionerà (suppongo che tu intenda, ad esempio, che il record 1 sia il genitore del record 2 e il record 3 sia il genitore del record 1). L'unico modo per farlo sarebbe nel codice genitore o con un trigger, ma se stai guardando una tabella di grandi dimensioni e più livelli questo sarebbe piuttosto intenso.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.