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

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


  1. dotnetdonik
    21.03.2018 09:23
    +1

    В Ef core 2.0 есть возможность мапить функции из коробки без необходимости расширять библиотеку.
    http://anthonygiretti.com/2018/01/11/entity-framework-core-2-scalar-function-mapping/


    1. elepner Автор
      21.03.2018 10:52

      Интересная фича, спасибо за наводку. Не думаю, что ее бы хватило для поддержки `CONTAINS` как минимум из-за того, что `DefaultQuerySqlGenerator` не достаточно универсален и проблема с `= 1` никуда бы не ушла (#9143). Но надо обязательно попробовать этот функционал.


  1. tarakanoff
    21.03.2018 09:35
    +1

    Спасибо за статью, ее очень не хватало. К слову, еще один большой баг EF Core в том, что он выполняет запросы при сборке выражения не ожидания вызовов ToList, ToListAsync и т.д.


    1. dotnetdonik
      21.03.2018 09:47

      Эта возможность есть и в Ef 6 platform кстати.


    1. mayorovp
      21.03.2018 09:54

      Это вообще как?


      1. lumini
        21.03.2018 10:01

        Плюс к вопросу. Никогда не замечал такого поведения. На 2.0 точно.


      1. 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.


        1. elepner Автор
          21.03.2018 10:46
          +4

          Да, очень бесит это «улучшение». Если EF6 не может что-то трансировать, то кидает исключение, что мне и надо. Это значит я написал кривой запрос и его надо пофиксать или переделать логику и явно написать `foreach`. EFC молча проглатывает все и исполняет локально. Нет, спасибо, не надо мне такой услуги.


          1. Dansoid
            22.03.2018 18:37

            Я видел что такие финты EF Core проделывает с группировкой. Записи вполне могут прогруппироваться на клиенте. Я бы очень внимательно следил за SQL которые в конце концов генерятся.


  1. Dansoid
    21.03.2018 16:02
    -2

    Да, конечно, вижу изврат на ровнм месте. Действительно уже кучу раз могли придумать поддержку кастомных функций, об кастомных агрегатах и оконных функциях я вообще молчу.
    Ну что же, я рад чо у нас в linq2db это занимает ровно один чих:
    github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Sql/Sql.cs#L468


    1. mayorovp
      21.03.2018 16:12
      +1

      Обычные функции что в EF, что в EF Core делаются столь же просто.


      1. Dansoid
        22.03.2018 14:29

        Ах предикат. Да какой угодно.
        github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Sql/Sql.cs#L103


  1. neyroneyro
    21.03.2018 22:49

    Спасибо за статью.
    Задумался, как написать поддержку такого метода расширения, как например
    db.Goods.DeleteAsync(x => x.Price > 2000).


    1. neyroneyro
      21.03.2018 22:57

      Всмысле, метода расширения для IQueryable для генерации Delete запроса.


      1. elepner Автор
        22.03.2018 00:47

        Не уверен, что это возможно. Такая функциональность противоречит идеологии фреймворка. Закрепление изменений происходит во время вызова метода SaveChanges(). EF и EFC хранят граф объектов и следят за изменениями. По накопленным изменениям генерируются UPDATE, INSERT, DELETE. Каким образом DeleteAsync превратится в граф объектов? Вы, конечно, можете написать простую логику:


        1. Загрузить все объекты x.Price > 2000
        2. Удалить их. Но это будет ужасный оверхед.

        Вообще, знающие люди говорят, что Bulk Operations и ORM это из разных областей.


        1. Dansoid
          22.03.2018 14:45

          Вы путаете change tracking с самим понятием ORM. Легковесные ORM это делают из коробки. Им совсем не обязательно тянуть запись с сервера чтобы ее изменить или удалить. Из-за таких мелких выборок серьезно проседает SQL сервер в высоконагруженых системах.