Filtrer toutes les propriétés de navigation avant de les charger (paresseux ou impatient) dans la mémoire

pour les futurs visiteurs: pour EF6 vous êtes probablement mieux à l'aide de filtres, par exemple via ce projet: https://github.com/jbogard/EntityFramework.Filters

dans l'application que nous construisons, nous appliquons le modèle "soft delete" où chaque classe a un bool "Deleted". En pratique, chaque classe hérite simplement de cette classe de base:

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

    public virtual bool Deleted { get; set; }
}

Pour donner un bref exemple, supposons que j'ai les classes GymMember et 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; }
}

quand je récupère la liste des membres de gym dans la base de données, je peux m'assurer qu'aucun des membres 'supprimés' de gym ne sont récupérés, comme ceci:

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

cependant, lorsque je répète à travers ces membres de gym, leur Workouts sont chargés à partir de la base de données sans aucune considération pour leur drapeau Deleted . Bien que je ne puisse pas blâmer le Framework Entity de ne pas répondre à cela, je voudrais configurer ou intercept lazy property loading de sorte que les propriétés de navigation supprimées ne soient jamais chargées.

je suis passé par mes options, mais elles semblent rares:

ce n'est tout simplement pas une option, car ce serait trop de travail manuel. (Notre application est énorme et obtenir plus énorme chaque jour). Nous ne voulons pas non plus renoncer aux avantages de L'Utilisation du Code en premier (dont il y a beaucoup)

encore une fois, pas une option. Cette configuration n'est disponible que par l'entité. Des entités de chargement toujours avides imposeraient également une lourde pénalité de performance.

  • application de la Expression visiteur pattern qui injecte automatiquement .Where(e => !e.Deleted) partout où il trouve un IQueryable<Entity> , comme décrit ici et ici .

en fait, j'ai testé cela dans une application de validation de principe, et cela a fonctionné merveilleusement. C'était une option très intéressante, mais hélas, elle ne parvient pas à appliquer le filtrage aux propriétés de navigation chargées paresseusement. C'est évident, car ces propriétés paresseuses n'apparaîtraient pas dans le expression / requête et en tant que tel ne peut pas être remplacé. Je me demande si Entity Framework pourrait permettre un point d'injection quelque part dans leur classe DynamicProxy qui charge les propriétés paresseuses. Je crains aussi pour d'autres conséquences, comme la possibilité de briser le mécanisme Include dans EF.

  • écrire une classe personnalisée qui implémente ICollection mais filtre les entités Deleted automatiquement.

Ce fut en fait, ma première approche. L'idée serait d'utiliser une propriété de support pour chaque propriété de collection qui utilise à l'interne une classe de Collection personnalisée:

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); }
     }

}

bien que cette approche ne soit pas mauvaise, j'ai encore quelques problèmes avec elle:

  • Il encore en charge toutes les Workout s dans la mémoire et les filtres de la Deleted lorsque la propriété setter est frappé. À mon humble avis, c'est beaucoup trop tard.

  • il y a un décalage logique entre les requêtes exécutées et les données chargées.

l'Image d'un scénario où je veux une liste des membres de gym qui fait une séance d'entraînement depuis la semaine dernière:

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

cette requête pourrait renvoyer un membre de gym qui n'a que des séances d'entraînement qui sont supprimés, mais aussi satisfaire le prédicat. Une fois qu'ils sont chargés en mémoire, il apparaît comme si cela membre de la salle de gym n'a pas d'entraînement du tout! On pourrait dire que le développeur doit connaître le Deleted et toujours l'inclure dans ses requêtes, mais c'est quelque chose que je voudrais vraiment éviter. Peut-être que le conseiller D'expression pourrait nous donner la réponse ici.

  • il est en fait impossible de marquer une propriété de navigation comme Deleted en utilisant la CustomCollection.

imaginez ce scénario:

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

vous vous attendez à ce que l'enregistrement approprié Workout soit mis à jour dans la base de données, et vous vous trompez! Puisque le gymMember est inspecté par le ChangeTracker pour tout changement, la propriété gymMember.Workouts retournera soudainement 1 séance d'entraînement de moins. C'est parce que CustomCollection filtre automatiquement les instances supprimées, vous vous souvenez? Donc maintenant, Entity Framework pense que l'entraînement doit être supprimé, et EF va essayer de mettre le FK à null, ou en fait supprimer l'enregistrement. (selon la configuration de votre base de données). C'est ce que nous essayons d'éviter le soft supprimer motif pour commencer!!!

j'ai trébuché sur un blog intéressant post qui remplace la méthode par défaut SaveChanges de la DbContext de sorte que toutes les entrées avec un EntityState.Deleted sont changées en EntityState.Modified mais cela se sent de nouveau 'hacky' et plutôt dangereux. Cependant, je suis prêt à essayer de résoudre les problèmes sans effets secondaires involontaires.


donc me voici StackOverflow. J'ai fait des recherches assez approfondies, si je peux me permettre, et je suis à bout de nerfs. Alors maintenant, je me tourne vers vous. Comment avez-vous implémenté les suppressions soft dans votre application d'entreprise?

pour réitérer, ce sont les exigences que je recherche:

  • les requêtes devraient exclure automatiquement les entités Deleted le niveau DB
  • supprimer une entité et appeler 'SaveChanges' devrait simplement mettre à jour l'enregistrement approprié et n'avoir aucun autre effet secondaire.
  • lorsque les propriétés de navigation sont chargées, qu'elles soient paresseuses ou pressantes, les propriétés Deleted devraient être automatiquement exclues.

je suis impatient de toutes les suggestions, merci à l'avance.

26
demandé sur Community 2013-09-05 01:15:58

3 réponses

après de nombreuses recherches, j'ai enfin trouvé un moyen de réaliser ce que je voulais. L'essentiel est que j'intercepte des entités matérialisées avec un gestionnaire d'événements dans le contexte de l'objet, puis j'injecte ma classe custom collection dans chaque propriété de collection que je peux trouver (avec réflexion).

la partie la plus importante est l'interception du" DbCollectionEntry", la classe responsable du chargement des propriétés de collecte liées. En me tortillant entre l'entité et la collecte DbCollectionEntry, je prends le contrôle total sur ce qui est chargé quand et comment. Le seul inconvénient est que cette classe de collecte de DbCollectionEntry a peu ou pas de membres du public, ce qui m'oblige à utiliser la réflexion pour la manipuler.

voici ma classe de collection personnalisée qui implémente ICollection et contient une référence à L'entry Dbcollection approprié:

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 vous skim à travers elle, vous trouverez que la partie la plus importante est les " entités" propriété, qui chargera paresseusement les valeurs réelles. Dans le constructeur du FilteredCollection je passe une ICollection optionnelle pour scenario où la collection est déjà chargée avec impatience.

bien sûr, nous avons encore besoin de configurer le Framework Entity pour que notre collecteur FilteredCollection soit utilisé partout où il y a des propriétés de collecte. On peut y parvenir en se raccordant à L'événement Objectmatérialisé de L'Objectecontexte sous-jacent du cadre de L'entité:

(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 });
            }
        }
    };

tout cela semble assez compliqué, mais ce qu'il fait essentiellement est de scanner le type matérialisé pour les propriétés de collecte et de changer la valeur à une collecte filtrée. Il passe également le DbCollectionEntry à la collection filtrée de sorte qu'il peut travailler sa magie.

couvre l'ensemble de la partie "entités de chargement". Le seul inconvénient jusqu'à présent est que les propriétés de collection chargées avec enthousiasme incluront toujours les entités supprimées, mais elles sont filtrées dans la méthode " Add " de la classe FilterCollection. C'est un inconvénient acceptable, bien que je n'ai pas encore fait quelques tests sur la façon dont cela affecte la méthode SaveChanges ().

bien sûr, il reste un problème: il n'y a pas de filtrage automatique des requêtes. Si vous voulez récupérer les membres de la salle de gym qui ont fait une séance d'entraînement au cours de la dernière semaine, vous voulez exclure les séances d'entraînement supprimées automatiquement.

cela est réalisé par un viseur D'expression qui automatiquement applique la".Où (e=>!e.Supprimée) " filtre à chaque IQueryable, il peut se trouver dans une expression donnée.

voici le code:

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);
    }
}

je suis un peu à court sur le temps, donc je vais revenir à ce post plus tard avec plus de détails, mais l'essentiel de celui-ci est écrit et pour ceux d'entre vous désireux de tout essayer; j'ai posté l'application d'essai complète ici: https://github.com/amoerie/TestingGround

cependant, il pourrait y avoir encore quelques erreurs, car il s'agit d'un travail en cours. L'idée conceptuelle est bonne cependant, et je m'attends à ce qu'elle fonctionne pleinement bientôt une fois que j'aurai tout soigneusement remanié et trouver le temps d'écrire quelques tests pour cela.

9
répondu Moeri 2013-09-13 13:56:34

une possibilité serait d'utiliser des spécifications avec des spécifications de base qui vérifient le drapeau "soft deleted" pour toutes les requêtes ainsi qu'une stratégie d'inclusion.

je vais illustrer une version ajustée du modèle de spécification que j'ai utilisé dans un projet (qui a eu son origine dans ce blog post )

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;
    }
}

La IPredicateBuilder est un wrapper pour le prédicat builder inclus dans le LINQKit.dll .

la classe de base de spécification est responsable de créer le constructeur de prédicat. Une fois créé, les critères qui doivent être appliqués à toute requête peuvent être ajoutés. Le constructeur de prédicat peut alors être passé aux spécifications héritées pour ajouter d'autres critères. Par exemple:

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);
    }
}

La IdSpecification complet du prédicat serait alors:

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

la spécification peut alors être passé au dépôt qui utilise la propriété PredicateBuilder pour construire la clause où:

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

AsExpandable() fait partie du kit de LINQ.DLL.

en ce qui concerne l'inclusion/les propriétés de chargement paresseux on peut étendre la spécification avec une autre propriété au sujet des includes. La base de spécification peut ajouter la base inclut Et puis les spécifications enfant ajoutent leurs inclusions. Le dépôt peut alors avant de récupérer à partir du db appliquer les inclusions à partir du spécification.

    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));
    } 

faites-moi savoir si quelque chose n'est pas clair. J'ai essayé de ne pas en faire un monster post donc certains détails pourraient être omis.

Edit: je me suis rendu compte que je ne répondais pas entièrement à vos questions; propriétés de navigation. Que faire si vous rendez la propriété de navigation interne (en utilisant ce post pour la configurer et en créant des propriétés publiques non-mappées qui sont IQueryable. Les propriétés non mappées peuvent avoir l'attribut personnalisé et le dépôt ajoute le prédicat de la spécification de base à l'endroit, sans le charger avec empressement. Quand quelqu'un applique une opération impatiente le filtre s'appliquera. Quelque chose comme:

    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;
    }

Je n'ai pas testé le code ci-dessus mais il pourrait fonctionner avec quelques retouches :)

Edit 2: Deletes.

si vous utilisez un dépôt général / Générique, vous pouvez simplement ajouter quelques fonctionnalités supplémentaires à la suppression méthode:

    public void Delete(T entity)
    {
        var castedEntity = entity as Entity;
        if (castedEntity != null)
        {
            castedEntity.IsDeleted = true;
        }
        else
        {
            _context.Remove(entity);
        }            
    }
1
répondu The Heatherleaf 2013-09-07 21:45:57

avez-vous envisagé d'utiliser des vues dans votre base de données pour charger vos entités problématiques avec les éléments supprimés exclus?

cela signifie que vous aurez besoin d'utiliser des procédures stockées pour cartographier INSERT / UPDATE / DELETE fonctionnalité, mais il serait certainement résoudre votre problème si Workout correspond à une vue avec les lignes supprimées omis. En outre - cela peut ne pas fonctionner le même dans une première approche de code...

0
répondu Matthew 2013-09-07 03:45:24