Реализуем свой оператор в Entity Framework Core +34


Однажды пасмурным мартовским субботним утром я решил посмотреть, как обстоят дела у Майкрософта в благом деле по трансформированию мастодонта 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%'.


Сильное заявление

image


Этот пост на 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.




К сожалению, не доступен сервер mySQL