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

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

    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 22
      +3

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

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

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

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

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

                +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.
                  +5
                  Да, очень бесит это «улучшение». Если EF6 не может что-то трансировать, то кидает исключение, что мне и надо. Это значит я написал кривой запрос и его надо пофиксать или переделать логику и явно написать `foreach`. EFC молча проглатывает все и исполняет локально. Нет, спасибо, не надо мне такой услуги.
                    0
                    Я видел что такие финты EF Core проделывает с группировкой. Записи вполне могут прогруппироваться на клиенте. Я бы очень внимательно следил за SQL которые в конце концов генерятся.
                      +1

                      Можно же:
                      protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
                      {
                      optionsBuilder
                      .UseSqlServer(...)
                      .ConfigureWarnings(warnings => warnings.Throw(RelationalEventId.QueryClientEvaluationWarning));
                      }

                      0
                      Тоже натыкался на такое поведение. Решил довольно просто: OrderBy — должен быть последним в дереве IQueryable. Точнее даже не так — запрос собирается до первого встреченного OrderBy, остальное выполняется на клиенте.
                      Я имею ввиду вот такие случаи:
                      var list = dbContext.Persons.where(predicate).orderby(order);
                      if(filter.value.hasvalue)
                      {
                      list = list.where(f=>f.someField == filter.value);
                      }


                      Пришлось от такого избавиться и переставить сортировку в конце.
                      var list = dbContext.Persons.where(predicate);
                      if(filter.value.hasvalue)
                      {
                      list = list.where(f=>f.someField == filter.value);
                      }
                      list = list.orderby(order);

                        0
                        Спасибо за подсказку.
                  –3
                  Да, конечно, вижу изврат на ровнм месте. Действительно уже кучу раз могли придумать поддержку кастомных функций, об кастомных агрегатах и оконных функциях я вообще молчу.
                  Ну что же, я рад чо у нас в linq2db это занимает ровно один чих:
                  github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Sql/Sql.cs#L468
                  0
                  Спасибо за статью.
                  Задумался, как написать поддержку такого метода расширения, как например
                  db.Goods.DeleteAsync(x => x.Price > 2000).
                    0
                    Всмысле, метода расширения для IQueryable для генерации Delete запроса.
                      0

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


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

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

                        +1
                        Вы путаете change tracking с самим понятием ORM. Легковесные ORM это делают из коробки. Им совсем не обязательно тянуть запись с сервера чтобы ее изменить или удалить. Из-за таких мелких выборок серьезно проседает SQL сервер в высоконагруженых системах.
                          0
                          Для тех кому надо было пачку обьектов удалять или апдейтть или копировать в другую таблицу или… Добро пожаловать в тестеры!
                          linq2db.EntityFrameworkCore
                        +1
                        Вот есть такой проект: www.zzzprojects.com
                        В частности, entityframework-plus.net
                        — Delete without loading entities
                        — Update without loading entities

                        Я использую это в bulk actions.
                        Конечно же принципиально быстрее отрабатывает, без подгрузки записей.
                        Все ок, если принять и иметь в виду тот факт, что такие действия не обновляют загруженные в DbContext объекты.
                          0
                          Наверное я вас не отговорю если скажу, что код этих расширений ужасен и иногда опасен.
                          Я провел некоторое время с их кодом, разбираясь как они интегрировались. Это печаль. К сожалению они уже закрыли сурцы, придется им дальше платить без оглядки.
                          Но если, все же, если я смог закласть зерна сомнения, попробуйте нашу итеграцию linq2db.EntityFrameworkCore. К релизу осталось пару дней, но пробовать можно уже.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое