Filtrar todas las propiedades de navegación antes de cargarlas (perezosas o ansiosas) en la memoria


Para futuros visitantes: para EF6 probablemente sea mejor usar filtros, por ejemplo a través de este proyecto: https://github.com/jbogard/EntityFramework.Filters

En la aplicación que estamos construyendo aplicamos el patrón "soft delete" donde cada clase tiene un bool 'Deleted'. 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 gimnasio miembros de la base de datos, puedo asegurarme de que ninguno de los miembros del gimnasio 'eliminados' se recuperen, así:

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

Sin embargo, cuando itero a través de estos miembros del gimnasio, su Workouts se cargan desde la base de datos sin tener en cuenta su bandera Deleted. Si bien no puedo culpar a Entity Framework por no darse cuenta de esto, me gustaría configurar o interceptar la carga de propiedades perezosas de alguna manera para que las propiedades de navegación eliminadas nunca se carguen.

He estado pasando por mi 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 vez más grande cada día). Tampoco queremos renunciar a las ventajas de usar Código Primero (de las cuales hay muchas)

De nuevo, no es una opción. Esta configuración solo está disponible por entidad. Las entidades que siempre cargan con entusiasmo también impondrían una grave penalización por rendimiento.

  • Aplicando la Expresión Visitor pattern que inyecta automáticamente .Where(e => !e.Deleted) en cualquier lugar que encuentre un IQueryable<Entity>, como se describe aquíy aquí.

Realmente probé esto en una aplicación de prueba de concepto, y funcionó maravillosamente. Esta fue una opción muy interesante, pero, no puede aplicar el filtrado a las propiedades de navegación cargadas perezosamente. Esto es obvio, ya que esas propiedades perezosas no aparecen en la expresión/consulta y, como tales, 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 cargue las propiedades perezosas. También temo por otras consecuencias, como la posibilidad de romper el mecanismo Include en EF.

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

Este fue en realidad mi primer acercamiento. La idea sería usar una propiedad de respaldo para cada propiedad de colección que utiliza internamente 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 en realidad no es malo, todavía tengo algunos problemas con él:

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

  • Hay un desajuste lógico entre las consultas ejecutadas y los datos que se cargan.

Imagine un escenario donde 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 también satisfacen el predicado. Una vez que se cargan en la memoria, parece como si este miembro del gimnasio no tiene entrenamientos en absoluto! Se podría decir que el desarrollador debe ser consciente de la Deleted y siempre incluirlo en sus consultas, pero eso es algo que realmente me gustaría evitar. Tal vez el visitador Expresion podría ofrecer la respuesta aquí de nuevo.

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

Imagine este escenario:

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

Usted esperaría que el registro apropiado Workout se actualice en la base de datos, ¡y usted estaría equivocado! Puesto que el gymMember está siendo inspeccionado por el ChangeTracker para cualquier cambio, la propiedad gymMember.Workouts devolverá repentinamente 1 entrenamiento menos. Esto se debe a que CustomCollection filtra automáticamente las instancias eliminadas, ¿recuerdas? Así que ahora Entity Framework piensa que el entrenamiento necesita ser eliminado, y EF intentará establecer el FK en null, o en realidad eliminar el registro. (dependiendo de cómo esté configurada su base de datos). Esto es lo que estábamos tratando de evitar con el patrón de borrado suave para empezar!!!

Me topé con un interesante blog post que anula el método predeterminado SaveChanges del 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 extremo ingenio. Así que ahora me dirijo a ti. ¿Cómo ha implementado soft deletes en su aplicación empresarial?

A reitero, estos son los requisitos que estoy buscando:

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

Estoy esperando cualquier y todas las sugerencias, gracias en avance.

Author: Community, 2013-09-05

3 answers

Después de mucha investigación, finalmente he encontrado 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 la "DbCollectionEntry", la clase responsable de cargar propiedades de colección relacionadas. Al moverme entre la entidad y el DbCollectionEntry, me obtenga un control total sobre lo que se carga, 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 reflexión para manipularlo.

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

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 hojea, encontrará que la parte más importante es la propiedad "Entities", que cargará perezosamente los valores reales. En el constructor de FilteredCollection Paso una ICollection opcional para los 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 todas partes donde haya propiedades de colección. Esto se puede lograr conectándose al 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 para las propiedades de la colección y cambiar el valor a una colección filtrada. También pasa el 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 aún incluirán las entidades eliminadas, pero se filtran en el método 'Add' de la clase FilterCollection. Este es un inconveniente aceptable, aunque todavía tengo que hacer algunas pruebas sobre cómo esto afecta 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 en la semana pasada, desea excluir los entrenamientos eliminados automáticamente.

Esto se logra a través de un Expresionvisitor que aplica automáticamente un '.Donde(e => !e. Deleted)' filtrar a cada IQueryable que puede 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);
    }
}

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

Sin embargo, todavía puede haber algunos errores, ya que esto es mucho un trabajo en progreso. Sin embargo, la idea conceptual es sólida, y espero que funcione completamente pronto una vez que haya refactorizado todo cuidadosamente y encuentre el tiempo para escribe algunas pruebas para esto.

 9
Author: Moeri,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-09-13 13:56:34

Una forma posible podría ser usar especificaciones con una especificación base que verifique el indicador soft deleted para todas las consultas junto con una estrategia de inclusión.

Ilustraré una versión ajustada del patrón de especificación que he utilizado en un proyecto (que tuvo su origen en esta entrada 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 contenedor para el constructor de predicados incluido en el LinqKit.dll .

La clase base de la especificación es responsable de crear el constructor de predicados. Una vez creado se pueden agregar los criterios que se deben aplicar a todas las consultas. 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 IdSpecification sería entonces:

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

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

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

AsExpandable() es parte de la LinqKit.DLL.

En lo que respecta a las propiedades de inclusión/lazy loading, se puede extender la especificación con una propiedad adicional sobre includes. La base de especificación puede agregar la base includes y luego las especificaciones secundarias agregan sus includes. El repositorio puede entonces antes de buscar desde la base de datos aplicar los includes de 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));
    } 

Hazme saber si algo no está claro. Traté de no hacer de este un post monstruo por lo que algunos detalles podrían quedar fuera.

Editar: I me di cuenta de que no respondí completamente a su(s) pregunta (s); propiedades de navegación. Qué pasa si hace que la propiedad de navegación sea interna (usando este post para configurarla y creando propiedades públicas no mapeadas que son IQueryables. Las propiedades no mapeadas pueden tener un atributo personalizado y el repositorio agrega el predicado de la especificación base al lugar, sin cargarlo ansiosamente. Cuando alguien aplique una operación ansiosa, 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:)

Edición 2: Elimina.

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

    public void Delete(T entity)
    {
        var castedEntity = entity as Entity;
        if (castedEntity != null)
        {
            castedEntity.IsDeleted = true;
        }
        else
        {
            _context.Remove(entity);
        }            
    }
 1
Author: The Heatherleaf,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-09-07 21:45:57

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

Esto significa que necesitará usar procedimientos almacenados para mapear INSERT/UPDATE/DELETE funcionalidad, pero definitivamente resolvería su problema si Workout se asigna a una vista con las filas eliminadas omitidas. También-esto puede no funcionar de la misma manera en un primer enfoque de código...

 0
Author: Matthew,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-09-07 03:45:24