Подтвердить что ты не робот

Отфильтруйте все свойства навигации до того, как они будут загружены (ленивы или нетерпеливы) в память

Для будущих посетителей: для EF6 вам, вероятно, лучше использовать фильтры, например, через этот проект: https://github.com/jbogard/EntityFramework.Filters

В приложении, которое мы создаем, мы применяем шаблон "soft delete", в котором каждый класс имеет "Deleted" bool. На практике каждый класс просто наследует этот базовый класс:

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

    public virtual bool Deleted { get; set; }
}

Чтобы дать краткий пример, предположим, что у меня есть классы GymMember и 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; }
}

Когда я получаю список членов тренажерного зала из базы данных, я могу убедиться, что ни один из "удаленных" членов тренажерного зала не будет выбран, например:

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

Однако, когда я повторяю эти члены тренажерного зала, их Workouts загружаются из базы данных, не обращая внимания на их флаг Deleted. Хотя я не могу обвинять Entity Framework в том, что вы не собираете этого, я бы хотел настроить или перехватить ленивую загрузку свойств так или иначе, чтобы удаленные навигационные свойства никогда не загружались.

Я просматривал свои варианты, но они кажутся скудными:

Это просто не вариант, так как это будет слишком много ручной работы. (Наше приложение огромно и каждый день становится хегером). Мы также не хотим отказываться от преимуществ использования Code First (которых их много)

Опять же, не вариант. Эта конфигурация доступна только для каждого объекта. Всегда охотно загружающие объекты также налагают серьезную штрафную санкцию.

  • Применяя шаблон Expression Visitor, который автоматически вводит .Where(e => !e.Deleted) в любом месте, он находит IQueryable<Entity>, как описано здесь и здесь.

Я действительно протестировал это в доказательстве концепции приложения, и он отлично работал. Это был очень интересный вариант, но, увы, он не смог применить фильтрацию к лениво загруженным свойствам навигации. Это очевидно, так как эти ленивые свойства не будут отображаться в выражении/запросе и как таковые не могут быть заменены. Интересно, сможет ли Entity Framework указать точку впрыска где-нибудь в классе DynamicProxy, который загружает ленивые свойства. Я также опасаюсь за другие последствия, такие как возможность взлома механизма Include в EF.

  • Написание пользовательского класса, который реализует ICollection, но автоматически фильтрует объекты Deleted.

Это был мой первый подход. Идея заключалась бы в использовании свойства backing для каждого свойства коллекции, которое внутренне использует собственный класс Collection:

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

}

Хотя этот подход на самом деле не плохой, у меня все еще есть некоторые проблемы с ним:

  • Он по-прежнему загружает все Workout в память и фильтрует теги Deleted при попадании устройства настройки свойств. По моему скромному мнению, это слишком поздно.

  • Существует логическое несоответствие между выполненными запросами и загружаемыми данными.

Изобразите сценарий, где мне нужен список членов тренажерного зала, которые тренировались с прошлой недели:

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

Этот запрос может вернуть члену спортзала, который имеет только удаленные тренировки, но также удовлетворяет предикату. Как только они загружаются в память, кажется, что у этого члена спортзала нет тренировок вообще! Вы могли бы сказать, что разработчик должен знать о Deleted и всегда включать его в свои запросы, но это то, что я действительно хотел бы избежать. Возможно, ExpressionVisitor может предложить ответ здесь снова.

  • Фактически невозможно отметить свойство навигации как Deleted при использовании CustomCollection.

Представьте себе этот сценарий:

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

Вы ожидаете, что соответствующая запись Workout будет обновлена ​​в базе данных, и вы ошибаетесь! Поскольку GymMember проверяется ChangeTracker для любых изменений, свойство gymMember.Workouts неожиданно возвращает 1 меньше тренировки. Это потому, что CustomCollection автоматически фильтрует удаленные экземпляры, помните? Итак, теперь Entity Framework думает, что разминка должна быть удалена, и EF попытается установить FK в значение null или фактически удалить запись. (в зависимости от конфигурации вашей БД). Это то, чего мы пытались избежать с помощью шаблона мягкого удаления, с самого начала.

Я наткнулся на интересный блогpost, который отменяет метод SaveChanges по умолчанию для DbContext, так что любые записи с EntityState.Deleted меняются обратно на EntityState.Modified, но это снова кажется "взломанным" и довольно опасным. Тем не менее, я готов попробовать, если он решает проблемы без каких-либо непредвиденных побочных эффектов.


Итак, я StackOverflow. Я довольно подробно изучил свои варианты, если так могу сказать сам, и я нахожусь на своем пути. Итак, теперь я обращаюсь к вам. Как вы реализовали мягкие удаления в своем корпоративном приложении?

Чтобы повторить, это требования, которые я ищу:

  • Запросы должны автоматически исключать объекты Deleted на уровне DB
  • Удаление объекта и вызов "SaveChanges" должны просто обновить соответствующую запись и не иметь других побочных эффектов.
  • При загрузке навигационных свойств, будь то ленивые или нетерпеливые, теги Deleted должны быть автоматически исключены.

Я с нетерпением жду любых предложений, спасибо заранее.

4b9b3361

Ответ 1

После долгих исследований я наконец нашел способ добиться того, чего хотел. Суть его в том, что я перехватываю материализованные объекты с обработчиком событий в контексте объекта, а затем внедряю свой собственный класс коллекции в каждое свойство коллекции, которое я могу найти (с отражением).

Наиболее важной частью является перехват "DbCollectionEntry", класса, ответственного за загрузку связанных свойств коллекции. Видя себя между сущностью и DbCollectionEntry, я получаю полный контроль над тем, что загружено, когда и как. Единственный недостаток заключается в том, что этот класс DbCollectionEntry практически не имеет публичных членов, что требует от меня использования отражения для его манипулирования.

Вот мой пользовательский класс коллекции, который реализует ICollection и содержит ссылку на соответствующий 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
}

Если вы пропустите его, вы обнаружите, что наиболее важной частью является свойство "Entities", которое будет ленить загружать фактические значения. В конструкторе FilteredCollection я передаю необязательный ICollection для сценария, в котором коллекция уже загружена.

Конечно, нам еще нужно настроить Entity Framework, чтобы наш FilteredCollection использовался везде, где есть свойства коллекции. Это может быть достигнуто путем подключения к событию ObjectMaterialized базового объекта ObjectContext для платформы 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 });
            }
        }
    };

Все выглядит довольно сложно, но в основном это сканирование материализованного типа для свойств коллекции и изменение значения в отфильтрованную коллекцию. Он также передает DbCollectionEntry в отфильтрованную коллекцию, чтобы он мог работать своей магией.

Это охватывает всю часть "загрузочных объектов". Единственным недостатком до сих пор является то, что с нетерпением загруженные свойства коллекции будут по-прежнему включать удаленные объекты, но они отфильтрованы в методе "Добавить" класса FilterCollection. Это приемлемый недостаток, хотя мне еще предстоит провести некоторое тестирование того, как это влияет на метод SaveChanges().

Конечно, это все еще оставляет одну проблему: автоматическая фильтрация по запросам отсутствует. Если вы хотите забрать членов тренажерного зала, которые тренировались на прошлой неделе, вы хотите автоматически исключить удаленные тренировки.

Это достигается с помощью ExpressionVisitor, который автоматически применяет фильтр ".Where(e = > ! e.Deleted)" к каждому IQueryable, который может найти в данном выражении.

Вот код:

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

Я бегу немного коротко, поэтому я вернусь к этому сообщению позже с более подробной информацией, но суть его записана и для тех из вас, кто хочет попробовать все; Я разместил здесь полное тестовое приложение: https://github.com/amoerie/TestingGround

Однако все же могут быть некоторые ошибки, так как это очень большая работа. Концептуальная идея звучит, хотя, и я ожидаю, что она будет полностью функционировать вскоре после того, как я аккуратно переработаю все и найду время, чтобы написать несколько тестов для этого.

Ответ 2

Одним из возможных способов может быть использование спецификаций с базовой спецификацией, которая проверяет флаг с мягким удалением для всех запросов вместе со стратегией включения.

Ill иллюстрирует скорректированную версию шаблона спецификации, который я использовал в проекте (который возник из этого сообщения )

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 является оболочкой для создателя предикатов, включенного в LINQKit.dll.

Основной класс спецификации отвечает за создание построителя предикатов. После создания могут быть добавлены критерии, которые должны применяться ко всему запросу. Затем создатель предикатов может быть передан унаследованным спецификациям для добавления дополнительных критериев. Например:

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

Полный предикат IdSpecification будет следующим:

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

Затем спецификация может быть передана в репозиторий, который использует свойство PredicateBuilder для создания предложения where:

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

AsExpandable() является частью файла LINQKit.dll.

В отношении включения/ленивых свойств загрузки можно расширить спецификацию с дополнительным свойством о включении. База спецификации может добавить базу включает, а затем дочерние спецификации добавить их включает. После этого репозиторий может извлечь данные из спецификации, прежде чем извлекать из db.

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

Сообщите мне, если что-то неясно. Я старался не делать это монстром, поэтому некоторые детали могут быть опущены.

Изменить: я понял, что не полностью ответил на ваш вопрос; навигационных свойств. Что делать, если вы сделаете свойство навигации внутренним (используя это сообщение, чтобы настроить его, и создание не отображаемых общедоступных свойств, которые являются IQueryable. имеют пользовательский атрибут, и репозиторий добавляет предикат базовой спецификации к тому, где он не загружает его. Когда кто-то применит активную операцию, фильтр применит. Что-то вроде:

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

Я не тестировал вышеуказанный код, но он мог работать с некоторой настройкой:)

Изменить 2: Удаляет.

Если вы используете общий/общий репозиторий, вы можете просто добавить некоторые дополнительные функции к методу удаления:

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

Ответ 3

Рассматривали ли вы использование представлений в своей базе данных для загрузки проблемных объектов с удаленными элементами?

Это означает, что вам нужно будет использовать хранимые процедуры для сопоставления функциональных возможностей INSERT/UPDATE/DELETE, но это определенно решит вашу проблему, если Workout сопоставляется с представлением с удаленными строками, опущенными. Также - это может не работать одинаково в первом подходе кода...