Suponha que temos uma tabela que possui uma restrição de chave estrangeira para si mesma, como:
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 tabela terá os seguintes registros:
FooId ParentFooId
----- -----------
1 3
2 1
3 2
Há casos em que esse tipo de design pode fazer sentido (por exemplo, o típico relacionamento "empregado-e-chefe-empregado") e, em qualquer caso: Estou em uma situação em que tenho isso em meu esquema.
Infelizmente, esse tipo de design permite a circularidade nos registros de dados, conforme mostrado no exemplo acima.
Minha pergunta então é:
- É possível escrever uma restrição que verifique isso? e
- É viável escrever uma restrição que verifique isso? (se necessário apenas até uma certa profundidade)
Para a parte (2) desta questão, pode ser relevante mencionar que espero apenas centenas ou talvez, em alguns casos, milhares de registros em minha tabela, normalmente não aninhados em níveis mais profundos do que 5 a 10.
Eu vi 2 maneiras principais de impor isso:
1, o jeito ANTIGO:
A coluna FooHierarchy conteria um valor como este:
Onde os números são mapeados para a coluna FooId. Você então imporia que a coluna Hierarchy terminasse com "|id" e o restante da string correspondesse a FooHieratchy do PARENT.
2, a NOVA maneira:
O SQL Server 2008 tem um novo tipo de dados chamado HierarchyID , que faz tudo isso para você.
Ele opera da mesma maneira que o modo ANTIGO, mas é tratado com eficiência pelo SQL Server e é adequado para uso como SUBSTITUIÇÃO para sua coluna "ParentID".
É meio que possível: você pode invocar uma UDF escalar de sua restrição CHECK e pode detectar ciclos de qualquer tamanho. Infelizmente, essa abordagem é extremamente lenta e pouco confiável: você pode ter falsos positivos e falsos negativos.
Em vez disso, eu usaria o caminho materializado.
Outra forma de evitar ciclos é ter um CHECK(ID > ParentID), o que provavelmente também não é muito viável.
Outra maneira de evitar ciclos é adicionar mais duas colunas, LevelInHierarchy e ParentLevelInHierarchy, ter (ParentID, ParentLevelInHierarchy) referir-se a (ID, LevelInHierarchy) e ter um CHECK(LevelInHierarchy > ParentLevelInHierarchy).
Você está usando o modelo de Lista de Adjacência , onde é difícil aplicar tal restrição.
Você pode examinar o modelo Nested Set , onde apenas hierarquias verdadeiras podem ser representadas (sem caminhos circulares). Isso tem outras desvantagens, como inserções/atualizações lentas.
Acredito que seja possível:
Posso ter perdido alguma coisa (desculpe, não posso testar completamente), mas parece funcionar.
Aqui está outra opção: um gatilho que permite atualizações de várias linhas e não impõe ciclos. Ele funciona percorrendo a cadeia ancestral até encontrar um elemento raiz (com pai NULL), provando assim que não há ciclo. É limitado a 10 gerações, pois é claro que um ciclo é infinito.
Funciona apenas com o conjunto atual de linhas modificadas, portanto, desde que as atualizações não toquem em um grande número de itens muito profundos na tabela, o desempenho não deve ser tão ruim. Ele tem que percorrer todo o caminho da cadeia para cada elemento, portanto, terá algum impacto no desempenho.
Um gatilho verdadeiramente "inteligente" procuraria ciclos diretamente, verificando se um item alcançou a si mesmo e, em seguida, saltou. No entanto, isso requer a verificação do estado de todos os nós encontrados anteriormente durante cada loop e, portanto, requer um loop WHILE e mais codificação do que eu gostaria de fazer agora. Isso não deve ser realmente mais caro porque a operação normal seria não ter ciclos e, neste caso, será mais rápido trabalhar apenas com a geração anterior em vez de todos os nós anteriores durante cada loop.
Eu adoraria receber informações de @AlexKuznetsov ou de qualquer outra pessoa sobre como isso funcionaria no isolamento de instantâneo. Eu suspeito que não ficaria muito bem, mas gostaria de entender melhor.
Atualizar
Eu descobri como evitar uma junção extra de volta à tabela Inserted. Se alguém vir uma maneira melhor de fazer o GROUP BY para detectar aqueles que não contêm um NULL, por favor me avise.
Também adicionei uma opção para READ COMMITTED se a sessão atual estiver no nível SNAPSHOT ISOLATION. Isso evitará inconsistências, embora, infelizmente, cause maior bloqueio. Isso é inevitável para a tarefa em questão.
Se seus registros estiverem aninhados em mais de 1 nível, uma restrição não funcionará (presumo que você queira dizer, por exemplo, que o registro 1 é o pai do registro 2 e o registro 3 é o pai do registro 1). A única maneira de fazer isso seria no código pai ou com um gatilho, mas se você estiver olhando para uma tabela grande e vários níveis, isso seria bastante intensivo.