پیاده سازی SoftDelete در EF Core
نویسنده: وحید نصیری
تاریخ: ۱۳۹۹/۰۶/۲۷ ۱۱:۳۰
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
using System;
namespace EFCoreSoftDelete.Entities
{
public abstract class BaseEntity
{
public int Id { get; set; }
public bool IsDeleted { set; get; }
public DateTime? DeletedAt { set; get; }
}
} using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace EFCoreSoftDelete.Entities
{
public class Blog : BaseEntity
{
public string Name { set; get; }
public virtual ICollection<Post> Posts { set; get; }
}
public class BlogConfiguration : IEntityTypeConfiguration<Blog>
{
public void Configure(EntityTypeBuilder<Blog> builder)
{
builder.Property(blog => blog.Name).HasMaxLength(450).IsRequired();
builder.HasIndex(blog => blog.Name).IsUnique();
builder.HasData(new Blog { Id = 1, Name = "Blog 1" });
builder.HasData(new Blog { Id = 2, Name = "Blog 2" });
builder.HasData(new Blog { Id = 3, Name = "Blog 3" });
}
}
} using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace EFCoreSoftDelete.Entities
{
public class Post : BaseEntity
{
public string Title { set; get; }
public Blog Blog { set; get; }
public int BlogId { set; get; }
}
public class PostConfiguration : IEntityTypeConfiguration<Post>
{
public void Configure(EntityTypeBuilder<Post> builder)
{
builder.Property(post => post.Title).HasMaxLength(450);
builder.HasOne(post => post.Blog).WithMany(blog => blog.Posts).HasForeignKey(post => post.BlogId);
builder.HasData(new Post { Id = 1, BlogId = 1, Title = "Post 1" });
builder.HasData(new Post { Id = 2, BlogId = 1, Title = "Post 2" });
builder.HasData(new Post { Id = 3, BlogId = 1, Title = "Post 3" });
builder.HasData(new Post { Id = 4, BlogId = 1, Title = "Post 4" });
builder.HasData(new Post { Id = 5, BlogId = 2, Title = "Post 5" });
}
}
} public class BlogConfiguration : IEntityTypeConfiguration<Blog>
{
public void Configure(EntityTypeBuilder<Blog> builder)
{
// ...
builder.HasQueryFilter(blog => !blog.IsDeleted);
}
} SELECT [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE [b].[IsDeleted] <> CAST(1 AS bit)
namespace EFCoreSoftDelete.DataLayer
{
public static class GlobalFiltersManager
{
public static void ApplySoftDeleteQueryFilters(this ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model
.GetEntityTypes()
.Where(eType => typeof(BaseEntity).IsAssignableFrom(eType.ClrType)))
{
entityType.addSoftDeleteQueryFilter();
}
}
private static void addSoftDeleteQueryFilter(this IMutableEntityType entityData)
{
var methodToCall = typeof(GlobalFiltersManager)
.GetMethod(nameof(getSoftDeleteFilter), BindingFlags.NonPublic | BindingFlags.Static)
.MakeGenericMethod(entityData.ClrType);
var filter = methodToCall.Invoke(null, new object[] { });
entityData.SetQueryFilter((LambdaExpression)filter);
}
private static LambdaExpression getSoftDeleteFilter<TEntity>() where TEntity : BaseEntity
{
return (Expression<Func<TEntity, bool>>)(entity => !entity.IsDeleted);
}
}
} namespace EFCoreSoftDelete.DataLayer
{
public class ApplicationDbContext : DbContext
{
//...
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(BaseEntity).Assembly);
modelBuilder.ApplySoftDeleteQueryFilters();
} namespace EFCoreSoftDelete.DataLayer
{
public static class AuditableEntitiesManager
{
public static void SetAuditableEntityOnBeforeSaveChanges(this ApplicationDbContext context)
{
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<BaseEntity>())
{
switch (entry.State)
{
case EntityState.Added:
//TODO: ...
break;
case EntityState.Modified:
//TODO: ...
break;
case EntityState.Deleted:
entry.State = EntityState.Unchanged; //NOTE: For soft-deletes to work with the original `Remove` method.
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = now;
break;
}
}
}
}
} var post1 = context.Posts.Find(1);
if (post1 != null)
{
context.Remove(post1);
context.SaveChanges();
} Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:11:32' (Nullable = true), @p1='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Posts] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT;
namespace EFCoreSoftDelete.DataLayer
{
public class ApplicationDbContext : DbContext
{
// ...
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
ChangeTracker.DetectChanges();
beforeSaveTriggers();
ChangeTracker.AutoDetectChangesEnabled = false; // for performance reasons, to avoid calling DetectChanges() again.
var result = base.SaveChanges(acceptAllChangesOnSuccess);
ChangeTracker.AutoDetectChangesEnabled = true;
return result;
}
// ...
private void beforeSaveTriggers()
{
setAuditProperties();
}
private void setAuditProperties()
{
this.SetAuditableEntityOnBeforeSaveChanges();
}
}
} ar blog1 = context.Blogs.FirstOrDefault(blog => blog.Id == 1);
if (blog1 != null)
{
context.Remove(blog1);
context.SaveChanges();
} var blog1AndItsRelatedPosts = context.Blogs
.Include(blog => blog.Posts)
.FirstOrDefault(blog => blog.Id == 1);
if (blog1AndItsRelatedPosts != null)
{
context.Remove(blog1AndItsRelatedPosts);
context.SaveChanges();
} SELECT [t].[Id], [t].[DeletedAt], [t].[IsDeleted], [t].[Name], [t0].[Id], [t0].[BlogId], [t0].[DeletedAt], [t0].[IsDeleted], [t0].[Title] FROM ( SELECT TOP(1) [b].[Id], [b].[DeletedAt], [b].[IsDeleted], [b].[Name] FROM [Blogs] AS [b] WHERE ([b].[IsDeleted] <> CAST(1 AS bit)) AND ([b].[Id] = 1) ) AS [t] LEFT JOIN ( SELECT [p].[Id], [p].[BlogId], [p].[DeletedAt], [p].[IsDeleted], [p].[Title] FROM [Posts] AS [p] WHERE [p].[IsDeleted] <> CAST(1 AS bit) ) AS [t0] ON [t].[Id] = [t0].[BlogId] ORDER BY [t].[Id], [t0].[Id] Executing DbCommand [Parameters=[@p2='1', @p0='2020-09-17T05:25:00' (Nullable = true), @p1='True', @p5='2', @p3='2020-09-17T05:25:00' (Nullable = true), @p4='True', @p8='3', @p6='2020-09-17T05:25:00' (Nullable = true), @p7='True', @p11='4', @p9='2020-09-17T05:25:00' (Nullable = true), @p10='True'], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [Blogs] SET [DeletedAt] = @p0, [IsDeleted] = @p1 WHERE [Id] = @p2; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p3, [IsDeleted] = @p4 WHERE [Id] = @p5; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p6, [IsDeleted] = @p7 WHERE [Id] = @p8; SELECT @@ROWCOUNT; UPDATE [Posts] SET [DeletedAt] = @p9, [IsDeleted] = @p10 WHERE [Id] = @p11; SELECT @@ROWCOUNT;
// To fix https://github.com/dotnet/efcore/issues/19786 context.ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges; context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.OnSaveChanges;
حل مشکل حذف منطقی در جداولی که فیلد منحصر به فرد دارند
[Index(nameof(PhoneNumber), IsUnique = true)]
public class User
{
public long Id { get; set; }
[Required]
[MaxLength(100)]
public string FullName { get; set; } = null!;
[Required]
[MaxLength(11)]
public string PhoneNumber { get; set; } = null!;
public bool IsDeleted { get; set; }
}
در موجودیت User فیلد PhoneNumber منحصر به فرد است.
کاربری با شماره تلفن 123 ثبت نام میکند و بعد حذف میشود
الان شماره تلفن 123 آزاد است و کاربر دیگری میتواند با این شماره ثبت نام کند و این کاربر نیز حذف میشود و یک کاربر جدید با شماره تلفن 123 ثبت نام میکند
FullName PhoneNumber IsDeleted User1 123 True User2 123 True User3 123 False
پس با وجود اینکه شماره تلفن Unique است ولی میتوان شماره تلفن تکراری داشت به شرطی که IsDeleted=True باشد و باید فقط یک شماره تلفن 123 با IsDeleted=False داشت.
برای اینکار از Filter ها استفاده میکنیم و تنها کافیست در فایل Configuration موجودیت User این کد را اضافه کرد
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasIndex(x => x.PhoneNumber)
.HasFilter("[IsDeleted] = 0");
}
}
الان شرط Unique بودن فقط در صورتی کار میکند که IsDeleted=False باشد.
public static void AppendQueryFilter<T>(
this EntityTypeBuilder<T> entityTypeBuilder, Expression<Func<T, bool>> expression)
where T : class
{
var parameterType = Expression.Parameter(entityTypeBuilder.Metadata.ClrType);
var expressionFilter = ReplacingExpressionVisitor.Replace(
expression.Parameters.Single(), parameterType, expression.Body);
if (entityTypeBuilder.Metadata.GetQueryFilter() != null)
{
var currentQueryFilter = entityTypeBuilder.Metadata.GetQueryFilter();
var currentExpressionFilter = ReplacingExpressionVisitor.Replace(
currentQueryFilter.Parameters.Single(), parameterType, currentQueryFilter.Body);
expressionFilter = Expression.AndAlso(currentExpressionFilter, expressionFilter);
}
var lambdaExpression = Expression.Lambda(expressionFilter, parameterType);
entityTypeBuilder.HasQueryFilter(lambdaExpression);
}