Получаем данные enum в проекции Automapper

в 8:04, , рубрики: .net, automapper, C#, ef, EF Core, expression

Немного ликбеза

Я очень люблю Automapper, особенно его QueryableExtensions и метод ProjectTo<>. Если вкратце, то данный метод позволяет делать проекцию типов прямо в SQL-запросе. Это позволяло получать dto фактически из базы данных. Т.е. не нужно получать две entity из базы, грузить их в память, применять Automapper.Map<>, что приводило к большому расходу и трафику памяти.

Проекция типа

Для получения проекции в linq требовалось написать что-то подобное:

    from user in dbContext.Users
    where user.IsActive
    select new
    {
        Name = user.Name,
        Status = user.IsConnected ? "Connected" : "Disconnected"
    }

Используя QueryableExtensions, этот код можно заменить на следующий (конечно, при условии, что правила преобразования User -> UserInfo уже описано)

dbContext.Users.Where(x => x.IsActive).ProjectTo<UserInfo>();

Enum и проблемы с ним

У проекции есть один недостаток, который необходимо учитывать. Это ограничение на выполняемые операции. Не все можно транслировать в SQL-запрос. В частности, нельзя получить информацию по типу-перечислению. Например, есть следующий Enum

    public enum FooEnum
    {
        [Display(Name = "Любой")]
        Any,
        [Display(Name = "Открытый")]
        Open,
        [Display(Name = "Закрытый")]
        Closed
    }

Есть entity, в котором объявлено свойство типа FooEnum. В dto необходимо получить не сам Enum, а значение свойства Name атрибута DisplayAttribute. Реализовать это через проекцию не получиться, т.к. получение значения атрибута требует Reflection, о чем SQL просто "ничего не знает".

В итоге приходится либо использовать обычный Map<>, загружая все сущности в память, либо заводить дополнительную таблицу со значениями Enum и внешними ключами на нее.

Решение есть — Expressions

Но "и на старуху найдется проруха". Ведь все значения Enum заранее известны. В SQL есть реализация switch, которую можно вставить при формировании проекции. Остается понять, как это сделать. ХэшТэг: "Деревья-выражений-наше-все".

Automapper при проекции типов может преобразовать expression в выражение, которое после Entity Framework конвертирует в соответствующий SQL-запрос.

На первый взгляд, синтаксис создания деревьев выражений в runtime крайне неудобен. Но после нескольких небольших решенных задач все становится очевидно. Для решения проблемы с Enum необходимо создать вложенное дерево условных выражений, возвращающих значения, в зависимости от исходных данных. Примерно такое

IF enum=Any THEN RETURN "Любой"
  ELSE IF enum=Open THEN RETURN "Открытый"
    ELSE enum=Closed THEN RETURN "Закрытый"
      ELSE RETURN ""

Определимся с сигнатурой метода.

    public class FooEntity
    {
        public int Id { get; set; }
        public FooEnum Enum { get; set; }
    }

    public class FooDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }

    //Задаем правило Automapper
    CreateMap<FooEntity, FooDto>()
        .ForMember(x => x.Enum, options => options.MapFrom(GetExpression()));

    private Expression<Func<FooEntity, string>> GetExpression()
    {

    }

Метод GetExpression() должен сформировать выражение, получающее экземпляр FooEntity и возвращающее строковое представление для свойства Enum.
Для начала определим входной параметр и получим само значение свойства

ParameterExpression value = Expression.Parameter(typeof(FooEntity), "x");
var propertyExpression = Expression.Property(value, "Enum");

Вместо строки имени свойства можно использовать синтаксис компилятора nameof(FooEntity.Enum) или даже получить данные о свойстве System.Reflection.PropertyInfo или геттера System.Reflection.MethodInfo. Но для примера нам хватит и явного задания имени свойства.

Чтобы вернуть конкретное значение, используем метод Expression.Constant. Формируем значение по умолчанию

    Expression resultExpression = Expression.Constant(string.Empty);

После этого, последовательно "оборачиваем" результат в условие.

    resultExpression = Expression.Condition(
        Expression.Equal(propertyExpression, Expression.Constant(FooEnum.Any)),
        Expression.Constant(EnumHelper.GetShortName(FooEnum.Any)),
        resultExpression);
    resultExpression = Expression.Condition(
        Expression.Equal(propertyExpression, Expression.Constant(FooEnum.Open)),
        Expression.Constant(EnumHelper.GetShortName(FooEnum.Open)),
        resultExpression);
    resultExpression = Expression.Condition(
        Expression.Equal(propertyExpression, Expression.Constant(FooEnum.Closed)),
        Expression.Constant(EnumHelper.GetShortName(FooEnum.Closed)),
        resultExpression);

    public static class EnumHelper
    {
        public static string GetShortName(this Enum enumeration)
        {
            return (enumeration
                .GetType()
                .GetMember(enumeration.ToString())?
                .FirstOrDefault()?
                .GetCustomAttributes(typeof(DisplayAttribute), false)?
                .FirstOrDefault() as DisplayAttribute)?
                .ShortName ?? enumeration.ToString();
        }
    }

Все, что осталось, это оформить результат

    return Expression.Lambda<Func<TEntity, string>>(resultExpression, value);

Еще немного рефлексии

Копипастить все значения Enum крайне неудобно. Давайте это исправим

    var enumValues = Enum.GetValues(typeof(FooEnum)).Cast<Enum>();
    Expression resultExpression = Expression.Constant(string.Empty);
    foreach (var enumValue in enumValues)
    {
        resultExpression = Expression.Condition(
            Expression.Equal(propertyExpression, Expression.Constant(enumValue)),
            Expression.Constant(EnumHelper.GetShortName(enumValue)),
            resultExpression);
    }

Усовершенствуем получение значения свойства

Недостаток кода выше — жесткая привязка типа используемой сущности. Если подобную задачу необходимо решить применительно к другому классу, необходимо придумать способ получения значения свойства типа-перечисление. Так пусть за нас это делает expression. В качестве параметра метода будем передавать выражение, получающее значение свойства, а сам код — просто формируем набор результатов по возможным этого свойства. Шаблоны нам в помощь

    public static Expression<Func<TEntity, string>> CreateEnumShortNameExpression<TEntity, TEnum>(Expression<Func<TEntity, TEnum>> propertyExpression)
        where TEntity : class
        where TEnum : struct
    {
        var enumValues = Enum.GetValues(typeof(TEnum)).Cast<Enum>();
        Expression resultExpression = Expression.Constant(string.Empty);
        foreach (var enumValue in enumValues)
        {
            resultExpression = Expression.Condition(
                Expression.Equal(propertyExpression.Body, Expression.Constant(enumValue)),
                Expression.Constant(EnumHelper.GetShortName(enumValue)),
                resultExpression);
        }

        return Expression.Lambda<Func<TEntity, string>>(resultExpression, propertyExpression.Parameters);
    }

Несколько пояснений. Т.к. входное значение мы получаем через другое выражение, то объявлять параметр через Expression.Parameter нам не нужно. Этот параметр мы берем из свойства входного выражения, а тело выражения используем для получения значения свойства.
Тогда использовать новый метод можно так:

    CreateMap<FooEntity, FooDto>()
        .ForMember(x => x.Enum, options => options.MapFrom(GetExpression<FooEntity, FooEnum>(x => x.Enum)));


Всем удачного освоения деревьев выражений.

Крайне рекомендую почитать статьи Максима Аршинова. Особенно про Деревья выражений в enterprise-разработке.

Автор: alexeystarchikov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js