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


Я очень люблю 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-разработке.

Комментарии (24)


  1. workless
    08.02.2019 11:46
    +1

    оффтоп
    Automapper плох тем, что его автор очень часто делает ломающие изменения.
    Как написали на иностранном форуме «Похоже его разработчик сидит на тяжелых наркотиках»

    Как только что-то начнете использовать отличное от тривиального маппинга — то скорее всего будет тяжело обновиться на следующую версию.


  1. AgentFire
    08.02.2019 12:02

    А как дела обстоят с сортировкой и фильтрацией на конечном потребителе?
    Что, если полученный IQueryable требуется передать дальше по логической цепочке, где будут применены Where/OrderBy и т.п.?


    1. alexeystarchikov Автор
      08.02.2019 12:08

      Проблем с этим нет. В результате же получается SQL-запрос, к которому, соответственно, можно применить WHERE/ORDER BY


      1. AgentFire
        08.02.2019 13:15

        Но каким же образом они транслируются в SQL?


        1. alexeystarchikov Автор
          08.02.2019 14:07

          Этим занимается Entity Framework


          1. AgentFire
            08.02.2019 14:10

            Но тогда индексация будет невозможна..


            1. mayorovp
              08.02.2019 14:16

              Почему вы думаете, что вот такой запрос будет выполнен без проблем с производительностью:


              select * from Foo order by Bar

              а вот такой уже не сможет использовать индексы?


              select Extent1.Bar1 as Bar1, Extent1.Baz1 as Baz1
              from (
                  select Project1.Bar1 as Bar1, Project1.Baz1 as Baz1
                  from (
                      select Foo.Bar as Bar1, Foo.Bar as Baz1
                      from Foo
                  ) as Project1
                  order by Project1.Bar
              ) as Extent1


              1. AgentFire
                08.02.2019 14:21

                У вас будет
                не будет вложенного запроса с тем подходом, что описан в посте, пост вообще немного о другом, там будет
                CASE WHEN Prop1 = Val1 THEN String1 CASE WHEN ... END
                А это не индексируемо


            1. alexeystarchikov Автор
              08.02.2019 14:34

              Индексация над проекцией в принципе невозможна.
              Например, есть таблица USERS с ID, NAME, SURNAME.


              SELECT NAME + ' ' + SURNAME FROM USERS

              Какой тут индекс?


              1. AgentFire
                08.02.2019 14:36
                +1

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


                1. alexeystarchikov Автор
                  08.02.2019 14:40

                  Согласен. Но это уже будет не будет «не то». В таком случае, используя Automapper, в запрос будут добавлены `JOIN`.
                  Я говорил о том, что не получиться создать индекс для проекции, создаваемой «на лету». Если бы это был `VIEW, тогда да. Для вьюшек в MSSQL можно создать индексы. Но не для одного запроса


                  1. AgentFire
                    08.02.2019 15:50

                    Если бы это был `VIEW, тогда да. Для вьюшек в MSSQL можно создать индексы. Но не для одного запроса

                    Не совсем ясен контекст вышего высказывания.
                    а) Индексировать VIEW можно только сделав его кластерным, и это уже не "на лету".
                    б) Так что ничто не мешает заранее проиндексировать стоблцы нужных таблиц для ожидаемого SELECT запроса "на лету".


              1. Sioln
                08.02.2019 14:54
                -1

                Индексация над проекцией в принципе невозможна.

                Почему вы так считаете? Проекция — это просто не считывание всех полей из таблицы, а считывание и, возможно, преобразование определённых.

                Например, индекс, включающий (именно включающий, а не построенный по) Name и Surname позволит считать данные быстрее кластерного (если в таблице не два поля) в силу того, что нужно будет считать меньше страниц БД.


                1. alexeystarchikov Автор
                  08.02.2019 15:27

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


        1. alexeystarchikov Автор
          08.02.2019 14:29

          Если вы уже получили корректный IQueryable, т.е. когда как минимум можно получить данные через ToArray/ToList, значит запрос корректно транслируется в sql-запрос.
          В данном случае, нужно помнить что тип данных результирующей колонки будет STRING. Поэтому при применении сортировки и фильтрации к этому полю, операции будут применяться к строковому результату, а не к исходному числовому enum.


  1. kemsky
    08.02.2019 16:26

    Неплохой вариант, но с другой стороны возникает вопрос, не лучше ли написать экстеншен метод т.е. foo.Enum.ToDisplayName()?


    1. alexeystarchikov Автор
      08.02.2019 16:41

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


      1. kemsky
        08.02.2019 16:58
        +1

        Я имел ввиду, что может быть маппить и не нужно это поле вообще, а в месте использования просто использовать экстеншен метод: myLabel.Text = foo.Enum.ToDisplayName(). По скорости это будет не хуже (если использовать кэш имен), по удобству — терпимо.


        1. alexeystarchikov Автор
          08.02.2019 18:02

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


  1. sentyaev
    08.02.2019 18:00

    В dto необходимо получить не сам Enum, а значение свойства Name атрибута DisplayAttribute.

    Я конечно не знаю вашей задачи, то на этом месте возник вопрос — почему так?
    По мне так удобнее в DTO иметь Enum, а во что его транслировать это уже задача сериализации, например какой-нибудь кастомный JsonSerializer, да какой угодно.
    Мне показалось, что задачу которую должен решать View слой вы перенесли аж в слой работы с данными.


    1. alexeystarchikov Автор
      08.02.2019 19:25

      Не совсем соглашусь с вами. То, что 0 соответствует строке «Any», а 1 — «Open» это никак не слой представления, это данные. 0 и 1 это идентификаторы. Строки — значения, им соответствующие.
      Но наша идея интересная и имеет место быть.

      Enum используется в проекте «исторически» и задача по стоит поддерживать чтение из существующей базы.


      1. eugene_bb
        08.02.2019 21:29

        По моему опыту, в большинстве случаев (т.е. enum из 10-20 значение) использование цифровых идентификаторов в базе не очень удобно. Особенно когда их несколько, то постоянно пытаешься вспомнить что эта «магическая» цифра значит, а по символу, сразу понятно.

        Опыт показал, что в таких случаях лучше использовать CHAR(1), а если у вас, как в примере, всего три с уникальными началами названий, то сам ТНБ велел.

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

        Одно время использовали IDbCommandInterceptor чтобы прозрачно работать с ними.

        И при использовании db-first генерировали эти enum автоматически с помощью T4 из таблиц справочников.


      1. sentyaev
        09.02.2019 16:44

        Так а я об этом и говорю, если у вас Dto содержит Enum, то это же не 0 и 1, а Enum.Any, Enum.Open и т.д. И я о том что это задача View как этот Enum представить потребителю Api.
        Возможный вариант — если Api поддерживает разные языки, или другой вариант, вы можете сериализовать данные в json, xml или protobuf да или в html.


        1. alexeystarchikov Автор
          09.02.2019 17:46

          Вот как раз 0 или 1.. Их заголовки это тоже данные, не имеющие отношение к данным. Но не будем спорить в этом русле, потому как и ваш вариант также укладывается в MVC. Я говорю немного о другом. Если заголовки enum относить к представлению, то получается, что одни и те же данные и бизнес платила для них будут находится на нескольких уровнях. И это главная проблема. Да, тут можно поспорить, "что есть представление", не отрицаю, тут ваши аргументы весомы. Но для чего обычно используется enum (точнее когда я его использую). Когда необходимо описать набор значений, от которых может зависеть бизнес-логика, когда это крайне неудобно представлять в виде отдельной таблицы в БД. Например, при описании набора состояний, особенной когда по условиям задачи, варианты состояний не меняются. Если это реализовать в виде таблицы, то в коде будет неявная свять, в виде "если Id=0, то вызываем какую то функцию". Поэтому и удобнее использовать enum. Это позволяет определить набор состояний, которые можно хранить в базе и которые однозначно определяются в коде. Но это пять же мое мнение.