- «پیاده سازی حذف منطقی در Entity framework» حذف منطقی، یکی از الگوهای بسیار پرکاربرد در برنامههای تجاری است. توسط آن بجای حذف فیزیکی اطلاعات، آنها را تنها به عنوان رکوردی حذف شده، «علامتگذاری» میکنیم. مزایای آن نیز به شرح زیر هستند:
- داشتن سابقهی حذف اطلاعات
- جلوگیری از cascade delete
- امکان بازیابی رکوردها و امکان ایجاد قسمتی به نام recycle bin در برنامه (شبیه به recycle bin در ویندوز که امکان بازیابی موارد حذف شده را میدهد)
- امکان داشتن رکوردهایی که در یک برنامه (به ظاهر) حذف شدهاند، اما هنوز در برنامهی دیگری در حال استفاده هستند.
- بالابردن میزان امنیت برنامه. فرض کنید سایت شما هک شده و شخصی، دسترسی به پنل مدیریتی و سطوح دسترسی مدیریتی برنامه را پیدا کردهاست. در این حالت حذف تمام رکوردهای سایت توسط او، تنها به معنای تغییر یک بیت، از یک به صفر است و بازگرداندن این درجه از خسارت، تنها با روشن کردن این بیت، برطرف میشود.
پیاده سازی حذف منطقی در EF Core شامل مراحل خاصی است که در این مطلب، جزئیات آنها را بررسی خواهیم کرد.
نیاز به تعریف دو خاصیت جدید در هر جدول
هر جدولی که قرار است soft delete به آن اعمال شود، باید دارای دو فیلد جدید bool IsDeleted و DateTime? DeletedAt باشد. میتوان این خواص را به هر موجودیتی به صورت دستی اضافه کرد و یا میتوان ابتدا یک کلاس پایهی abstract را برای آن ایجاد کرد:
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" }); } } }
حذف خودکار رکوردهایی که Soft Delete شدهاند، از نتیجهی کوئریها و گزارشات
تا اینجا فقط دو خاصیت ساده را به کلاسهای مدنظر خود اضافه کردهایم. پس از آن یا میتوان در هر جائی برای مثال شرط context.Blogs.Where(blog => !blog.IsDeleted) را به صورت دستی اعمال کرد و در گزارشات، رکوردهای حذف منطقی شده را نمایش نداد و یا از زمان ارائهی EF Core 2x میتوان برای آنها Query Filter تعریف کرد. برای مثال میتوان به تنظیمات موجودیت Blog و یا Post مراجعه نمود و با استفاده از متد HasQueryFilter، همان شرط blog => !blog.IsDeleted را به صورت سراسری به تمام کوئریهای مرتبط با این موجودیتها اعمال کرد:
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)
اعمال خودکار QueryFilter مخصوص Soft Delete به تمام موجودیتها
همانطور که عنوان شد، مزیت علامتگذاری موجودیتها با کلاس پایهی BaseEntity، امکان کوئری گرفتن از آنها است:
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); } } }
محل اعمال آن نیز در انتهای متد OnModelCreating است تا به صورت خودکار به تمام موجودیتهای موجود اعمال شود:
namespace EFCoreSoftDelete.DataLayer { public class ApplicationDbContext : DbContext { //... protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(typeof(BaseEntity).Assembly); modelBuilder.ApplySoftDeleteQueryFilters(); }
مشکل! هنوز هم حذف فیزیکی رخ میدهد!
تنظیمات فوق، تنها بر روی کوئریهای نوشته شده تاثیر دارند؛ اما هیچگونه تاثیری را بر روی متد Remove و سپس SaveChanges نداشته و در این حالت، هنوز هم حذف واقعی و فیزیکی رخ میدهد.
برای رفع این مشکل باید به EF Core گفت، هر چند دستور حذف صادر شده، اما آنرا تبدیل به دستور Update کن؛ یعنی فیلد IsDelete را به 1 و فیلد DeletedAt را با زمان جاری مقدار دهی کن:
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;
محل اعمال متد SetAuditableEntityOnBeforeSaveChanges فوق، پیش از فراخوانی SaveChanges و به صورت زیر است:
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(); } } }
حالت پیشفرض حذف رکوردها در EFCore به cascade delete تنظیم شدهاست. یعنی اگر blog با id=1 حذف شود، نه فقط این blog، بلکه تمام مطالب وابستهی به آن نیز حذف خواهند شد. اما در اینجا اگر این بلاگ را حذف کنیم:
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;
یک نکته: مشکل حذف منطقی و رکوردهای منحصربفرد
فرض کنید در جدولی، فیلد نام کاربری را به عنوان یک فیلد منحصربفرد تعریف کردهاید و اکنون رکوردی در این بین، حذف منطقی شدهاست. مشکلی که در آینده بروز خواهد کرد، عدم امکان ثبت رکورد جدیدی با همان نام کاربری است که حذف منطقی شدهاست؛ چون یک unique index بر روی آن وجود دارد. در این حالت اگر از SQL Server استفاده میکنید، از قابلیتی به نام filtered indexes پشتیبانی میکند که در آن امکان تعریف یک شرط و predicate، در حین تعریف ایندکسها وجود دارد. در این حالت میتوان رکوردهای حذف منطقی شده را به ایندکس وارد نکرد.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: EFCoreSoftDelete.zip