Немного ликбеза
Я очень люблю 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)
AgentFire
08.02.2019 12:02А как дела обстоят с сортировкой и фильтрацией на конечном потребителе?
Что, если полученный IQueryable требуется передать дальше по логической цепочке, где будут применены Where/OrderBy и т.п.?alexeystarchikov Автор
08.02.2019 12:08Проблем с этим нет. В результате же получается SQL-запрос, к которому, соответственно, можно применить WHERE/ORDER BY
AgentFire
08.02.2019 13:15Но каким же образом они транслируются в SQL?
alexeystarchikov Автор
08.02.2019 14:07Этим занимается Entity Framework
AgentFire
08.02.2019 14:10Но тогда индексация будет невозможна..
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
AgentFire
08.02.2019 14:21У вас будет
не будет вложенного запроса с тем подходом, что описан в посте, пост вообще немного о другом, там будет
CASE WHEN Prop1 = Val1 THEN String1 CASE WHEN ... END
А это не индексируемо
alexeystarchikov Автор
08.02.2019 14:34Индексация над проекцией в принципе невозможна.
Например, есть таблица USERS с ID, NAME, SURNAME.
SELECT NAME + ' ' + SURNAME FROM USERS
Какой тут индекс?
AgentFire
08.02.2019 14:36+1Вообще-то индексация вполне себе возможна над любой проекцией, в том числе и Automapper-овской, при выполнении определенного условия.
В случае с постом, если бы требовалась индексация, единственным верным решением было бы создавать отдельную таблицу на каждый Enum.alexeystarchikov Автор
08.02.2019 14:40Согласен. Но это уже будет не будет «не то». В таком случае, используя Automapper, в запрос будут добавлены `JOIN`.
Я говорил о том, что не получиться создать индекс для проекции, создаваемой «на лету». Если бы это был `VIEW, тогда да. Для вьюшек в MSSQL можно создать индексы. Но не для одного запросаAgentFire
08.02.2019 15:50Если бы это был `VIEW, тогда да. Для вьюшек в MSSQL можно создать индексы. Но не для одного запроса
Не совсем ясен контекст вышего высказывания.
а) Индексировать VIEW можно только сделав его кластерным, и это уже не "на лету".
б) Так что ничто не мешает заранее проиндексировать стоблцы нужных таблиц для ожидаемого SELECT запроса "на лету".
Sioln
08.02.2019 14:54-1Индексация над проекцией в принципе невозможна.
Почему вы так считаете? Проекция — это просто не считывание всех полей из таблицы, а считывание и, возможно, преобразование определённых.
Например, индекс, включающий (именно включающий, а не построенный по) Name и Surname позволит считать данные быстрее кластерного (если в таблице не два поля) в силу того, что нужно будет считать меньше страниц БД.alexeystarchikov Автор
08.02.2019 15:27Возможно, пример неудачный привел.
В любом случае, мы явно отдалились от изначального вопроса.
alexeystarchikov Автор
08.02.2019 14:29Если вы уже получили корректный
IQueryable
, т.е. когда как минимум можно получить данные черезToArray/ToList
, значит запрос корректно транслируется в sql-запрос.
В данном случае, нужно помнить что тип данных результирующей колонки будетSTRING
. Поэтому при применении сортировки и фильтрации к этому полю, операции будут применяться к строковому результату, а не к исходному числовому enum.
kemsky
08.02.2019 16:26Неплохой вариант, но с другой стороны возникает вопрос, не лучше ли написать экстеншен метод т.е.
foo.Enum.ToDisplayName()
?alexeystarchikov Автор
08.02.2019 16:41В статье как раз есть такой хелпер
EnumHelper.GetShortname
Но идея то не в этом, а в том, как эту операцию "отправить" в sql-запрос, чтобы не вытягивать из базы все записи, а потом маппить из поляkemsky
08.02.2019 16:58+1Я имел ввиду, что может быть маппить и не нужно это поле вообще, а в месте использования просто использовать экстеншен метод:
myLabel.Text = foo.Enum.ToDisplayName()
. По скорости это будет не хуже (если использовать кэш имен), по удобству — терпимо.alexeystarchikov Автор
08.02.2019 18:02В конкретно взятом случае — да. Но если это веб-сервис, или вам нужно получить список сущностей с фильтрацией и сортировкой, то маппинг плохая идея. Тут нужно проекцией пользоваться
sentyaev
08.02.2019 18:00В dto необходимо получить не сам Enum, а значение свойства Name атрибута DisplayAttribute.
Я конечно не знаю вашей задачи, то на этом месте возник вопрос — почему так?
По мне так удобнее в DTO иметь Enum, а во что его транслировать это уже задача сериализации, например какой-нибудь кастомный JsonSerializer, да какой угодно.
Мне показалось, что задачу которую должен решать View слой вы перенесли аж в слой работы с данными.alexeystarchikov Автор
08.02.2019 19:25Не совсем соглашусь с вами. То, что 0 соответствует строке «Any», а 1 — «Open» это никак не слой представления, это данные. 0 и 1 это идентификаторы. Строки — значения, им соответствующие.
Но наша идея интересная и имеет место быть.
Enum используется в проекте «исторически» и задача по стоит поддерживать чтение из существующей базы.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 из таблиц справочников.
sentyaev
09.02.2019 16:44Так а я об этом и говорю, если у вас Dto содержит Enum, то это же не 0 и 1, а Enum.Any, Enum.Open и т.д. И я о том что это задача View как этот Enum представить потребителю Api.
Возможный вариант — если Api поддерживает разные языки, или другой вариант, вы можете сериализовать данные в json, xml или protobuf да или в html.alexeystarchikov Автор
09.02.2019 17:46Вот как раз 0 или 1.. Их заголовки это тоже данные, не имеющие отношение к данным. Но не будем спорить в этом русле, потому как и ваш вариант также укладывается в MVC. Я говорю немного о другом. Если заголовки enum относить к представлению, то получается, что одни и те же данные и бизнес платила для них будут находится на нескольких уровнях. И это главная проблема. Да, тут можно поспорить, "что есть представление", не отрицаю, тут ваши аргументы весомы. Но для чего обычно используется enum (точнее когда я его использую). Когда необходимо описать набор значений, от которых может зависеть бизнес-логика, когда это крайне неудобно представлять в виде отдельной таблицы в БД. Например, при описании набора состояний, особенной когда по условиям задачи, варианты состояний не меняются. Если это реализовать в виде таблицы, то в коде будет неявная свять, в виде "если Id=0, то вызываем какую то функцию". Поэтому и удобнее использовать enum. Это позволяет определить набор состояний, которые можно хранить в базе и которые однозначно определяются в коде. Но это пять же мое мнение.
workless
оффтоп
Automapper плох тем, что его автор очень часто делает ломающие изменения.
Как написали на иностранном форуме «Похоже его разработчик сидит на тяжелых наркотиках»
Как только что-то начнете использовать отличное от тривиального маппинга — то скорее всего будет тяжело обновиться на следующую версию.