3

How do you delete rows in tables using Entity Framework (Core), when you have two tables that are referencing each other in both directions?

Consider there classes:

public class ImageSeries
{
    int Id { get set; }
    int? MainImageId { get set; }    // Reference to Image.Id
}

public class Image
{
    int Id { get set; }
    int ImageSeriesId { get set; }  // Reference to ImageSeries.Id
}

Both have corresponding tables in the database. Zero to many images can be connected to an image series, but the image series can also reference one of those images as its main image.

In the database, the tables have constraints such that an image must reference an existing image series. The image series can, but doesn't have to, reference an image. There is no cascade delete configured.

When deleting an image series, the images connected to that should also be deleted. Because of the constraints, I want to delete the images before deleting the image series. However, I cannot delete the image that the image series references as its main image…

The solution, I believe, is this:

  1. Set MainImageId of ImageSeries to null.
  2. Delete all Images referencing the ImageSeries.
  3. Delete the ImageSeries.

I want to do this in a single transaction. Using Entity Framework, I'm not explicitly using a transaction in my code, but I'm only calling SaveChanges() once which I believe will have the same effect.

Here some code:

public void DeleteImageSeries(int imageSeriesToRemoveId)
{
    var imageSeries = dbContext.ImageSeries.First(is => is.Id == imageSeriesToRemoveId);
    imageSeries.MainImageId = null;
    var images = dbContext.Images.Where(i => i.ImageSeriesId == imageSeriesToRemoveId);
    foreach (var image in images)
    {
        dbContext.Remove(image);
    }
    dbContext.Remove(imagesSeries);
    dbContext.SaveChanges();
}

This does not work. MainImageId of the image series is not set to null before the images are being deleted, causing an exception to be thrown.

How do I make EntityFramework set MainImageId of ImageSeries to null before deleting the images? Preferably without calling SaveChanges() multiple times, and also without using a transaction (my real code I have this problem in is considerably more complex and would be hard to use a transaction in).

5
  • do this in a single transaction. already done, which means your method is buggy. SaveChanges will persist all pending changes, including any INSERTs and UPDATEs, inside a single internal database transaction. EF Core is an ORM, not a database driver and deals with objects, not tables. When you call DbContext.Remove you aren't deleting anything, you change that object's state, so it will be deleted when SaveChanges is called. Your entities have no object relations (eg Image.ImageSeries, or ImageSeries.Images), otherwise you wouldn't have to load entities only to delete them. Commented Oct 30 at 14:11
  • If proper relations are used, cascade deletes can be performed automatically and Image objects would be deleted automatically. In the doc example, where Blog has a Posts collection, context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).FirstAsync() will load a blog with its posts, no extra query required. context.Remove(blog); will result in a cascading delete of the blog's Posts Commented Oct 30 at 14:16
  • On top of that, if the table is configured for cascade deletes, as the Cascade Delete in the Database shows, you won't have to load the related entities at all. The database itself will delete the Images if an ImageSeries is deleted Commented Oct 30 at 14:18
  • Your design is kinda weird, shouldn't ImageSeries contain List of images, rather than a specific Image be connected to a single ImageSeries? Commented Oct 30 at 15:20
  • Rather than having a reciprocal relationship, which requires all kinds of insert/update in various order shenanigans, why not just have a property on the detail table like IsMainForSeries where only one row for each series can = 1? Commented Oct 30 at 15:21

3 Answers 3

4

You need to call SaveChanges in between, with a transaction over the whole thing.

public void DeleteImageSeries(int imageSeriesToRemoveId)
{
    using var tran = dbContext.Database.BeginTransaction();
    var imageSeries = dbContext.ImageSeries.First(is => is.Id == imageSeriesToRemoveId);
    imageSeries.MainImageId = null;
    dbContext.SaveChanges();

    var images = dbContext.Images.Where(i => i.ImageSeriesId == imageSeriesToRemoveId);
    foreach (var image in images)
    {
        dbContext.Remove(image);
    }
    dbContext.Remove(imagesSeries);
    dbContext.SaveChanges();

    tran.Commit();
}

Alternatively, a better idea is to just use ExecuteUpdate and ExecuteDelete without loading the entities at all.

public void DeleteImageSeries(int imageSeriesToRemoveId)
{
    using var tran = dbContext.Database.BeginTransaction();
    var imageSeriesQuery = dbContext.ImageSeries
        .Where(is => is.Id == imageSeriesToRemoveId);
    imageSeriesQuery.ExecuteUpdate(setter => setter
        .SetProperty(is => is.MainImageId, null));
    dbContext.Images
        .Where(i => i.ImageSeriesId == imageSeriesToRemoveId)
        .ExecuteDelete();
    imageSeriesQuery.ExecuteDelete();
    tran.Commit();
}

I agree with the others that you should probably just make an IsMain property on the Image (and add a filtered unique index to prevent duplicates). Then you can remove the circular reference.

Sign up to request clarification or add additional context in comments.

Comments

2

An alternative setup for this common pattern of collections with one "favored" item is to have all references in the owned entity. EF-core has various ways to implement 1:1 relationships and here one of them is used:

public class ImageSeries
{
    public int Id { get; set; }
    public ICollection<Image> Images { get; set; } = [];
    public required Image MainImage { get; set; }
}

public class Image
{
    public int Id { get; set; }
    public int? MainOfImageSeriesId { get; set; }
    public int ImageSeriesId { get; set; }
}

The mapping:

modelBuilder.Entity<ImageSeries>()
    .HasMany(e => e.Images)
    .WithOne()
    .HasForeignKey(i => i.ImageSeriesId)
    .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ImageSeries>()
    .HasOne(e => e.MainImage)
    .WithOne()
    .HasForeignKey<Image>(i => i.MainOfImageSeriesId);

In this model it's a piece of cake to add an ImageSeries with Images and a MainImage in one transaction, and to delete them:

using (var db = getContext())
{
    var images = Enumerable.Range(1,5).Select(e => new Image()).ToList();
    db.ImageSeries.Add(new ImageSeries
    {
        Images = images,
        MainImage = images[0]
    });
    db.SaveChanges();
}
using (var db = getContext())
{
    db.Remove(db.ImageSeries.FirstOrDefault()); 
    db.SaveChanges();
}

If you have an existing database, note that the Image table differs from what you probably have now and you may have to change the schema, if possible/permitted:

CREATE TABLE [Image] (
  [Id] int NOT NULL IDENTITY,
  [MainOfImageSeriesId] int NULL,
  [ImageSeriesId] int NOT NULL,
  CONSTRAINT [PK_Image] PRIMARY KEY ([Id]),
  CONSTRAINT [FK_Image_ImageSeries_ImageSeriesId] FOREIGN KEY ([ImageSeriesId])
      REFERENCES [ImageSeries] ([Id]) ON DELETE CASCADE,
  CONSTRAINT [FK_Image_ImageSeries_MainOfImageSeriesId] FOREIGN KEY ([MainOfImageSeriesId])
      REFERENCES [ImageSeries] ([Id])
);

I.e. both the 1:n and the 1:1 foreign keys are in the Image table, which is the reason that both relationships can be removed in one go.

Comments

-1

Try to call this, after changing the mainImageId to null, which should tell EF to do an update before trying to delete:

    dbContext.Entry(imageSeries).Property(x => x.MainImageId).IsModified = true;

Also, use RemoveRange instead of the foreach:

    dbContext.Images.RemoveRange(images);

1 Comment

That doesn't change anything. The property is already marked as modified by EF itself and RemoveRange essentially does the same thing, albeit a bit more efficiently.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.