c# ef-code-first entity-framework-5 soft-delete

c# - Filtre todas las propiedades de navegación antes de que se carguen(perezosas o ansiosas) en la memoria



ef-code-first entity-framework-5 (3)

Para futuros visitantes: para EF6 es probable que esté mejor usando filtros, por ejemplo a través de este proyecto: https://github.com/jbogard/EntityFramework.Filters

En la aplicación que estamos creando, aplicamos el patrón de "eliminación suave" donde cada clase tiene un bool ''Eliminado''. En la práctica, cada clase simplemente hereda de esta clase base:

public abstract class Entity { public virtual int Id { get; set; } public virtual bool Deleted { get; set; } }

Para dar un breve ejemplo, supongamos que tengo las clases GymMember y Workout :

public class GymMember: Entity { public string Name { get; set; } public virtual ICollection<Workout> Workouts { get; set; } } public class Workout: Entity { public virtual DateTime Date { get; set; } }

Cuando busco la lista de miembros del gimnasio de la base de datos, puedo asegurarme de que ninguno de los miembros del gimnasio ''eliminados'' sea recuperado, como este

var gymMembers = context.GymMembers.Where(g => !g.Deleted);

Sin embargo, cuando recorro estos miembros del gimnasio, sus Workouts se cargan desde la base de datos sin tener en cuenta su Deleted . Si bien no puedo culpar a Entity Framework por no aceptar esto, me gustaría configurar o interceptar la carga perezosa de propiedades para que las propiedades de navegación eliminadas nunca se carguen.

He estado revisando mis opciones, pero parecen escasas:

Esto simplemente no es una opción, ya que sería demasiado trabajo manual. (Nuestra aplicación es enorme y cada día es más enorme). Tampoco queremos renunciar a las ventajas de usar Code First (de los cuales hay muchos)

De nuevo, no es una opción. Esta configuración solo está disponible por entidad. La carga siempre activa de entidades también impondría una penalización de rendimiento grave.

  • Aplicando el patrón de Expression Visitor que inyecta automáticamente .Where(e => !e.Deleted) cualquier lugar donde encuentre una IQueryable<Entity> .Where(e => !e.Deleted) , como se describe here y here .

De hecho, probé esto en una aplicación de prueba de concepto, y funcionó maravillosamente. Esta fue una opción muy interesante, pero, por desgracia, no aplica el filtrado a las propiedades de navegación cargadas perezosamente. Esto es obvio, ya que esas propiedades perezosas no aparecerían en la expresión / consulta y, por lo tanto, no se pueden reemplazar. Me pregunto si Entity Framework permitiría un punto de inyección en algún lugar de su clase DynamicProxy que carga las propiedades perezosas. También temo por otras consecuencias, como la posibilidad de romper el mecanismo de Include en EF.

  • Escribir una clase personalizada que implementa ICollection pero filtra las entidades Deleted automáticamente.

Este fue en realidad mi primer enfoque. La idea sería usar una propiedad de respaldo para cada propiedad de colección que internamente use una clase de Colección personalizada:

public class GymMember: Entity { public string Name { get; set; } private ICollection<Workout> _workouts; public virtual ICollection<Workout> Workouts { get { return _workouts ?? (_workouts = new CustomCollection()); } set { _workouts = new CustomCollection(value); } } }

Si bien este enfoque no es realmente malo, todavía tengo algunos problemas con él:

  • Todavía carga todos los Workout en la memoria y filtra los Deleted cuando se golpea el establecedor de propiedades. En mi humilde opinión, esto es demasiado tarde.

  • Existe una discrepancia lógica entre las consultas ejecutadas y los datos que se cargan.

Imagen de un escenario en el que quiero una lista de los miembros del gimnasio que hicieron un entrenamiento desde la semana pasada:

var gymMembers = context.GymMembers.Where(g => g.Workouts.Any(w => w.Date >= DateTime.Now.AddDays(-7).Date));

Esta consulta puede devolver a un miembro del gimnasio que solo tiene entrenamientos que se eliminan pero que también satisfacen el predicado. Una vez que se cargan en la memoria, ¡parece que este miembro del gimnasio no tiene ningún entrenamiento en absoluto! Podría decir que el desarrollador debe estar al tanto de lo Deleted y siempre incluirlo en sus consultas, pero eso es algo que realmente me gustaría evitar. Tal vez el ExpressionVisitor podría ofrecer la respuesta aquí de nuevo.

  • En realidad, es imposible marcar una propiedad de navegación como Deleted cuando se usa CustomCollection.

Imagina este escenario:

var gymMember = context.GymMembers.First(); gymMember.Workouts.First().Deleted = true; context.SaveChanges();`

Usted esperaría que el registro de Workout apropiado se actualice en la base de datos, ¡y estaría equivocado! Dado que el gymMember está inspeccionando al ChangeTracker por cualquier cambio, la propiedad gymMember.Workouts volverá repentinamente a 1 entrenamiento menos. Esto se debe a que CustomCollection filtra automáticamente las instancias eliminadas, ¿recuerdas? Así que ahora Entity Framework cree que el entrenamiento debe eliminarse, y EF intentará establecer el FK en nulo, o eliminar el registro. (Dependiendo de cómo esté configurado su DB). ¡Esto es lo que intentábamos evitar con el patrón de eliminación suave para empezar!

Me topé con una interesante publicación de blog que anula el método predeterminado de SaveChanges de DbContext para que cualquier entrada con un EntityState.Deleted se cambie de nuevo a EntityState.Modified pero esto nuevamente se siente ''hacky'' y bastante inseguro. Sin embargo, estoy dispuesto a probarlo si resuelve problemas sin efectos secundarios no deseados.

Así que aquí estoy StackOverflow. He investigado mis opciones bastante extensamente, si puedo decirlo yo mismo, y estoy en mi punto final. Así que ahora me dirijo a ti. ¿Cómo ha implementado las eliminaciones de software en su aplicación empresarial?

Para reiterar, estos son los requisitos que estoy buscando:

  • Las consultas deben excluir automáticamente las entidades Deleted en el nivel de base de datos
  • Eliminar una entidad y llamar a ''SaveChanges'' debería simplemente actualizar el registro apropiado y no tener otros efectos secundarios.
  • Cuando se cargan las propiedades de navegación, ya sean perezosas o ansiosas, las Deleted deben excluirse automáticamente.

Espero con interés cualquier y todas las sugerencias, gracias de antemano.


¿Ha considerado usar vistas en su base de datos para cargar sus entidades problemáticas con los elementos eliminados excluidos?

Significa que necesitará usar procedimientos almacenados para asignar la funcionalidad INSERT / UPDATE / DELETE , pero definitivamente resolvería su problema si Workout asigna a una Vista con las filas eliminadas omitidas. Además, esto puede no funcionar de la misma manera en un primer enfoque de código ...


Después de mucha investigación, finalmente encontré una manera de lograr lo que quería. La esencia de esto es que intercepto entidades materializadas con un controlador de eventos en el contexto del objeto, y luego inyecto mi clase de colección personalizada en cada propiedad de colección que puedo encontrar (con reflexión).

La parte más importante es interceptar "DbCollectionEntry", la clase responsable de cargar las propiedades de colección relacionadas. Al moverme entre la entidad y DbCollectionEntry, gano el control total sobre lo que está cargado, cuándo y cómo. El único inconveniente es que esta clase DbCollectionEntry tiene poco o ningún miembro público, lo que requiere que use la reflexión para manipularla.

Aquí está mi clase de colección personalizada que implementa ICollection y contiene una referencia a la DbCollectionEntry apropiada:

public class FilteredCollection <TEntity> : ICollection<TEntity> where TEntity : Entity { private readonly DbCollectionEntry _dbCollectionEntry; private readonly Func<TEntity, Boolean> _compiledFilter; private readonly Expression<Func<TEntity, Boolean>> _filter; private ICollection<TEntity> _collection; private int? _cachedCount; public FilteredCollection(ICollection<TEntity> collection, DbCollectionEntry dbCollectionEntry) { _filter = entity => !entity.Deleted; _dbCollectionEntry = dbCollectionEntry; _compiledFilter = _filter.Compile(); _collection = collection != null ? collection.Where(_compiledFilter).ToList() : null; } private ICollection<TEntity> Entities { get { if (_dbCollectionEntry.IsLoaded == false && _collection == null) { IQueryable<TEntity> query = _dbCollectionEntry.Query().Cast<TEntity>().Where(_filter); _dbCollectionEntry.CurrentValue = this; _collection = query.ToList(); object internalCollectionEntry = _dbCollectionEntry.GetType() .GetField("_internalCollectionEntry", BindingFlags.NonPublic | BindingFlags.Instance) .GetValue(_dbCollectionEntry); object relatedEnd = internalCollectionEntry.GetType() .BaseType.GetField("_relatedEnd", BindingFlags.NonPublic | BindingFlags.Instance) .GetValue(internalCollectionEntry); relatedEnd.GetType() .GetField("_isLoaded", BindingFlags.NonPublic | BindingFlags.Instance) .SetValue(relatedEnd, true); } return _collection; } } #region ICollection<T> Members void ICollection<TEntity>.Add(TEntity item) { if(_compiledFilter(item)) Entities.Add(item); } void ICollection<TEntity>.Clear() { Entities.Clear(); } Boolean ICollection<TEntity>.Contains(TEntity item) { return Entities.Contains(item); } void ICollection<TEntity>.CopyTo(TEntity[] array, Int32 arrayIndex) { Entities.CopyTo(array, arrayIndex); } Int32 ICollection<TEntity>.Count { get { if (_dbCollectionEntry.IsLoaded) return _collection.Count; return _dbCollectionEntry.Query().Cast<TEntity>().Count(_filter); } } Boolean ICollection<TEntity>.IsReadOnly { get { return Entities.IsReadOnly; } } Boolean ICollection<TEntity>.Remove(TEntity item) { return Entities.Remove(item); } #endregion #region IEnumerable<T> Members IEnumerator<TEntity> IEnumerable<TEntity>.GetEnumerator() { return Entities.GetEnumerator(); } #endregion #region IEnumerable Members IEnumerator IEnumerable.GetEnumerator() { return ( ( this as IEnumerable<TEntity> ).GetEnumerator() ); } #endregion }

Si lo hojeas, verás que la parte más importante es la propiedad "Entidades", que cargará perezosamente los valores reales. En el constructor de FilteredCollection, paso una ICollection opcional para escenarios donde la colección ya está cargada con entusiasmo.

Por supuesto, todavía necesitamos configurar Entity Framework para que nuestra FilteredCollection se use en todos los lugares donde haya propiedades de colección. Esto se puede lograr conectando el evento ObjectMaterialized del ObjectContext subyacente de Entity Framework:

(this as IObjectContextAdapter).ObjectContext.ObjectMaterialized += delegate(Object sender, ObjectMaterializedEventArgs e) { if (e.Entity is Entity) { var entityType = e.Entity.GetType(); IEnumerable<PropertyInfo> collectionProperties; if (!CollectionPropertiesPerType.TryGetValue(entityType, out collectionProperties)) { CollectionPropertiesPerType[entityType] = (collectionProperties = entityType.GetProperties() .Where(p => p.PropertyType.IsGenericType && typeof(ICollection<>) == p.PropertyType.GetGenericTypeDefinition())); } foreach (var collectionProperty in collectionProperties) { var collectionType = typeof(FilteredCollection<>).MakeGenericType(collectionProperty.PropertyType.GetGenericArguments()); DbCollectionEntry dbCollectionEntry = Entry(e.Entity).Collection(collectionProperty.Name); dbCollectionEntry.CurrentValue = Activator.CreateInstance(collectionType, new[] { dbCollectionEntry.CurrentValue, dbCollectionEntry }); } } };

Todo parece bastante complicado, pero lo que hace esencialmente es escanear el tipo materializado en busca de propiedades de colección y cambiar el valor a una colección filtrada. También pasa la DbCollectionEntry a la colección filtrada para que pueda hacer su magia.

Esto cubre toda la parte de ''entidades de carga''. El único inconveniente hasta ahora es que las propiedades de colección cargadas con entusiasmo seguirán incluyendo las entidades eliminadas, pero se filtran en el método ''Agregar'' de la clase FilterCollection. Este es un inconveniente aceptable, aunque todavía tengo que hacer algunas pruebas sobre cómo afecta esto al método SaveChanges ().

Por supuesto, esto todavía deja un problema: no hay filtrado automático en las consultas. Si desea buscar a los miembros del gimnasio que hicieron un entrenamiento la semana pasada, desea excluir los entrenamientos eliminados automáticamente.

Esto se logra a través de un ExpressionVisitor que aplica automáticamente un filtro ''.Where (e =>! E.Deleted)'' a cada IQueryable que pueda encontrar en una expresión dada.

Aquí está el código:

public class DeletedFilterInterceptor: ExpressionVisitor { public Expression<Func<Entity, bool>> Filter { get; set; } public DeletedFilterInterceptor() { Filter = entity => !entity.Deleted; } protected override Expression VisitMember(MemberExpression ex) { return !ex.Type.IsGenericType ? base.VisitMember(ex) : CreateWhereExpression(Filter, ex) ?? base.VisitMember(ex); } private Expression CreateWhereExpression(Expression<Func<Entity, bool>> filter, Expression ex) { var type = ex.Type;//.GetGenericArguments().First(); var test = CreateExpression(filter, 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); } private LambdaExpression CreateExpression(Expression<Func<Entity, bool>> condition, Type type) { var lambda = (LambdaExpression) condition; if (!typeof(Entity).IsAssignableFrom(type)) return null; var newParams = new[] { Expression.Parameter(type, "entity") }; 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; } } public class ParameterRebinder : ExpressionVisitor { private readonly Dictionary<ParameterExpression, ParameterExpression> _map; public ParameterRebinder(Dictionary<ParameterExpression, ParameterExpression> map) { _map = map ?? new Dictionary<ParameterExpression, ParameterExpression>(); } public static Expression ReplaceParameters(Dictionary<ParameterExpression, ParameterExpression> 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); } }

Me estoy quedando un poco corto de tiempo, así que volveré a este post más adelante con más detalles, pero la esencia de esto está escrita y para aquellos de ustedes ansiosos por probar todo; He publicado la aplicación de prueba completa aquí: https://github.com/amoerie/TestingGround

Sin embargo, aún puede haber algunos errores, ya que esto es un trabajo en progreso. Sin embargo, la idea conceptual es sólida, y espero que funcione completamente pronto, una vez que haya reformulado todo perfectamente y encuentre el tiempo para escribir algunas pruebas para esto.


Una posible manera podría ser usar especificaciones con una especificación básica que verifique el indicador de eliminación suave para todas las consultas junto con una estrategia de inclusión.

Ilustraré una versión ajustada del patrón de especificación que he usado en un proyecto (que tuvo su origen en esta publicación de blog )

public abstract class SpecificationBase<T> : ISpecification<T> where T : Entity { private readonly IPredicateBuilderFactory _builderFactory; private IPredicateBuilder<T> _predicateBuilder; protected SpecificationBase(IPredicateBuilderFactory builderFactory) { _builderFactory = builderFactory; } public IPredicateBuilder<T> PredicateBuilder { get { return _predicateBuilder ?? (_predicateBuilder = BuildPredicate()); } } protected abstract void AddSatisfactionCriterion(IPredicateBuilder<T> predicateBuilder); private IPredicateBuilder<T> BuildPredicate() { var predicateBuilder = _builderFactory.Make<T>(); predicateBuilder.Check(candidate => !candidate.IsDeleted) AddSatisfactionCriterion(predicateBuilder); return predicateBuilder; } }

El IPredicateBuilder es un envoltorio para el generador de predicados incluido en el LINQKit.dll .

La clase base de especificación es responsable de crear el generador de predicados. Una vez creados los criterios que deben aplicarse a todas las consultas se pueden agregar. El generador de predicados se puede pasar a las especificaciones heredadas para agregar criterios adicionales. Por ejemplo:

public class IdSpecification<T> : SpecificationBase<T> where T : Entity { private readonly int _id; public IdSpecification(int id, IPredicateBuilderFactory builderFactory) : base(builderFactory) { _id = id; } protected override void AddSatisfactionCriterion(IPredicateBuilder<T> predicateBuilder) { predicateBuilder.And(entity => entity.Id == _id); } }

El predicado completo de la IdSpecification sería entonces:

entity => !entity.IsDeleted && entity.Id == _id

La especificación se puede pasar al repositorio que utiliza la propiedad PredicateBuilder para crear la cláusula where:

public IQueryable<T> FindAll(ISpecification<T> spec) { return context.AsExpandable().Where(spec.PredicateBuilder.Complete()).AsQueryable(); }

AsExpandable() es parte de LINQKit.dll.

En lo que respecta a las propiedades de inclusión / carga lenta, se puede ampliar la especificación con una propiedad adicional que incluye. La base de especificaciones puede agregar las bases incluidas y luego las especificaciones secundarias agregar sus inclusiones. El repositorio puede entonces, antes de obtener desde la base de datos, aplicar los incluidos en la especificación.

public IQueryable<T> Apply<T>(IDbSet<T> context, ISpecification<T> specification) { if (specification.IncludePaths == null) return context; return specification.IncludePaths.Aggregate<string, IQueryable<T>>(context, (current, path) => current.Include(path)); }

Déjame saber si algo no está claro. Intenté no hacer de esto una publicación monstruosa, por lo que algunos detalles podrían quedar fuera.

Edit: Me di cuenta de que no respondía completamente a tu (s) pregunta (s); Propiedades de navegación. ¿Qué sucede si hace que la propiedad de navegación sea interna (utilizando esta publicación para configurarlo y creando propiedades públicas no asignadas que son IQueryable? Las propiedades no asignadas pueden tener un atributo personalizado y el repositorio agrega el predicado de la especificación de base al lugar, sin cargar con entusiasmo Cuando alguien aplique una operación impaciente, se aplicará el filtro. Algo como:

public T Find(int id) { var entity = Context.SingleOrDefault(x => x.Id == id); if (entity != null) { foreach(var property in entity.GetType() .GetProperties() .Where(info => info.CustomAttributes.OfType<FilteredNavigationProperty>().Any())) { var collection = (property.GetValue(property) as IQueryable<IEntity>); collection = collection.Where(spec.PredicateBuilder.Complete()); } } return entity; }

No he probado el código anterior pero podría funcionar con algunos ajustes :)

Editar 2: Borra.

Si está utilizando un repositorio general / genérico, simplemente podría agregar alguna funcionalidad adicional al método de eliminación:

public void Delete(T entity) { var castedEntity = entity as Entity; if (castedEntity != null) { castedEntity.IsDeleted = true; } else { _context.Remove(entity); } }