- PVSM.RU - https://www.pvsm.ru -
Однажды пасмурным мартовским субботним утром я решил посмотреть, как обстоят дела у Майкрософта в благом деле по трансформированию мастодонта Entity Framework в Entity Framework Core. Ровно год назад, когда наша команда начинала новый проект и подбирала ORM, то руки чесались использовать все как можно более стильное и молодежное. Однако, присмотревшись к EFC, мы поняли, что он еще очень далек продакшна. Очень много проблем с N+1 запросами (сильно улучшили во 2й версии), кривые вложенные селекты (пофиксали в 2.1.0-preview1 [1]), нет поддержки Many-to-Many (все еще нет) и вишенка на торте — отсутствие поддержки DbGeometry, что в нашем проекте было очень критично. Примечательно, что последняя фича находится в road map [2] проекта с 2015 года в списке высокоприоритетных. У нас в команде есть даже шутка на эту тему: "Эту задачу добавим в список высокоприоритетных". И вот прошел один год с последней ревизии EFC, вышла уже вторая версия данного продукта и я решил проверить, как обстоят дела.
На мой взгляд один из лучших способов проверить продукт — это попытаться расширить его какой-нибудь кастомной фичей. Это сразу проливает свет на: а) качество архитектуры; б) качество документации; в) поддержку сообщества.
Беглый просмотр первой страницы выдачи гугла показал, что полнотекстовый поиск в EFC пока не поддерживается, но есть планы. Отлично, это нам и надо, можно попробовать реализовать предикат CONTAINS
из T-SQL самому.
Не стал заморачиваться со сложными способами и просто объявил метод-расширение для строк:
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" выдает лишь ссылку на топик [3] из гитхаба проекта, оканчивающийся сообщением типа "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 [4] — наш кандидат. Интересно, что же в нем такого оптимизированного? Оказывается, EFC, в отличие от EF использует CHARINDEX > 0
вместо LIKE '%pattern%'
.
Этот пост на SO [5] ставит под сомнение решение команды EFC.
Code Lens подсказывает нам, что SqlServerContainsOptimizedTranslator
используется только в одном месте — SqlServerCompositeMethodCallTranslator.cs [6]. Бинго! Данный класс, наследуется от 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() });
}
}
EFC использует DI паттерн по полной, я бы даже сказал чересчур. Чувствуется влияние команды Kestrel (или наоборот). Если вы уже работаете с ASP.NET Core, то проблем с пониманием внедрения и разрешения завивимостей в EFC у вас не возникнет. Метод-расширение UseSqlServer
устанавливает пару десятков зависимостей, необходимых для работы библиотеки. Исходники можно посмотреть тут [7]. Там есть и наш ICompositeMethodCallTranslator
, который мы перезапишем, используя хелпер ReplaceService
optionsBuilder.ReplaceService<ICompositeMethodCallTranslator, CustomSqlMethodCallTranslator>();
Устанавливаем и запускаем.
var textContains = dbContext.Posts.Where(x => x.Content.ContainsText("egg")).ToArray();
После запуска обнаруживаем 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 [8]. И, о чудо, мне указали, проблему и спрособ ее решения в течение 24 часов.
Моя догадка о том, что SQL генератор хочет возвращаемое значение была верна. Чтобы решить эту проблему, нужно было в SqlVisitor'e подменить VisitBinary на VisitUnary, т.к. CONTAINS является унарным оператором. Вот тут [9] есть реализованная идея. Действуем по аналогии, создаем наш кастомный генератор, подключаем его в контейнере и запускаем снова.
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. На большую часть из них был получен ответ.
Готовый код находится здесь [10]. Чтобы его запустить, вам понадобится последняя VS и docker на linux контейнерах, либо SQL Server с Full-Text Search. К сожалению, localdb поставляется без лингвистических сервисов и подключить их не представляется возможным. Я воспользовался докер-файлом из интернета. Сборка и запуск docker образа находится в файлe database-create.ps1.
Также не забудьте запустить миграции используя cmdlet update-database
.
Автор: elepner
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/275763
Ссылки в тексте:
[1] 2.1.0-preview1: https://stackoverflow.com/questions/41573491/ef-core-nested-linq-select-results-in-n-1-sql-queries
[2] road map: https://github.com/aspnet/EntityFrameworkCore/wiki/roadmap#high-priority-features
[3] ссылку на топик : https://github.com/aspnet/EntityFrameworkCore/issues/5845
[4] SqlServerContainsOptimizedTranslator.cs: https://github.com/aspnet/EntityFrameworkCore/blob/dev/src/EFCore.SqlServer/Query/ExpressionTranslators/Internal/SqlServerContainsOptimizedTranslator.cs
[5] SO: https://stackoverflow.com/questions/32788227/charindex-vs-like-search-gives-very-different-performance-why
[6] SqlServerCompositeMethodCallTranslator.cs: https://github.com/aspnet/EntityFrameworkCore/blob/dev/src/EFCore.SqlServer/Query/ExpressionTranslators/Internal/SqlServerCompositeMethodCallTranslator.cs
[7] тут: https://github.com/aspnet/EntityFrameworkCore/blob/dev/src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs
[8] #11316: https://github.com/aspnet/EntityFrameworkCore/issues/11316
[9] тут: https://github.com/aspnet/EntityFrameworkCore/blob/c78637261b0114650b5fe08867cdfcadf450730b/src/EFCore.SqlServer/Query/Sql/Internal/SqlServerQuerySqlGenerator.cs#L48
[10] здесь: https://github.com/elepner/ef-experiments
[11] Источник: https://habrahabr.ru/post/351556/?utm_campaign=351556
Нажмите здесь для печати.