使用ExpressionVisitor排除连接中的软删除记录

我有一个框架,在数据库中实现软删除(Nullable DateTime称为DeletedDate)。 我正在使用Repository来处理主要实体请求,如下所示:

///  /// Returns a Linq Queryable instance of the entity collection. ///  public IQueryable All { get { return Context.Set().Where(e => e.DeletedDate == null); } } 

这很好用,但我遇到的问题是当你包含导航属性时,以及如何确保只查询活动记录。 有问题的存储库方法如下所示:

 ///  /// Returns a Linq Queryable instance of the entity collection, allowing connected objects to be loaded. ///  /// Connected objects to be included in the result set. /// An IQueryable collection of entity. public IQueryable AllIncluding(params Expression<Func>[] includeProperties) { IQueryable query = Context.Set().Where(e => e.DeletedDate == null); foreach (var includeProperty in includeProperties) { query = query.Include(includeProperty); } return query; } 

因此,如果存储库由名为Parent的实体使用,该实体具有名为Children的导航属性,则AllIncluding方法将正确过滤掉软删除的父记录,但仍会包含软删除的子记录。

查看发送到数据库的查询,似乎所有需要做的就是添加到sql join子句“AND Children.DeletedDate IS NULL”,查询将返回正确的结果。

在我的研究过程中,我发现这篇文章似乎正是我所需要的,但是我的实现并没有得到海报的相同结果。 单步执行代码,查询的Children部分似乎没有任何结果。

这是我当前的相关代码(注意:使用nuget中的QueryInterceptor)

BaseClass的:

 using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace DomainClasses { ///  /// Serves as the Base Class for All Data Model Classes ///  public class BaseClass { ///  /// Default constructor, sets EntityState to Unchanged. ///  public BaseClass() { this.StateOfEntity = DomainClasses.StateOfEntity.Unchanged; } ///  /// Indicates the current state of the entity. Not mapped to Database. ///  [NotMapped] public StateOfEntity StateOfEntity { get; set; } ///  /// The entity primary key. ///  [Key, Column(Order = 0), ScaffoldColumn(false)] [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)] public int Id { get; set; } ///  /// The date the entity record was created. Updated in InsightDb.SaveChanges() method ///  [Column(Order = 1, TypeName = "datetime2"), ScaffoldColumn(false)] public DateTime AddDate { get; set; } ///  /// The UserName of the User who created the entity record. Updated in InsightDb.SaveChanges() method ///  [StringLength(56), Column(Order = 2), ScaffoldColumn(false)] public string AddUser { get; set; } ///  /// The date the entity record was modified. Updated in InsightDb.SaveChanges() method ///  [Column(Order = 3, TypeName = "datetime2"), ScaffoldColumn(false)] public DateTime ModDate { get; set; } ///  /// The UserName of the User who modified the entity record. ///  [StringLength(56), Column(Order = 4), ScaffoldColumn(false)] public string ModUser { get; set; } ///  /// Allows for Soft Delete of records. ///  [Column(Order = 5, TypeName = "datetime2"), ScaffoldColumn(false)] public DateTime? DeletedDate { get; set; } } } 

家长class:

 using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace DomainClasses { ///  /// The Parent Entity. ///  public class Parent : BaseClass { ///  /// Instantiates a new instance of Parent, initializes the virtual sets. ///  public Parent() { this.Children = new HashSet(); } #region Properties ///  /// The Parent's Name ///  [StringLength(50), Required, Display(Name="Parent Name")] public string Name { get; set; } #endregion #region Relationships ///  /// Relationship to Child, 1 Parent = Many Children. ///  public virtual ICollection Children { get; set; } #endregion } } 

儿童class:

 using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace DomainClasses { ///  /// The Child entity. One Parent = Many Children ///  public class Child : BaseClass { #region Properties ///  /// Child Name. ///  [Required, StringLength(50), Display(Name="Child Name")] public string Name { get; set; } #endregion #region Relationships ///  /// Parent Relationship. 1 Parent = Many Children. ///  public virtual Parent Parent { get; set; } #endregion } } 

上下文类:

 using DomainClasses; using System; using System.Data; using System.Data.Entity; using System.Linq; namespace DataLayer { public class DemoContext : DbContext, IDemoContext { ///  /// ActiveSession object of the user performing the action. ///  public ActiveSession ActiveSession { get; private set; } public DemoContext(ActiveSession activeSession) : base("name=DemoDb") { ActiveSession = activeSession; this.Configuration.LazyLoadingEnabled = false; } #region Db Mappings public IDbSet Children { get; set; } public IDbSet Parents { get; set; } #endregion public override int SaveChanges() { var changeSet = ChangeTracker.Entries(); if (changeSet != null) { foreach (var entry in changeSet.Where(c => c.State != EntityState.Unchanged)) { entry.Entity.ModDate = DateTime.UtcNow; entry.Entity.ModUser = ActiveSession.UserName; if (entry.State == EntityState.Added) { entry.Entity.AddDate = DateTime.UtcNow; entry.Entity.AddUser = ActiveSession.UserName; } else if (entry.State == EntityState.Deleted) { entry.State = EntityState.Modified; entry.Entity.DeletedDate = DateTime.UtcNow; } } } return base.SaveChanges(); } public new IDbSet Set() where T : BaseClass { return ((DbContext)this).Set(); } } } 

存储库类:

 using DomainClasses; using QueryInterceptor; using System; using System.Data.Entity; using System.Linq; using System.Linq.Expressions; namespace DataLayer { ///  /// Entity Repository to be used in Business Layer. ///  public class EntityRepository : IEntityRepository where T : BaseClass { public IDemoContext Context { get; private set; } ///  /// Main Constructor for Repository. Creates an instance of DemoContext (derives from DbContext). ///  /// UserName of the User performing the action. public EntityRepository(ActiveSession activeSession) : this(new DemoContext(activeSession)) { } ///  /// Constructor for Repository. Allows a context (ie FakeDemoContext) to be passed in for testing. ///  /// IDemoContext to be used in the repository. Ie FakeDemoContext. public EntityRepository(IDemoContext context) { Context = context; } ///  /// Returns a Linq Queryable instance of the entity collection. ///  public IQueryable All { get { return Context.Set().Where(e => e.DeletedDate == null); } } ///  /// Returns a Linq Queryable instance of the entity collection, allowing connected objects to be loaded. ///  /// Connected objects to be included in the result set. /// An IQueryable collection of entity. public IQueryable AllIncluding(params Expression<Func>[] includeProperties) { IQueryable query = Context.Set().Where(e => e.DeletedDate == null); InjectConditionVisitor icv = new InjectConditionVisitor(); foreach (var includeProperty in includeProperties) { query = query.Include(includeProperty); } return query.InterceptWith(icv); } ///  /// Finds a single instance of the entity by the Id. ///  /// The primary key for the entity. /// An instance of the entity. public T Find(int id) { return Context.Set().Where(e => e.DeletedDate == null).SingleOrDefault(e => e.Id == id); } ///  /// Takes a single entity or entity graph and reads the explicit state, then applies the necessary State changes to Update or Add the entities. ///  /// The entity object. public void InsertOrUpdate(T entity) { if (entity.StateOfEntity == StateOfEntity.Added) { Context.Set().Add(entity); } else { Context.Set().Add(entity); Context.ApplyStateChanges(); } } ///  /// Deletes the instance of the entity. ///  /// The primary key of the entity. public void Delete(int id) { var entity = Context.Set().Where(e => e.DeletedDate == null).SingleOrDefault(e => e.Id == id); entity.StateOfEntity = StateOfEntity.Deleted; Context.Set().Remove(entity); } ///  /// Saves the transaction. ///  public void Save() { Context.SaveChanges(); } ///  /// Disposes the Repository. ///  public void Dispose() { Context.Dispose(); } } } 

InjectConditionVisitor类:

 using System; using System.Linq; using System.Linq.Expressions; namespace DataLayer { public class InjectConditionVisitor : ExpressionVisitor { private QueryConditional queryCondition; public InjectConditionVisitor(QueryConditional condition) { queryCondition = condition; } public InjectConditionVisitor() { queryCondition = new QueryConditional(x => x.DeletedDate == null); } protected override Expression VisitMember(MemberExpression ex) { // Only change generic types = Navigation Properties // else just execute the normal code. return !ex.Type.IsGenericType ? base.VisitMember(ex) : CreateWhereExpression(queryCondition, ex) ?? base.VisitMember(ex); } ///  /// Create the where expression with the adapted QueryConditional ///  /// The condition to use /// The MemberExpression we're visiting ///  private Expression CreateWhereExpression(QueryConditional condition, Expression ex) { var type = ex.Type;//.GetGenericArguments().First(); var test = CreateExpression(condition, type); if (test == null) return null; var listType = typeof(IQueryable).MakeGenericType(type); return Expression.Convert(Expression.Call(typeof(Enumerable), "Where", new Type[] { type }, (Expression)ex, test), listType); } ///  /// Adapt a QueryConditional to the member we're currently visiting. ///  /// The condition to adapt /// The type of the current member (=Navigation property) /// The adapted QueryConditional private LambdaExpression CreateExpression(QueryConditional condition, Type type) { var lambda = (LambdaExpression)condition.Conditional; var conditionType = condition.Conditional.GetType().GetGenericArguments().FirstOrDefault(); // Only continue when the condition is applicable to the Type of the member if (conditionType == null) return null; if (!conditionType.IsAssignableFrom(type)) return null; var newParams = new[] { Expression.Parameter(type, "bo") }; var paramMap = lambda.Parameters.Select((original, i) => new { original, replacement = newParams[i] }).ToDictionary(p => p.original, p => p.replacement); var fixedBody = ParameterRebinder.ReplaceParameters(paramMap, lambda.Body); lambda = Expression.Lambda(fixedBody, newParams); return lambda; } } } 

QueryConditional类:

 using DomainClasses; using System; using System.Linq.Expressions; namespace DataLayer { public class QueryConditional { public QueryConditional(Expression<Func> ex) { Conditional = ex; } public Expression<Func> Conditional { get; set; } } } 

ParameterRebinder类:

 using System.Collections.Generic; using System.Linq.Expressions; namespace DataLayer { public class ParameterRebinder : ExpressionVisitor { private readonly Dictionary map; public ParameterRebinder(Dictionary map) { this.map = map ?? new Dictionary(); } public static Expression ReplaceParameters(Dictionary map, Expression exp) { return new ParameterRebinder(map).Visit(exp); } protected override Expression VisitParameter(ParameterExpression node) { ParameterExpression replacement; if (map.TryGetValue(node, out replacement)) node = replacement; return base.VisitParameter(node); } } } 

IEntityRepository接口:

 using System; using System.Linq; using System.Linq.Expressions; namespace DataLayer { public interface IEntityRepository : IDisposable { IQueryable All { get; } IQueryable AllIncluding(params Expression<Func>[] includeProperties); T Find(int id); void InsertOrUpdate(T entity); void Delete(int id); void Save(); } } 

IDemoContext接口:

 using DomainClasses; using System; using System.Data.Entity; namespace DataLayer { public interface IDemoContext : IDisposable { ActiveSession ActiveSession { get; } IDbSet Children { get; } IDbSet Parents { get; } int SaveChanges(); IDbSet Set() where T : BaseClass; } } 

问题是您想要在AllIncluding方法中使用Include()语句添加条件。 queryinterceptor包不支持Include()方法。 只有使其工作的解决方案才是使用Include语句。

当您执行以下操作时,一切正常:

 Articles.Select(x => new { Vat = x.VatTypes }) .InterceptWith(Visitor); 

因此,当上述内容转换为sql时,您将看到Where VatTypes.IsDeleted = 0被添加到查询中。

是否真的有必要使用includeAll方法,从性能角度来看,这看起来像是一个巨大的开销,因为你正在从数据库加载所有东西。

编辑:再次阅读一些较旧的post后,看起来实际上可以使用InterludeWith方法和Include()语句。 也许是ExpressionVisitor与Include()有问题。 如果我找到一些时间,那么我会尝试一下并回复你。

我从来没有弄清楚访客的表达方式,并且已经花了足够的时间。 因此,如果DeletedDate不为null,我最终只是通过删除记录在表触发器中处理它。

软删除的最初目的是跟踪谁删除了应用程序中的记录。 我在保存更改上下文中设置了Mod用户,但是在删除时这不会更新,因此没有审核谁删除了。

我已经审核了每个表的“After Update”和“After Delete”触发器以及每个表的关联审计表。 只要有更新或删除,触发器基本上就会将旧记录插入到审计表中。 审计表和触发器是通过存储过程创建的:

 CREATE PROCEDURE [dbo].[CreateAuditTable]( @TableName NVARCHAR(100), @SchemaName NVARCHAR(50) ) as /* ----------------------------------------------------------------------------------------------------- * Procedure Name : dbo.CreateAuditTable * Author : Josh Jay * Date : 03/15/2013 * Description : Creates an Audit table from an existing table. ----------------------------------------------------------------------------------------------------- Sl No Date Modified Modified By Changes ------- ------------- ----------------- ------------------------------------------------- 1 07/01/2013 Josh Jay Removed the table alias parameter and replaced usage with table name. 2 08/28/2013 Josh Jay Modified the Update Statement to Delete the Row if it is a Soft Delete. ----------------------------------------------------------------------------------------------------- Ex: EXEC dbo.CreateAuditTable @TableName = 'Product', @SchemaName = 'dbo' */ BEGIN DECLARE @IssueCount INT = 0, @IssueList NVARCHAR(MAX) = NULL, @LineBreak NVARCHAR(50) = REPLICATE('-',50), @CreateTableScript NVARCHAR(MAX) = NULL, @CreateDeleteScript NVARCHAR(MAX) = NULL, @CreateUpdateScript NVARCHAR(MAX) = NULL, @ColumnNamesSection NVARCHAR(MAX) = NULL, @TableObjectId INT, @msg varchar(1024); --1) Check if table exists IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @SchemaName AND TABLE_NAME = @TableName) BEGIN SET @IssueCount = @IssueCount + 1; SET @IssueList = ISNULL(@IssueList + CHAR(10),'') + CONVERT(VARCHAR,@IssueCount) + ') The table ' + @SchemaName + '.' + @Tablename + ' does not exist.'; END; --2) Check if audit table exists IF EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @SchemaName AND TABLE_NAME = @TableName + '_Audit') BEGIN SET @IssueCount = @IssueCount + 1; SET @IssueList = ISNULL(@IssueList + CHAR(10),'') + CONVERT(VARCHAR,@IssueCount) + ') The audit table ' + @SchemaName + '.' + @Tablename + '_Audit already exists. To recreate the audit table, please drop the existing audit table and try again.'; END; --3) Check for existing triggers IF EXISTS (SELECT 1 FROM sys.triggers tr INNER JOIN sys.tables t on tr.parent_id = t.object_id WHERE t.schema_id = SCHEMA_ID(@SchemaName) AND t.name = @TableName AND tr.name LIKE 'tg_%Audit_%') BEGIN SET @IssueCount = @IssueCount + 1; SET @IssueList = ISNULL(@IssueList + CHAR(10),'') + CONVERT(VARCHAR,@IssueCount) + ') At least one audit trigger exists on the ' + @SchemaName + '.' + @Tablename + ' table. To recreate the audit table, please drop the audit triggers.'; END; --4) Print errors if there are any IF @IssueCount > 0 BEGIN PRINT('There were ' + CONVERT(VARCHAR,@IssueCount) + ' issues found when attempting to create the audit table. Please correct the issues below before trying again.'); PRINT(@LineBreak); PRINT(@IssueList); RETURN; END; --5) Build Scripts select @CreateTableScript = 'CREATE TABLE [' + SS.name + '].[' + ST.name + '_Audit]' + CHAR(10) + '(' + CHAR(10) + CHAR(9) + '[AuditId] INT IDENTITY(1,1) NOT NULL CONSTRAINT [pk_' + @SchemaName + '.' + @Tablename + '_Audit_AuditId] PRIMARY KEY,' + CHAR(10) + CHAR(9) + '[AuditDate] DATETIME NOT NULL CONSTRAINT [df_' + @SchemaName + '.' + @Tablename + '_Audit_AuditDate] DEFAULT (getutcdate()),' + CHAR(10) + CHAR(9) + '[AuditIsDelete] BIT NOT NULL CONSTRAINT [df_' + @SchemaName + '.' + @Tablename + '_Audit_AuditIsDelete] DEFAULT ((0))', @CreateDeleteScript = 'CREATE TRIGGER [dbo].[tg_' + @SchemaName + '.' + @Tablename + '_Audit_Delete]' + CHAR(10) + 'ON [' + SS.name + '].[' + ST.name + ']' + CHAR(10) + 'After Delete' + CHAR(10) + 'As Begin' + CHAR(10) + CHAR(9) + 'IF TRIGGER_NESTLEVEL() > 1' + CHAR(10) + CHAR(9) + CHAR(9) + 'Return' + CHAR(10) + CHAR(10) + CHAR(9) + 'INSERT INTO' + CHAR(10) + CHAR(9) + CHAR(9) + '[' + SS.name + '].[' + ST.name + '_Audit] (' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + '[AuditIsDelete]', @CreateUpdateScript = 'CREATE TRIGGER [dbo].[tg_' + @SchemaName + '.' + @Tablename + '_Audit_Update]' + CHAR(10) + 'ON [' + SS.name + '].[' + ST.name + ']' + CHAR(10) + 'After Update' + CHAR(10) + 'As Begin' + CHAR(10) + CHAR(9) + 'IF TRIGGER_NESTLEVEL() > 1' + CHAR(10) + CHAR(9) + CHAR(9) + 'Return' + CHAR(10) + CHAR(10) + CHAR(9) + 'INSERT INTO' + CHAR(10) + CHAR(9) + CHAR(9) + '[' + SS.name + '].[' + ST.name + '_Audit] (' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + '[AuditIsDelete]' from sys.tables ST INNER JOIN sys.schemas SS ON ST.schema_id = SS.schema_id WHERE ST.name = @TableName AND ST.type = 'U' AND SS.name = @SchemaName SELECT @CreateTableScript = @CreateTableScript + ',' + CHAR(10) + CHAR(9) + '[' + ISC.COLUMN_NAME + '] ' + ISC.DATA_TYPE + CASE WHEN ISC.CHARACTER_MAXIMUM_LENGTH IS NOT NULL AND ISC.DATA_TYPE <> 'xml' THEN '(' + CASE WHEN ISC.CHARACTER_MAXIMUM_LENGTH = -1 THEN 'MAX' ELSE CONVERT(varchar,ISC.CHARACTER_MAXIMUM_LENGTH) END + ')' ELSE '' END + ' NULL', @ColumnNamesSection = ISNULL(@ColumnNamesSection,'') + ',' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + '[' + ISC.COLUMN_NAME + ']' FROM INFORMATION_SCHEMA.COLUMNS ISC WHERE ISC.TABLE_NAME = @TableName AND ISC.TABLE_SCHEMA = @SchemaName ORDER BY ISC.ORDINAL_POSITION ASC SET @CreateTableScript = @CreateTableScript + CHAR(10) + ');' SET @CreateDeleteScript = @CreateDeleteScript + @ColumnNamesSection + CHAR(10) + CHAR(9) + CHAR(9) + ')' + CHAR(10) + CHAR(9) + CHAR(9) + 'SELECT' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + '1 as [AuditIsDelete]' + @ColumnNamesSection + CHAR(10) + CHAR(9) + CHAR(9) + 'FROM' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + 'deleted' + CHAR(10) + 'End;' SET @CreateUpdateScript = @CreateUpdateScript + @ColumnNamesSection + CHAR(10) + CHAR(9) + CHAR(9) + ')' + CHAR(10) + CHAR(9) + CHAR(9) + 'SELECT' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + '0 as [AuditIsDelete]' + @ColumnNamesSection + CHAR(10) + CHAR(9) + CHAR(9) + 'FROM' + CHAR(10) + CHAR(9) + CHAR(9) + CHAR(9) + 'deleted' + CHAR(10) + 'declare @SoftDelete bit, @Id int select @SoftDelete = case when i.DeletedDate is not null then 1 else 0 end, @Id = i.Id from inserted i; if @SoftDelete = 1 begin INSERT INTO [' + @SchemaName + '].[' + @TableName + '_Audit] ( [AuditIsDelete] ' + @ColumnNamesSection + ' ) SELECT 1 as [AuditIsDelete] ' + @ColumnNamesSection + ' FROM inserted delete from ' + @SchemaName + '.' + @TableName + ' where Id = @Id end;' + CHAR(10) + 'End;' --6) Print and Run Scripts BEGIN TRY BEGIN TRANSACTION; EXEC(@CreateTableScript); EXEC(@CreateDeleteScript); EXEC(@CreateUpdateScript); --Test Try Catch: --SELECT 1/0 COMMIT TRANSACTION; PRINT('The audit table was successfully created.') END TRY BEGIN CATCH ROLLBACK TRANSACTION; set @msg = 'db_name()=' + isnull( db_name(), 'NULL' ) + '; ERROR_MESSAGE()=' + isnull( ERROR_MESSAGE(), 'NULL' ) + '; ERROR_PROCEDURE()=' + isnull( ERROR_PROCEDURE(), 'NULL' ) + '; ERROR_LINE()=' + isnull( CONVERT( varchar(10), ERROR_LINE() ), 'NULL' ) + '; ERROR_NUMBER()=' + isnull( CONVERT( varchar(10), ERROR_NUMBER() ), 'NULL' ) + '; ERROR_SEVERITY()=' + isnull( CONVERT( varchar(10), ERROR_SEVERITY() ), 'NULL' ) + '; ERROR_STATE()=' + isnull( CONVERT( varchar(10), ERROR_STATE() ), 'NULL' ); PRINT(CHAR(10) + 'Create Audit Table Script:'); PRINT(@LineBreak); PRINT(@CreateTableScript); PRINT(@LineBreak); PRINT(CHAR(10) + 'Create Audit Delete Trigger Script:'); PRINT(@LineBreak); PRINT(@CreateDeleteScript); PRINT(@LineBreak); PRINT(CHAR(10) + 'Create Audit Update Trigger Script:'); PRINT(@LineBreak); PRINT(@CreateUpdateScript); PRINT(@LineBreak); raiserror ( @msg, 18, 1 ); END CATCH END; 

虽然触发器不理想,但它们实现了审核删除用户的目标,我不再需要担心软删除的记录。

就个人而言,我讨厌设计模式,即在表格中添加“IsDeleted”列。 原因很多。

  1. 该模式生成一个内部平台,您在数据库中有一个数据库。
  2. 访问内部数据库所需的自定义API( select * from table where IsDeleted = 0 )和( delete from table becomes update table set IsDeleted = 1
  3. 表中的额外数据会降低性能
  4. 额外数据对于审计目的无用,如果您想要审计,请正确执行。

您遇到的痛点是2. Custom API。 创建entity framework是为了对抗SQL数据库,而不是SQL数据库中存在的一些奇怪的数据存储。

我发现这个问题的解决方案是使用SQL Server Views。 MS SQL Server支持视图,您可以使用软删除对行进行过滤。 然后,我将在视图上添加TRIGGER INSTEAD OF INSERT,UPDATE, DELETE ,以将插入/更新/删除映射到数据库上的正确操作。

但是,当使用任何forms的抽象时,您会发现性能会降低。 在这种情况下,主要的权衡是SELECT 。 使用SQL Server Enterprise Edition,可以在视图上添加索引(并让SQL Server自动使用索引)来加速所有选择,但代价是写入访问。 这照顾了第3点。

至于第4点。我更喜欢使用IsDeleted列而不是IsDeleted列来使用以下模式…

  • ValidFrom DateTime NOT NULL
  • ValidTo DateTime NULL
  • EditedBy VARCHAR NOT NULL

创建新行时,将ValidFrom设置为UTCNOW() ,将EditedBy设置为CURRENTUSER() 。 更新行时,将旧行的ValidTo设置为UTCNOW()并创建具有正确值的新行。 删除时,将旧行的ValidTo设置为UTCNOW()

此架构允许您在任何时间点获得表的完整历史视图。 全面审计。 🙂