Однажды пасмурным мартовским субботним утром я решил посмотреть, как обстоят дела у Майкрософта в благом деле по трансформированию мастодонта Entity Framework в Entity Framework Core. Ровно год назад, когда наша команда начинала новый проект и подбирала ORM, то руки чесались использовать все как можно более стильное и молодежное. Однако, присмотревшись к EFC, мы поняли, что он еще очень далек продакшна. Очень много проблем с N+1 запросами (сильно улучшили во 2й версии), кривые вложенные селекты (пофиксали в 2.1.0-preview1), нет поддержки Many-to-Many (все еще нет) и вишенка на торте — отсутствие поддержки DbGeometry, что в нашем проекте было очень критично. Примечательно, что последняя фича находится в road map проекта с 2015 года в списке высокоприоритетных. У нас в команде есть даже шутка на эту тему: "Эту задачу добавим в список высокоприоритетных". И вот прошел один год с последней ревизии EFC, вышла уже вторая версия данного продукта и я решил проверить, как обстоят дела.
На мой взгляд один из лучших способов проверить продукт — это попытаться расширить его какой-нибудь кастомной фичей. Это сразу проливает свет на: а) качество архитектуры; б) качество документации; в) поддержку сообщества.
Беглый просмотр первой страницы выдачи гугла показал, что полнотекстовый поиск в EFC пока не поддерживается, но есть планы. Отлично, это нам и надо, можно попробовать реализовать предикат CONTAINS
из T-SQL самому.
Придумываем API
Не стал заморачиваться со сложными способами и просто объявил метод-расширение для строк:
public static class StringExt
{
public static bool ContainsText(this string text, string sub)
{
throw new NotImplementedException("This method is not supposed to run on client");
}
}
В теле метода просто кидаем исключение, потому что это просто маркер, не предназначенный для запуска на клиенте. В пользовательском коде это должно выглядеть как-то так:
dbContext.Posts.Where(x => x.Content.ContainsText("egg"));
осталось придумать, как это реализовать.
Поиск точек расширения
С этим дела обстоят посложнее. Гугл по запросу "ef core create custom operator" выдает лишь ссылку на топик из гитхаба проекта, оканчивающийся сообщением типа "hey, any updates on that?". Также предлагается запускать SQL запрос руками, что безусловно сработало бы, но это не наш вариант.
Самый лучший способ сделать что-то новое — это сделать по аналогии. Какой самый ближайший близкий по смыслу оператор, который мы хотим реализовать? Правильно, LIKE
. Оператор LIKE
транслируется из метода String.Contains
. Все что нам нужно сделать, это подсмотреть, как это сделано разработчиками EFC.
Качаем репозиторий, открываем его в Visual Studio 2017 и… Visual Studio уходит в мертвый штопор. Ну ок, жирные IDE для дилетантов, берем Visual Studio Code, там все летает. Более того, Code Lens работает из коробки, просто удивительно.
Находим файлы, содержащие Contains в названии,SqlServerContainsOptimizedTranslator.cs — наш кандидат. Интересно, что же в нем такого оптимизированного? Оказывается, EFC, в отличие от EF использует CHARINDEX > 0
вместо LIKE '%pattern%'
.
Этот пост на SO ставит под сомнение решение команды EFC.
Code Lens подсказывает нам, что SqlServerContainsOptimizedTranslator
используется только в одном месте — SqlServerCompositeMethodCallTranslator.cs. Бинго! Данный класс, наследуется от RelationalCompositeMethodCallTranslator
и судя по названию транслирует вызов .NET методов в SQL запрос, что нам и надо! Нужно всего лишь расширить данный класс и добавить в его список еще один наш кастомный транслятор.
Пишем свой транслятор
Транслятор должен реализовать интерфейс IMethodCallTranslator
. Контракт, который он должен исполнить в методе Expression Translate(MethodCallExpression methodCallExpression)
, достаточно прост: если входное выражение не известно — возвращаем null, в другом случае — преобразовываем в Sql выражение.
Вот как выглядит класс:
public class FreeTextTranslator : IMethodCallTranslator
{
private static readonly MethodInfo _methodInfo
= typeof(StringExt).GetRuntimeMethod(nameof(StringExt.ContainsText), new[] {typeof(string), typeof(string)});
public Expression Translate(MethodCallExpression methodCallExpression)
{
if (methodCallExpression.Method != _methodInfo) return null;
var patternExpression = methodCallExpression.Arguments[1];
var objectExpression = (ColumnExpression) methodCallExpression.Arguments[0];
var sqlExpression =
new SqlFunctionExpression("CONTAINS", typeof(bool),
new[] { objectExpression, patternExpression });
return sqlExpression;
}
}
Осталось только подключить его при помощи CustomSqlMethodCallTranslator:
public class CustomSqlMethodCallTranslator : SqlServerCompositeMethodCallTranslator
{
public CustomSqlMethodCallTranslator(RelationalCompositeMethodCallTranslatorDependencies dependencies) : base(dependencies)
{
// ReSharper disable once VirtualMemberCallInConstructor
AddTranslators(new [] {new FreeTextTranslator() });
}
}
DI в EFC
EFC использует DI паттерн по полной, я бы даже сказал чересчур. Чувствуется влияние команды Kestrel (или наоборот). Если вы уже работаете с ASP.NET Core, то проблем с пониманием внедрения и разрешения завивимостей в EFC у вас не возникнет. Метод-расширение UseSqlServer
устанавливает пару десятков зависимостей, необходимых для работы библиотеки. Исходники можно посмотреть тут. Там есть и наш ICompositeMethodCallTranslator
, который мы перезапишем, используя хелпер ReplaceService
optionsBuilder.ReplaceService<ICompositeMethodCallTranslator, CustomSqlMethodCallTranslator>();
Устанавливаем и запускаем.
var textContains = dbContext.Posts.Where(x => x.Content.ContainsText("egg")).ToArray();
Проблемы с генерированием SQL
После запуска обнаруживаем 2 новости: хорошую и не очень. Хорошая заключается в том, что наш кастомный транслятор был успешно подхвачен EFC. Плохая — запрос получился неправильным.
SELECT [x].[Id], [x].[AuthorId], [x].[BlogId], [x].[Content], [x].[Created], [x].[Rating], [x].[Title]
FROM [Posts] AS [x]
WHERE CONTAINS([x].[Content], N'egg') = 1
Очевидно, итоговый SQL генератор, преобразовывающий промежуточнее дерево выражений в уже готовый запрос, ожидает от SQL функции какое-либо значение. Но CONTAINS — это предикат, который возвращает bool, на что SQL генератор не обращает внимания. После гугления, множества безуспешных попыток создать костыль я сдался. Я даже пытался использовать SqlFragmentExpression
, который вставляет SQL строку в итоговый запрос как есть. Генератор упортно добавлял = 1
. Перед тем как пойти спать, я оставил баг рапорт на гитхабе проекта #11316. И, о чудо, мне указали, проблему и спрособ ее решения в течение 24 часов.
Проблема и решение
Моя догадка о том, что SQL генератор хочет возвращаемое значение была верна. Чтобы решить эту проблему, нужно было в SqlVisitor'e подменить VisitBinary на VisitUnary, т.к. CONTAINS является унарным оператором. Вот тут есть реализованная идея. Действуем по аналогии, создаем наш кастомный генератор, подключаем его в контейнере и запускаем снова.
public class FreeTextSqlGenerator : DefaultQuerySqlGenerator
{
internal FreeTextSqlGenerator(QuerySqlGeneratorDependencies dependencies, SelectExpression selectExpression) : base(dependencies, selectExpression)
{
}
protected override Expression VisitBinary(BinaryExpression binaryExpression)
{
if (binaryExpression.Left is SqlFunctionExpression sqlFunctionExpression
&& sqlFunctionExpression.FunctionName == "CONTAINS")
{
Visit(binaryExpression.Left);
return binaryExpression;
}
return base.VisitBinary(binaryExpression);
}
}
Все заработало, генерируется правильный SQL. Метод ContainsText
может участвовать в различных выражениях, в общем является полноценным участником EFC.
Выводы
Архитектурно EFC ушел далеко вперед от классического EF. Расширить его не составляет никаких проблем, однако будьте готовы искать решения в исходниках. Для меня это один из главных способов узнать что-то новое, хоть он и занимает много времени.
Мейнтейнеры проекта готовы дать развернутый ответ на ваш вопрос. Я заметил, что спустя 4 дня после того, как я зарепортил свой баг, было открыто еще ~20 issues. На большую часть из них был получен ответ.
Готовый код находится здесь. Чтобы его запустить, вам понадобится последняя VS и docker на linux контейнерах, либо SQL Server с Full-Text Search. К сожалению, localdb поставляется без лингвистических сервисов и подключить их не представляется возможным. Я воспользовался докер-файлом из интернета. Сборка и запуск docker образа находится в файлe database-create.ps1.
Также не забудьте запустить миграции используя cmdlet update-database
.
Комментарии (16)
tarakanoff
21.03.2018 09:35+1Спасибо за статью, ее очень не хватало. К слову, еще один большой баг EF Core в том, что он выполняет запросы при сборке выражения не ожидания вызовов ToList, ToListAsync и т.д.
mayorovp
21.03.2018 09:54Это вообще как?
tarakanoff
21.03.2018 10:35+2Например, сборка выражения по условиям. Мы создаем IQueryable, сортируем его, добавляем условия, соединения и так далее. Затем выполняем запрос, вызывая ToList (и другие известные методы, для трансляции и выполнения SQL-запроса на сервере).
Так вот, помежуточные IQueryable выполняются до финального вызова ToList, причем EF Core это аргументирует в логах тем, что не может транслировать «некоторые выражения» и он вынужден выполнить запрос немедленно и далее уже работать с коллекцией в памяти.
Копание пока ничего не дало, есть старая закрытая issue github.com/aspnet/EntityFrameworkCore/issues/7096
Аналогично для Skip, Take. К примеру, warn: Microsoft.EntityFrameworkCore.Query[20500]
The LINQ expression 'Skip(__p_3)' could not be translated and will be evaluated locally.elepner Автор
21.03.2018 10:46+4Да, очень бесит это «улучшение». Если EF6 не может что-то трансировать, то кидает исключение, что мне и надо. Это значит я написал кривой запрос и его надо пофиксать или переделать логику и явно написать `foreach`. EFC молча проглатывает все и исполняет локально. Нет, спасибо, не надо мне такой услуги.
Dansoid
22.03.2018 18:37Я видел что такие финты EF Core проделывает с группировкой. Записи вполне могут прогруппироваться на клиенте. Я бы очень внимательно следил за SQL которые в конце концов генерятся.
Dansoid
21.03.2018 16:02-2Да, конечно, вижу изврат на ровнм месте. Действительно уже кучу раз могли придумать поддержку кастомных функций, об кастомных агрегатах и оконных функциях я вообще молчу.
Ну что же, я рад чо у нас в linq2db это занимает ровно один чих:
github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Sql/Sql.cs#L468mayorovp
21.03.2018 16:12+1Обычные функции что в EF, что в EF Core делаются столь же просто.
Dansoid
22.03.2018 14:29Ах предикат. Да какой угодно.
github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Sql/Sql.cs#L103
neyroneyro
21.03.2018 22:49Спасибо за статью.
Задумался, как написать поддержку такого метода расширения, как например
db.Goods.DeleteAsync(x => x.Price > 2000).neyroneyro
21.03.2018 22:57Всмысле, метода расширения для IQueryable для генерации Delete запроса.
elepner Автор
22.03.2018 00:47Не уверен, что это возможно. Такая функциональность противоречит идеологии фреймворка. Закрепление изменений происходит во время вызова метода
SaveChanges()
. EF и EFC хранят граф объектов и следят за изменениями. По накопленным изменениям генерируютсяUPDATE
,INSERT
,DELETE
. Каким образом DeleteAsync превратится в граф объектов? Вы, конечно, можете написать простую логику:
- Загрузить все объекты
x.Price > 2000
- Удалить их. Но это будет ужасный оверхед.
Вообще, знающие люди говорят, что Bulk Operations и ORM это из разных областей.
Dansoid
22.03.2018 14:45Вы путаете change tracking с самим понятием ORM. Легковесные ORM это делают из коробки. Им совсем не обязательно тянуть запись с сервера чтобы ее изменить или удалить. Из-за таких мелких выборок серьезно проседает SQL сервер в высоконагруженых системах.
- Загрузить все объекты
dotnetdonik
В Ef core 2.0 есть возможность мапить функции из коробки без необходимости расширять библиотеку.
http://anthonygiretti.com/2018/01/11/entity-framework-core-2-scalar-function-mapping/
elepner Автор
Интересная фича, спасибо за наводку. Не думаю, что ее бы хватило для поддержки `CONTAINS` как минимум из-за того, что `DefaultQuerySqlGenerator` не достаточно универсален и проблема с `= 1` никуда бы не ушла (#9143). Но надо обязательно попробовать этот функционал.