Filtruj wszystkie właściwości nawigacji zanim zostaną załadowane (leniwe lub chętne) do pamięci

Dla przyszłych odwiedzających: dla EF6 prawdopodobnie lepiej będzie używać filtrów, na przykład za pośrednictwem tego projektu: https://github.com/jbogard/EntityFramework.Filters

W budowanej przez nas aplikacji stosujemy wzór "soft delete", gdzie każda klasa ma' Deleted ' bool. W praktyce każda klasa po prostu dziedziczy z tej klasy bazowej:

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

    public virtual bool Deleted { get; set; }
}

Aby podać krótki przykład, załóżmy, że mam klasy GymMember i 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; }
}

Kiedy pobieram listę siłowni Członkowie z bazy danych, mogę upewnić się, że żaden z 'usuniętych' członków siłowni nie jest pobierany, jak to: {]}

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

Jednakże, kiedy iteruję przez tych członków siłowni, ich {[8] } są ładowane z bazy danych bez względu na ich Deleted flagę. Chociaż nie mogę winić Entity Framework za brak wychwytywania tego, chciałbym skonfigurować lub przechwycić leniwe Ładowanie właściwości tak, aby usunięte właściwości nawigacyjne nigdy nie były ładowane.

I ' ve been going through my opcje, ale wydają się rzadkie:

To po prostu nie jest opcja, ponieważ byłoby to zbyt wiele pracy ręcznej. (Nasza aplikacja jest ogromna i staje się coraz większa każdego dnia). Nie chcemy również rezygnować z zalet używania kodu jako pierwszego (których jest wiele)

Znowu nie ma takiej opcji. Ta konfiguracja jest dostępna tylko dla jednostki. Zawsze chętnie obciążające podmioty nakładałyby również poważną karę za wykonanie.
  • zastosowanie wzorca Visitor expression, który automatycznie wstrzykuje .Where(e => !e.Deleted) gdziekolwiek znajdzie IQueryable<Entity>, jak opisano tutaj i tutaj .
Przetestowałem to w aplikacji proof of concept i zadziałało cudownie. To była bardzo ciekawa opcja, ale niestety, nie stosuje filtrowania do leniwie załadowanych właściwości nawigacji. Jest to oczywiste, ponieważ te leniwe właściwości nie pojawią się w wyrażeniu/zapytaniu i jako takie nie mogą być zastąpione. Zastanawiam się, czy Entity Framework pozwoliłby na punkt wtrysku gdzieś w ich klasie DynamicProxy, która ładuje właściwości leniwe. Obawiam się również o inne konsekwencje, takie jak możliwość złamania mechanizmu Include w EF.
  • pisanie niestandardowej klasy, która implementuje ICollection, ale automatycznie filtruje encje Deleted.
To było moje pierwsze podejście. Ideą byłoby użycie właściwości pomocniczej dla każdej właściwości kolekcji, która wewnętrznie używa niestandardowej klasy kolekcji: {]}
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); }
     }

}
Chociaż to podejście nie jest złe, nadal mam z nim pewne problemy:]}
  • Nadal ładuje wszystkie Workout s do pamięci i filtruje Deleted te, gdy zostanie uderzony setter właściwości. Moim skromnym zdaniem jest to zbyt późno.

  • Istnieje logiczna rozbieżność między wykonanymi zapytaniami a załadowanymi danymi.

Wyobraź sobie scenariusz, w którym chcę listę członków siłowni, którzy ćwiczyli od zeszłego tygodnia:

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

To zapytanie może zwrócić członka siłowni, który ma tylko treningi, które są usuwane, ale również spełniają predykat. Po załadowaniu do pamięci, wydaje się, że ten członek siłowni nie ma treningów w ogóle! Można powiedzieć, że deweloper powinien mieć świadomość z Deleted i zawsze włączaj to do jego zapytań, ale to jest coś, czego naprawdę chciałbym uniknąć. Może ExpressionVisitor mógłby dać odpowiedź tutaj ponownie.

  • W rzeczywistości nie można oznaczyć właściwości nawigacji jako Deleted podczas korzystania z CustomCollection.

Wyobraź sobie ten scenariusz:

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

Spodziewałbyś się, że odpowiedni rekord Workout zostanie zaktualizowany w bazie danych, a myliłbyś się! Ponieważ {[21] } jest kontrolowany przez ChangeTracker w przypadku jakichkolwiek zmian właściwość gymMember.Workouts zwróci nagle o 1 mniej. To dlatego, że CustomCollection automatycznie filtruje usunięte instancje, pamiętasz? Więc teraz Entity Framework myśli, że trening musi zostać usunięty, a EF spróbuje ustawić FK na null, lub faktycznie usunąć rekord. (w zależności od konfiguracji DB). To jest to, czego staraliśmy się uniknąć za pomocą miękkiego wzoru delete na początek!!!

Natknąłem się na ciekawy blog post, który nadpisuje domyślna metoda SaveChanges DbContext tak, że wszelkie wpisy z EntityState.Deleted są zmieniane z powrotem na EntityState.Modified, Ale To znowu wydaje się "hakerskie" i raczej niebezpieczne. Jednak jestem skłonny go wypróbować, jeśli rozwiązuje problemy bez niezamierzonych skutków ubocznych.


Więc oto jestem StackOverflow. Zbadałem moje opcje dość obszernie, jeśli mogę tak powiedzieć, i jestem w moim rozum. Więc teraz zwracam się do ciebie. Jak zaimplementowałeś miękkie usuwanie w swojej aplikacji korporacyjnej?

To powtórzę, to są wymagania, których szukam:

  • zapytania powinny automatycznie wykluczać encje Deleted na poziomie DB
  • usunięcie encji i wywołanie "SaveChanges" powinno po prostu zaktualizować odpowiedni rekord i nie mieć innych skutków ubocznych.
  • gdy właściwości nawigacyjne są ładowane, leniwe lub chętne, Deleted powinny być automatycznie wykluczone.

Czekam na wszelkie sugestie, dziękuję w naprzód.

Author: Community, 2013-09-05

3 answers

Po wielu badaniach, w końcu znalazłem sposób, aby osiągnąć to, czego chciałem. Chodzi o to, że przechwytywam zmaterializowane byty za pomocą obsługi zdarzeń w kontekście obiektu, a następnie wprowadzam własną klasę collection do każdej właściwości kolekcji ,którą mogę znaleźć(z odbiciem).

Najważniejszą częścią jest przechwycenie "DbCollectionEntry", klasy odpowiedzialnej za ładowanie powiązanych właściwości kolekcji. Poruszając się pomiędzy istotą a DbCollectionEntry, ja zyskaj pełną kontrolę nad tym, co jest ładowane, kiedy i jak. Jedynym minusem jest to, że ta klasa DbCollectionEntry ma niewiele lub nie ma członków publicznych, co wymaga ode mnie użycia reflection, aby nią manipulować.

Oto moja klasa custom collection, która implementuje ICollection i zawiera odniesienie do odpowiedniego DbCollectionEntry:

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
}

Jeśli przejrzysz ją, przekonasz się, że najważniejszą częścią jest właściwość "Entities", która leniwie ładuje rzeczywiste wartości. W konstruktor FilteredCollection przekazuję opcjonalną ICollection dla scenariuszy, w których kolekcja jest już chętnie ładowana.

Oczywiście nadal musimy skonfigurować strukturę encji tak, aby nasz FilteredCollection był używany wszędzie tam, gdzie istnieją właściwości kolekcji. Można to osiągnąć poprzez podłączenie do zdarzenia Obiektmaterialized podstawowego Obiektkontekstu struktury podmiotu:

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

To wszystko wygląda dość skomplikowanie, ale to, co robi zasadniczo, to skanowanie Typ zmaterializowany dla właściwości kolekcji i zmień wartość na przefiltrowaną kolekcję. Przekazuje również DbCollectionEntry do filtrowanej kolekcji, dzięki czemu może działać magicznie.

Obejmuje to całą część "jednostki ładujące". Jak dotąd jedynym minusem jest to, że chętnie załadowane właściwości kolekcji nadal będą zawierać usunięte encje, ale są one filtrowane metodą 'Add' klasy FilterCollection. Jest to akceptowalny minus, chociaż muszę jeszcze wykonać kilka testów jak to wpływa na metodę SaveChanges ().

Oczywiście pozostaje jeszcze jeden problem: nie ma automatycznego filtrowania zapytań. Jeśli chcesz pobrać członków siłowni, którzy wykonali trening w ciągu ostatniego tygodnia, chcesz automatycznie wykluczyć usunięte treningi.

Uzyskuje się to za pomocą funkcji ExpressionVisitor, która automatycznie stosuje a'.Gdzie (e =>!e. Deleted) ' filtr do każdego IQueryable, który może znaleźć w danym wyrażeniu.

Oto kod:

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

Jestem trochę brakuje czasu, więc wrócę do tego posta później z więcej szczegółów, ale sedno jest zapisane i dla tych z was chętnych do wypróbowania wszystkiego; zamieściłem pełną aplikację testową tutaj: {24]} https://github.com/amoerie/TestingGround

Jednak nadal mogą wystąpić pewne błędy, ponieważ jest to bardzo dużo pracy w toku. Pomysł koncepcyjny jest jednak solidny i spodziewam się, że wkrótce będzie w pełni funkcjonował, gdy wszystko porządnie zrefakturuję i znajdę czas na napisz kilka testów do tego.

 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/doraprojects.net/template/agent.layouts/content.php on line 54
2013-09-13 13:56:34

Jednym z możliwych sposobów może być użycie specyfikacji z podstawową specyfikacją, która sprawdza miękką usuniętą flagę dla wszystkich zapytań wraz ze strategią include.

Zilustruję poprawioną wersję wzorca specyfikacji, który użyłem w projekcie (który miał swój początek w tym blogu)

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

IPredicateBuilder jest opakowaniem do konstruktora predykatów zawartego w LINQKit .dll .

Klasa podstawowa specyfikacji to odpowiedzialny za stworzenie kreatora predykatów. Po utworzeniu można dodać kryteria, które powinny być stosowane do wszystkich zapytań. Konstruktor predykatów może być następnie przekazywany do odziedziczonych specyfikacji w celu dodania kolejnych kryteriów. Na przykład:

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

Pełny predykat IdSpecification będzie wtedy:

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

Specyfikacja może być następnie przekazana do repozytorium, które używa właściwości PredicateBuilder do budowania klauzuli where:

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

AsExpandable() jest częścią LINQKit.dll.

W odniesieniu do właściwości including/lazy loading można rozszerzyć specyfikację o kolejną właściwość includes. Baza specyfikacji może dodawać podstawowe includes, a następnie specyfikacje potomne dodają swoje includes. Repozytorium może wtedy przed pobraniem Z db zastosować includes ze specyfikacji.

    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));
    } 
Daj mi znać, jeśli coś jest niejasne. Starałem się nie robić z tego postu potwora, więc niektóre szczegóły mogą zostać pominięte.

Edit: I zdałem sobie sprawę, że nie w pełni odpowiedziałem na twoje pytania; właściwości nawigacji. Co zrobić, jeśli właściwość nawigacyjna będzie wewnętrzna (używając tego postu, aby ją skonfigurować i tworząc niepomapowane publiczne właściwości, które są IQueryable. Nie mapowane właściwości mogą mieć niestandardowy atrybut, a repozytorium dodaje predykat specyfikacji bazowej do gdzie, bez ochoczego ładowania go. Gdy ktoś zastosuje chętną operację, zastosuje filtr. Coś w stylu:

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

I haven ' t testowałem powyższy kod, ale może zadziałać z pewnymi poprawkami:)

Edycja 2: Usuwa.

Jeśli używasz repozytorium general/generic, możesz po prostu dodać kilka dodatkowych funkcji do metody 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/doraprojects.net/template/agent.layouts/content.php on line 54
2013-09-07 21:45:57

Czy rozważałeś użycie widoków w swojej bazie danych, aby załadować problematyczne elementy z wykluczonymi usuniętymi elementami?

Oznacza to, że będziesz musiał użyć procedur składowanych, aby zmapować INSERT/UPDATE/DELETE funkcjonalność, ale zdecydowanie rozwiąże twój problem, Jeśli Workout mapuje do widoku z pominiętymi usuniętymi wierszami. Również - może to nie działać tak samo w pierwszym podejściu kodu...

 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/doraprojects.net/template/agent.layouts/content.php on line 54
2013-09-07 03:45:24