Home > other >  Cascading soft deletes without loading entire relationship in Ef Core
Cascading soft deletes without loading entire relationship in Ef Core

Time:01-25

I was working on cascading soft deletes to related entities. I have the following solution so far but it requires that I load the related data using Include, ThenInclude ... in order to work. Is there a better way to achieve the same thing? The problem is if I forgot to load a related data the corresponding Delete method will raise null exception.

public class Entity1
    {
        public string Name { get; set; }
        public IList<Entity2> Entity2s { get; set; }
       
       public void Delete()
        {
            if (IsDeleted == null)
            {
                Name = $"{Guid.NewGuid()}${Name}";
                IsDeleted = DateTime.Now;

                foreach (var entity2 in Entity2s) // this raises null reference exception if Entity2s is not loaded
                {
                    entity2.Delete();
                }
         }
    }
public class Entity2
    {
        public string Name { get; set; }
        public IList<Entity3> Entity3s{ get; set; }
       
       public void Delete()
        {
            if (IsDeleted == null)
            {
                Name = $"{Guid.NewGuid()}${Name}";
                IsDeleted = DateTime.Now;

                foreach (var entity3 in Entity3s) // this raises null reference exception if Entity3s is not loaded
                {
                    entity3.Delete();
                }
         }
    }

Then on delete I have

var ent = await _context.Entity1.Include(x => x.Entity2s)
                               .ThenInclude(x => x.Entity3s)
                               .FirstOrDefaultAsync(x => x.Id == id);

                ent.Delete();

   await _context.SaveChangesAsync(cancellationToken);

This example is only between 3 entities but in the real application there are a lots of relations. So for each relation I have to do Include, ThenInclude ... Please help. I really appreciate it if you could also point me to a better solution. I have googled this but so far I couldnt find the right solution for this.

CodePudding user response:

Since you are applying business logic to the delete process, such as in recording the deletion time and renaming deleted items then this is pretty much going to be the mechanism to delete them. Putting the Delete as a domain action in the entity like you have I would recommend enabling lazy loading as this would ensure that it can run reliably as you'd expect it would only be called while the entity is in scope of a DbContext. Eager loading is certainly recommended, but entities can reasonably assure that a delete operation will succeed if something is forgotten. (Better to have a "sub-optimal" performance bug than a operation cancelling bug)

If you want to disable lazy loading entirely then I would consider moving the Delete method out of the entity and into a Repository or service method that ensures that the provided entity and related entities are loaded then proceeds to perform the delete operation on the entire graph. The entities can still use an internal Delete() method to standardize the setting of the date and rename if necessary, just not worry about the graph, that would be the responsibility of the Repository/service. This way your controller or such code might "see" an entity, but cannot call Delete, they must use the Service to delete entities at whatever level is supported and the service ensures that the relevant entity graph is updated.

CodePudding user response:

I have used a more generic approach in the past by overriding the SaveChanges() and SaveChangesAsync() EF methods in the DbContext.cs class as follows...

public override int SaveChanges()
{
    UpdateDateTrackingColumns();
    return base.SaveChanges();
}

public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
{
    UpdateDateTrackingColumns();
    return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

private void UpdateDateTrackingColumns()
{
    foreach (var entry in ChangeTracker.Entries())
    {
        if (!entry.CurrentValues.EntityType.ToString().Contains("Identity"))
        {
            switch (entry.Metadata.Name)
            {
                // Entities where there is no DateCreated, DateUpdated, or DateDeleted columns in the entity are processed this way...
                // For example, EF Core generated many-to-many intersect tables will not have these columns.
                case "IgnoreTheseEntities": // Exclude entities by listing them as 'case "EntityName":' lines.
                    break;
                // All entities with DateCreated, DateUpdated, and DateDeleted columns are processed in this way...
                default:
                    var saveUtcDate = DateTime.UtcNow;
                    switch (entry.State)
                    {
                        case EntityState.Added:
                            entry.CurrentValues["DateCreated"] = saveUtcDate;
                            entry.CurrentValues["DateUpdated"] = saveUtcDate;
                            entry.CurrentValues["DateDeleted"] = DateTime.MinValue;
                            break;
                        case EntityState.Modified:
                            entry.CurrentValues["DateUpdated"] = saveUtcDate;
                            break;
                        case EntityState.Deleted:
                            entry.State = EntityState.Modified;
                            entry.CurrentValues["DateDeleted"] = saveUtcDate;
                            break;
                    }
                    break;
            }
        }
    }
}

Probably worth noting that in the example above I was also using Microsoft Identity Framework... these tables don't have the date columns by default. So I chose to ignore these tables, hence the if (!entry.CurrentValues.EntityType.ToString().Contains("Identity")) clause. You can remove this if clause if you're not using Identity Framework.

[Edit] You may also need to worry about DeleteBehaviour. You can prevent cascading deletes using something like the following in EntityTypeConfiguration classes...

builder
    .HasMany(p => p.Teams)
    .WithOne(d => d.Location)
    .OnDelete(DeleteBehavior.NoAction);

You may also want to add query filters to each of your entities. This will then prevent soft deleted rows being returned by EF queries. I use EntityTypeConfiguration classes to handle these. An example of this would look like...

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Common.Models;
using System;

namespace MyQT.DAL.EntityTypeConfiguration
{
    public class AddressTypeEntityTypeConfiguration : IEntityTypeConfiguration<AddressType>
    {
        public void Configure(EntityTypeBuilder<AddressType> builder)
        {
            builder
                .Property(b => b.AddressTypeId)
                .HasColumnType("uniqueidentifier")
                .ValueGeneratedOnAdd()
                .IsRequired()
                .HasComment("Unique identifier for a given AddressType.");
            builder
                .Property(b => b.Name)
                .HasMaxLength(200)
                .IsRequired()
                .HasComment("The AddressType name.");
            builder
                .Property(b => b.DateCreated)
                .IsRequired()
                .HasComment("The UTC date/time that the row was inserted into the database.");
            builder
                .Property(b => b.DateUpdated)
                .IsRequired()
                .HasComment("The UTC date/time that any given row was last updated. Upon record creation this will be set to DateCreated value.");
            builder
                .Property(b => b.DateDeleted)
                .HasComment("The UTC date/time that any given row was \"deleted\".  Data is NOT actually deleted.  It will instead be \"archived\" by populating the DateDeleted for any given row.");
            builder
                .Property(b => b.TimeStamp)
                .IsRowVersion()  // Alternatively use ".IsConcurrencyToken()"... similar but different
                .HasComment("Concurrency token.");

            // Soft delete automated query filter
            builder
                .HasQueryFilter(m => EF.Property<DateTime>(m, "DateDeleted") == DateTime.MinValue);
                // Possible better way...
                //.HasQueryFilter(p => p.DateDeleted.Value == DateTime.MinValue);
        }
    }
}
  •  Tags:  
  • Related