Поделиться через


Каскадное удаление

Entity Framework Core (EF Core) представляет связи с помощью внешних ключей. Сущность с внешним ключом является дочерней или зависимой сущностью в отношениях. Значение внешнего ключа этой сущности должно соответствовать значению первичного ключа (или альтернативному значению ключа) связанной сущности или родительской сущности.

Если основная или родительская сущность удаляется, то значения внешнего ключа зависимых или дочерних объектов больше не будут соответствовать первичному или альтернативному ключу любой главной или родительской сущности. Это недопустимое состояние и приведет к нарушению ссылочного ограничения в большинстве баз данных.

Существует два варианта, чтобы избежать этого нарушения ограничений ссылки:

  1. Задайте для значений FK значение NULL
  2. Кроме того, удалите зависимые или дочерние сущности

Первый вариант действителен только для необязательных связей, где свойство внешнего ключа (и столбец базы данных, с которым сопоставляется) должно иметь значение NULL.

Второй вариант действителен для любой связи и называется каскадным удалением.

Подсказка

В этом документе описывается каскадное удаление (и удаление осиротевших записей) с точки зрения обновления базы данных. Широко использует концепции, представленные в Отслеживании изменений в EF Core и Изменении внешних ключей и навигаций. Прежде чем приступать к изучению материала, необходимо полностью понять эти понятия.

Подсказка

Вы можете запускать и отлаживать весь код в этом документе, скачав пример кода из GitHub.

Когда происходит каскадное поведение

Каскадные удаления необходимы, если зависимые или дочерние сущности больше не могут быть связаны с текущей главной или родительской сущностью. Это может произойти, так как субъект или родитель удаляется или может произойти, когда субъект или родитель по-прежнему существует, но зависимый или дочерний объект больше не связан с ним.

Удаление основного субъекта или родителя

Рассмотрим эту простую модель, где Blog является субъектом или родителем в связи с Post, от которого зависит или является дочерним объектом. Post.BlogId — это свойство внешнего ключа, значение которого должно соответствовать Blog.Id первичному ключу блога, к которому принадлежит запись.

public class Blog
{
    public int Id { get; set; }

    public string Name { get; set; }

    public IList<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }

    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public Blog Blog { get; set; }
}

По соглашению эта связь настраивается как обязательная, так как Post.BlogId свойство внешнего ключа не допускает значения NULL. Необходимые связи настроены для использования каскадных удалений по умолчанию. Дополнительные сведения о связях моделирования см. в разделе "Связи ".

При удалении блога все записи удаляются каскадно. Рассмотрим пример.

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync();

context.Remove(blog);

await context.SaveChangesAsync();

SaveChanges создает следующий SQL, используя SQL Server в качестве примера:

-- Executed DbCommand (1ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Posts]
WHERE [Id] = @p0;
SELECT @@ROWCOUNT;

-- Executed DbCommand (0ms) [Parameters=[@p0='2'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Posts]
WHERE [Id] = @p0;
SELECT @@ROWCOUNT;

-- Executed DbCommand (2ms) [Parameters=[@p1='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Blogs]
WHERE [Id] = @p1;
SELECT @@ROWCOUNT;

Разрыв связи

Вместо удаления блога мы могли бы вместо этого удалить связь между каждой записью и его блогом. Это можно сделать, установив ссылку навигации Post.Blog в null для каждого поста.

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync();

foreach (var post in blog.Posts)
{
    post.Blog = null;
}

await context.SaveChangesAsync();

Связь также может быть разорвана, удалив каждый Blog.Posts пост из навигации коллекции:

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync();

blog.Posts.Clear();

await context.SaveChangesAsync();

В любом случае результат тот же: блог не удаляется, но записи, которые больше не связаны с любым блогом, удаляются:

-- Executed DbCommand (1ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Posts]
WHERE [Id] = @p0;
SELECT @@ROWCOUNT;

-- Executed DbCommand (0ms) [Parameters=[@p0='2'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Posts]
WHERE [Id] = @p0;
SELECT @@ROWCOUNT;

Удаление сущностей, которые больше не связаны с каким-либо главным объектом или зависимым объектом, называется удалением сирот.

Подсказка

Каскадное удаление и удаление осиротелых объектов тесно связаны. Оба результата приводят к удалению зависимых или дочерних сущностей, когда связь с обязательным субъектом или родительским объектом будет удалена. Для каскадного удаления этот разрыв происходит, так как субъект или родитель сам удаляется. Для "сирот" принципал или родительская сущность по-прежнему существует, но больше не связана с зависимыми или дочерними сущностями.

Где происходит каскадное поведение

Каскадные действия можно применить к:

  • Сущности, отслеживаемые текущим DbContext
  • Сущности в базе данных, которые не были загружены в контекст

Каскадное удаление отслеживаемых сущностей

EF Core всегда применяет настроенное каскадное поведение для отслеживаемых сущностей. Это означает, что если приложение загружает все соответствующие зависимые или дочерние сущности в DbContext, как показано в приведенных выше примерах, то каскадные действия будут применены правильно независимо от того, как настроена база данных.

Подсказка

Точное время наступления каскадных действий для отслеживаемых сущностей можно контролировать с помощью ChangeTracker.CascadeDeleteTiming и ChangeTracker.DeleteOrphansTiming. Дополнительные сведения см. в разделе "Изменение внешних ключей и навигаций ".

Каскадное удаление в базе данных

Многие системы баз данных также предлагают каскадное поведение, которое активируется при удалении сущности в базе данных. EF Core настраивает эти поведения на основе каскадного поведения удаления в модели EF Core при создании базы данных с помощью EnsureCreated или миграций EF Core. Например, с помощью приведенной выше модели создается следующая таблица для записей при использовании SQL Server:

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NULL,
    [Content] nvarchar(max) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id]) ON DELETE CASCADE
);

Обратите внимание, что ограничение внешнего ключа, определяющее связь между блогами и записями, настроено с ON DELETE CASCADE помощью.

Если мы знаем, что база данных настроена так, то мы можем удалить блог без первой загрузки записей , и база данных будет заботиться об удалении всех записей, связанных с этим блогом. Рассмотрим пример.

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).FirstAsync();

context.Remove(blog);

await context.SaveChangesAsync();

Обратите внимание, что для записей нет Include , поэтому они не загружаются. SaveChanges в этом случае будет удалять только блог, так как это единственная сущность, отслеживаемая:

-- Executed DbCommand (6ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Blogs]
WHERE [Id] = @p0;
SELECT @@ROWCOUNT;

Это приведет к исключению, если ограничение внешнего ключа в базе данных не настроено для каскадных удалений. Однако в этом случае записи удаляются базой данных, так как она была настроена с помощью ON DELETE CASCADE при создании.

Замечание

Базы данных обычно не имеют способа автоматического удаления осиротевших записей. Это связано с тем, что EF Core представляет связи с использованием навигационных свойств и внешних ключей, в то время как базы данных имеют только внешние ключи и не имеют навигаций. Это означает, что обычно невозможно разорвать связь без загрузки обеих сторон в DbContext.

Замечание

База данных EF Core в памяти в настоящее время не поддерживает каскадные удаления в базе данных.

Предупреждение

Не настраивайте каскадное удаление в базе данных при мягком удалении сущностей. Это может привести к случайному удалению сущностей вместо обратимого удаления.

Каскадные ограничения базы данных

Некоторые базы данных, в частности SQL Server, имеют ограничения на каскадное поведение, которое формирует циклы. Например, рассмотрим следующую модель:

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public IList<Post> Posts { get; } = new List<Post>();

    public int OwnerId { get; set; }
    public Person Owner { get; set; }
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public Blog Blog { get; set; }

    public int AuthorId { get; set; }
    public Person Author { get; set; }
}

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }

    public IList<Post> Posts { get; } = new List<Post>();

    public Blog OwnedBlog { get; set; }
}

Эта модель имеет три отношения, все обязательные и поэтому настроенные для каскадного удаления по соглашению.

  • Удаление блога будет каскадно удалять все связанные записи
  • Удаление автора записей приведет к тому, что созданные записи будут удалены каскадными
  • Удаление владельца блога приведет к каскаду удаления блога

Это все разумно (если немного драконовское в политике управления блогами!), но попытка создать базу данных SQL Server с настроенными каскадами приводит к следующему исключению:

Microsoft.Data.SqlClient.SqlException (0x80131904): введение ограничения FOREIGN KEY "FK_Posts_Person_AuthorId" в таблице "Записи" может вызвать циклы или несколько каскадных путей. Укажите ON DELETE NO ACTION или ON UPDATE NO ACTION либо измените другие ограничения внешнего ключа (FOREIGN KEY).

Существует два способа обработки этой ситуации:

  1. Измените одну или несколько связей, чтобы отменить каскадное удаление.
  2. Настройте базу данных без одного или нескольких этих каскадных удалений, а затем убедитесь, что все зависимые сущности загружаются таким образом, чтобы EF Core могли выполнять каскадное поведение.

При первом подходе к нашему примеру можно сделать связь после блога необязательной, предоставив ему свойство внешнего ключа, допускающее значение NULL:

public int? BlogId { get; set; }

Необязательная связь позволяет публикации существовать без блога, что означает, что каскадное удаление больше не будет настроено по умолчанию. Это означает, что цикл в каскадных действиях больше не существует, и база данных может быть создана без ошибок в SQL Server.

Приняв второй подход, мы можем сохранить связь владельца блога, требуемую и настроенную для каскадного удаления, но сделать эту конфигурацию применимой только к отслеживаемым сущностям, а не к базе данных.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<Blog>()
        .HasOne(e => e.Owner)
        .WithOne(e => e.OwnedBlog)
        .OnDelete(DeleteBehavior.ClientCascade);
}

Что произойдет, если мы загружаем обоих: человека и блог, которым они владеют, а затем удаляем этого человека?

using var context = new BlogsContext();

var owner = await context.People.SingleAsync(e => e.Name == "ajcvickers");
var blog = await context.Blogs.SingleAsync(e => e.Owner == owner);

context.Remove(owner);

await context.SaveChangesAsync();

EF Core будет каскадно удалять владельца, чтобы блог также был удален:

-- Executed DbCommand (8ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Blogs]
WHERE [Id] = @p0;
SELECT @@ROWCOUNT;

-- Executed DbCommand (2ms) [Parameters=[@p1='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [People]
WHERE [Id] = @p1;
SELECT @@ROWCOUNT;

Однако если блог не загружается при удалении владельца:

using var context = new BlogsContext();

var owner = await context.People.SingleAsync(e => e.Name == "ajcvickers");

context.Remove(owner);

await context.SaveChangesAsync();

Затем исключение будет вызвано нарушением ограничения внешнего ключа в базе данных:

Microsoft.Data.SqlClient.SqlException: инструкция DELETE конфликтует с ограничением REFERENCE "FK_Blogs_People_OwnerId". Конфликт произошел в базе данных "Scratch", таблице "dbo.Blogs", столбец 'OwnerId'. Заявление было прекращено.

Каскадное присваивание NULL

Необязательные связи имеют свойства внешнего ключа, допускающие значение NULL, сопоставленные со столбцами базы данных, допускающих значение NULL. Это означает, что значение внешнего ключа может быть установлено в NULL, если текущий главный объект или родитель удаляется или отсоединяется от зависимого или дочернего элемента.

Давайте снова рассмотрим случаи каскадного поведения, но на этот раз с необязательным отношением, представленным nullable свойством внешнего ключа Post.BlogId.

public int? BlogId { get; set; }

Это свойство внешнего ключа будет иметь значение NULL для каждой записи при удалении связанного блога. Например, этот код, аналогичный приведенному ранее:

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync();

context.Remove(blog);

await context.SaveChangesAsync();

Теперь приведет к следующим обновлениям базы данных при вызове SaveChanges:

-- Executed DbCommand (2ms) [Parameters=[@p1='1', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
UPDATE [Posts] SET [BlogId] = @p0
WHERE [Id] = @p1;
SELECT @@ROWCOUNT;

-- Executed DbCommand (0ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
UPDATE [Posts] SET [BlogId] = @p0
WHERE [Id] = @p1;
SELECT @@ROWCOUNT;

-- Executed DbCommand (1ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Blogs]
WHERE [Id] = @p2;
SELECT @@ROWCOUNT;

Аналогичным образом, если связь разорвана с помощью любого из приведенных выше примеров:

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync();

foreach (var post in blog.Posts)
{
    post.Blog = null;
}

await context.SaveChangesAsync();

Или:

using var context = new BlogsContext();

var blog = await context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync();

blog.Posts.Clear();

await context.SaveChangesAsync();

Затем записи обновляются со значениями внешнего ключа NULL при вызове SaveChanges:

-- Executed DbCommand (2ms) [Parameters=[@p1='1', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
UPDATE [Posts] SET [BlogId] = @p0
WHERE [Id] = @p1;
SELECT @@ROWCOUNT;

-- Executed DbCommand (0ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
UPDATE [Posts] SET [BlogId] = @p0
WHERE [Id] = @p1;
SELECT @@ROWCOUNT;

Дополнительные сведения об управлении внешними ключами и навигациями EF Core при изменении их значений см. в разделе "Изменение внешних ключей и навигаций".

Замечание

Исправление таких связей было поведением Entity Framework по умолчанию с первой версии в 2008 году. До EF Core он не имеет имени и не смог измениться. Теперь это известно как ClientSetNull, как описано в следующем разделе.

Базы данных также можно настроить для каскадного обнуления, когда главный элемент или родитель в необязательных связях удаляется. Однако это гораздо менее распространено, чем использование каскадных удалений в базе данных. Использование каскадных удалений и каскадных значений NULL в базе данных в то же время почти всегда приводит к циклам отношений при использовании SQL Server. Дополнительные сведения о настройке каскадных значений NULL см. в следующем разделе.

Настройка каскадного поведения

Подсказка

Прежде чем приехать сюда, обязательно ознакомьтесь с разделами выше. Параметры конфигурации, скорее всего, не будут понятны, если предыдущий материал не понят.

Каскадные поведения настраиваются для каждой связи с помощью метода OnDelete в OnModelCreating. Рассмотрим пример.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<Blog>()
        .HasOne(e => e.Owner)
        .WithOne(e => e.OwnedBlog)
        .OnDelete(DeleteBehavior.ClientCascade);
}

Дополнительные сведения о настройке связей между типами сущностей см. в разделе "Связи ".

OnDelete принимает значение из, по общему признанию, запутанного DeleteBehavior перечисления. Этот перечисление определяет поведение EF Core для отслеживаемых сущностей и настройку каскадного удаления в базе данных, когда EF используется для создания схемы.

Влияние на схему базы данных

В следующей таблице показан результат воздействия каждого значения OnDelete на ограничение внешнего ключа, созданное миграциями EF Core или EnsureCreated.

DeleteBehavior Влияние на схему базы данных
Каскад ПРИ УДАЛЕНИИ КАСКАДА
Ограничивать ПРИ УДАЛЕНИИ ОГРАНИЧЕНИЕ
НетДействия база данных по умолчанию
УстановитьНоль ПРИ УДАЛЕНИИ УСТАНОВИТЬ NULL
ClientSetNull база данных по умолчанию
ClientCascade база данных по умолчанию
ClientNoAction база данных по умолчанию

Поведение ON DELETE NO ACTION (по умолчанию базы данных) и ON DELETE RESTRICT реляционных баз данных обычно идентичны или очень похожи. Несмотря на то, что NO ACTION может означать, оба этих варианта вызывают принудительное применение ссылочных ограничений. Разница, если есть одна, заключается в том, когда база данных проверяет ограничения. Ознакомьтесь с документацией вашей базы данных, чтобы узнать о конкретных различиях между ON DELETE NO ACTION и ON DELETE RESTRICT в вашей системе управления базой данных.

SQL Server не поддерживается ON DELETE RESTRICT, поэтому ON DELETE NO ACTION вместо этого используется.

Единственными значениями, которые вызывают каскадное поведение в базе данных, являются Cascade и SetNull. Все остальные значения будут настроены так, чтобы база данных не передавала изменения каскадом.

Влияние на поведение функции "SaveChanges"

В таблицах в следующих разделах описывается, что происходит с зависимыми/дочерними сущностями при удалении основной/родительской сущности или разрыве её связи с зависимыми/дочерними сущностями. Каждая таблица охватывает один из следующих вариантов:

  • Необязательные (допускающие значение NULL FK) и обязательные (не допускающие значение NULL FK) связи
  • Когда зависимые или дочерние элементы загружаются и отслеживаются dbContext и когда они существуют только в базе данных

Необходимая связь с зависимыми или детьми загружена.

DeleteBehavior Удаление субъекта или родителя При отсоединении от основного/родительского элемента
Каскад Зависящие объекты, удаленные EF Core Зависящие объекты, удаленные EF Core
Ограничивать InvalidOperationException InvalidOperationException
НетДействия InvalidOperationException InvalidOperationException
УстановитьНоль SqlException о создании базы данных SqlException о создании базы данных
ClientSetNull InvalidOperationException InvalidOperationException
ClientCascade Зависящие объекты, удаленные EF Core Зависящие объекты, удаленные EF Core
ClientNoAction DbUpdateException InvalidOperationException

Примечания:

  • Значение по умолчанию для необходимых связей, таких как это Cascade.
  • Использование любого другого метода, кроме каскадного удаления для обязательных отношений, приведет к исключению при вызове метода SaveChanges.
    • Как правило, это случай InvalidOperationException из EF Core, так как недопустимое состояние обнаруживается в загруженных дочерних или зависимых объектах.
    • ClientNoAction заставляет EF Core не проверять зависимости исправления перед отправкой их в базу данных, поэтому в этом случае база данных выдает исключение, которое затем упаковывается в DbUpdateException saveChanges.
    • SetNull блокируется при создании базы данных, так как столбец внешнего ключа не допускает значения NULL.
  • Поскольку зависимые объекты/дочерние объекты загружаются, они всегда удаляются в EF Core и никогда не оставляются для удаления базой данных.

Обязательные связи с зависимыми или потомками не загружены

DeleteBehavior Удаление субъекта или родителя При отсоединении от основного/родительского элемента
Каскад Зависимые удалены базой данных Не применимо
Ограничивать DbUpdateException Не применимо
НетДействия DbUpdateException Не применимо
УстановитьНоль SqlException о создании базы данных Не применимо
ClientSetNull DbUpdateException Не применимо
ClientCascade DbUpdateException Не применимо
ClientNoAction DbUpdateException Не применимо

Примечания:

  • Прекращение отношений здесь невозможно, поскольку зависимые компоненты или дочерние элементы не загружены.
  • Значение по умолчанию для необходимых связей, таких как это Cascade.
  • Использование любого другого метода, кроме каскадного удаления для обязательных отношений, приведет к исключению при вызове метода SaveChanges.
    • Как правило, это DbUpdateException связано с тем, что зависимые или дочерние элементы не загружаются, поэтому недопустимое состояние может быть обнаружено только базой данных. SaveChanges затем упаковывает исключение базы данных в DbUpdateException.
    • SetNull блокируется при создании базы данных, так как столбец внешнего ключа не допускает значения NULL.

Необязательная связь с зависимыми или дочерними элементами загружена

DeleteBehavior Удаление субъекта или родителя При отсоединении от основного/родительского элемента
Каскад Зависящие объекты, удаленные EF Core Зависящие объекты, удаленные EF Core
Ограничивать Зависимые FK, равные NULL по EF Core Зависимые FK, равные NULL по EF Core
НетДействия Зависимые FK, равные NULL по EF Core Зависимые FK, равные NULL по EF Core
УстановитьНоль Зависимые FK, равные NULL по EF Core Зависимые FK, равные NULL по EF Core
ClientSetNull Зависимые FK, равные NULL по EF Core Зависимые FK, равные NULL по EF Core
ClientCascade Зависящие объекты, удаленные EF Core Зависящие объекты, удаленные EF Core
ClientNoAction DbUpdateException Зависимые FK, равные NULL по EF Core

Примечания:

  • Значение по умолчанию для необязательных связей, подобных этой, — ClientSetNull.
  • Зависимые/дети никогда не удаляются, если не настроены Cascade или ClientCascade.
  • Все остальные значения приводят к тому, что зависимые FK будут иметь значение NULL в EF Core...
    • ...за исключением ClientNoAction, который указывает EF Core не изменять внешние ключи зависимых или дочерних элементов при удалении основного объекта или родителя. Поэтому база данных создает исключение, которое обертывается методом SaveChanges как DbUpdateException.

Необязательная связь с иждивенцами/детьми не загружена

DeleteBehavior Удаление субъекта или родителя При отсоединении от основного/родительского элемента
Каскад Зависимые удалены базой данных Не применимо
Ограничивать DbUpdateException Не применимо
НетДействия DbUpdateException Не применимо
УстановитьНоль Зависимые FK, равные null по базе данных Не применимо
ClientSetNull DbUpdateException Не применимо
ClientCascade DbUpdateException Не применимо
ClientNoAction DbUpdateException Не применимо

Примечания:

  • Прекращение отношений здесь невозможно, поскольку зависимые компоненты или дочерние элементы не загружены.
  • Значение по умолчанию для необязательных связей, подобных этой, — ClientSetNull.
  • Зависимости или дочерние объекты должны быть загружены, чтобы избежать исключения базы данных, если база данных не настроена на каскадное удаление или установку значений NULL.