If I have a simple User table in my database and a simple Item table with a User.id as a foreign key thus:
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
name NVARCHAR (MAX) NULL,
email NVARCHAR (128) NULL,
authenticationId NVARCHAR (128) NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id))
CREATE TABLE Items
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
userId UNIQUEIDENTIFIER NOT NULL,
name NVARCHAR (MAX) NULL,
description NVARCHAR (MAX) NULL,
isPublic BIT DEFAULT 0 NOT NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (userId) REFERENCES Users (id))
If a user is removed from the table I need all of the related items to be removed first to avoid breaking referential integrity constraints. This is easily done with CASCADE DELETE
CREATE TABLE Items
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
userId UNIQUEIDENTIFIER NOT NULL,
name NVARCHAR (MAX) NULL,
description NVARCHAR (MAX) NULL,
isPublic BIT DEFAULT 0 NOT NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (userId) REFERENCES Users (id) ON DELETE CASCADE)
But if I also have collections which reference users, and a table collecting items into collections I am in trouble, i.e. the following additional code does not work.
CREATE TABLE Collections
(id UNIQUEIDENTIFIER DEFAULT (NEWID()) NOT NULL,
userId UNIQUEIDENTIFIER NOT NULL,
name NVARCHAR (MAX) NULL,
description NVARCHAR (MAX) NULL,
isPublic BIT DEFAULT 0 NOT NULL,
layoutSettings NVARCHAR (MAX) NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (userId) REFERENCES Users (id) ON DELETE CASCADE)
CREATE TABLE CollectedItems
(itemId UNIQUEIDENTIFIER NOT NULL,
collectionId UNIQUEIDENTIFIER NOT NULL,
createdAt DATETIME DEFAULT GETDATE() NOT NULL,
PRIMARY KEY CLUSTERED (itemId, collectionId),
FOREIGN KEY (itemId) REFERENCES Items (id) ON DELETE CASCADE,
FOREIGN KEY (collectionId) REFERENCES Collections (id) ON DELETE CASCADE)
The error indicates that this "may cause cycles or multiple cascade paths". The way around this I see recommended is to
- Redesign the tables, but I cannot see how; or, and often stated as "a last resort"
- Use triggers.
So I remove the ON DELETE CASCADE
and instead use triggers (documentation) like this:
CREATE TRIGGER DELETE_User
ON Users
INSTEAD OF DELETE
AS
BEGIN
SET NOCOUNT ON
DELETE FROM Items WHERE userId IN (SELECT id FROM DELETED)
DELETE FROM Collections WHERE userId IN (SELECT id FROM DELETED)
DELETE FROM Users WHERE id IN (SELECT id FROM DELETED)
END
CREATE TRIGGER DELETE_Item
ON Items
INSTEAD OF DELETE
AS
BEGIN
SET NOCOUNT ON
DELETE FROM CollectedItems WHERE itemId IN (SELECT id FROM DELETED)
DELETE FROM Items WHERE id IN (SELECT id FROM DELETED)
END
CREATE TRIGGER DELETE_Collection
ON Collections
INSTEAD OF DELETE
AS
BEGIN
SET NOCOUNT ON
DELETE FROM CollectedItems WHERE collectionId IN (SELECT id FROM DELETED)
DELETE FROM Collections WHERE id IN (SELECT id FROM DELETED)
END
However this fails, although subtly. I have a bunch of unit tests (written in xUnit). Individually the tests always pass. But run en masse some randomly fail with a SQL deadlock. In another answer I was pointed to the SQL Profiler which shows a deadlock between two delete calls.
What is the correct way to solve these diamond shaped delete cascades?