PostgreSQL имеет тип данных Jsonb, который позволяет добавлять к стандартной реляционной модели дополнительные свойства с возможностью поиска по ним.


EntityFramework Core с расширением Npgsql умеет вытягивать данные поля в тип System.String


Однако для фильтрации по Json свойствам через EF на уровне запросов приходится использовать чистый SQL, что не очень то удобно, так как нужно лезть в маппинг (если он не автоматический), искать названия полей, соответствующих свойствам моделей, поддерживать это именование. Пропадает гибкость, которую нам дает ORM.


Если вас это угнетает, так же как и меня, добро пожаловать под кат.


В конце статьи имеется ссылка на исходники!


Обозначим задачи


Я, как разработчик, хочу иметь инструмент для доступа к полям Jsonb с целью фильтрации и сортировки по ним, который:


  • Будет совместим с EntityFramework Core 2 (мы используем именно его)
  • Не потребует во время работы с ним самому писать SQL
  • Будет работать с плоской структурой Json (внутри json есть только json свойства)

Добавлю еще, что есть Npgsql.Json.NET, который умеет проецировать Json и Jsonb значения в CLR типы. Если честно, не понимаю, для чего он может понадобиться, ведь раз у нас появилась необходимость в Json поле в реляционной БД, вероятнее всего у нас есть сущности с динамическим набором полей.


Алгоритм решения задачи


  1. Определить метод (или методы), который будет покрывать наши потребности.
  2. Создать транслятор, который будет участвовать в генерации SQL кода.
  3. Прикрутить все это к Npgsql.

Решение


Для начала определим метод. Хочу, чтобы он использовался как то так:


context.Entity.Where(x => JsonbMethods.Value<string>(x.JsonbField, "jsonPropertyName") == "value")

Следовательно, вот наш метод:


public static TSource Value<TSource>(object jsonbProperty, string jsonbPropertyName)
{
    throw new NotSupportedException();
}

Я несколько часов ковырял исходники EF Core, Npgsql и не только в поисках способов расширить базовый функционал генерации SQL. Добрался вот до этой статьи, но подход автора по способу подключения транслятора мне не понравился, ведь он переопределяет стандартный инструмент, а значит может конфликтовать с другим похожим инструментом.
В итоге добрался до исходников Net Topology Suite. Все, что мне оттуда потребовалось, это способ подключения транслятора методов.


Но больше всего времени я потратил на то, чтобы сформировать нужный мне фрагмент sql.


Вот требуемый синтаксис


tableAlias."JsonField"->>"insideProperty"


Сначала я пробовал в трансляторе возвращать ColumnExpression. Первым параметром при его создании идет имя столбца (string). Я просто состряпал его из параметров, которые мне приходят в метод. Запустил, проверил, ошибка. Оказывается, то, что я передаю в качестве имени, оборачивается в кавычки. В итоге SQLполучился таким tableAlias.""JsonField"->>"insideProperty"".


В исходниках генератора я нашел метод VisitColumn, в котором это поведение было хардкодным и не зависело ни от каких параметров. То есть я не мог на это повлиять. Нужно было искать другое решение.


Тогда я создал собственный ExpressionJsonbPropertyAccessorExpression: Expression


Осталось переопределить его метод Accept для ISqlExpressionVisitor.


Но вот беда, в данном интерфейсе нет метода, который бы мог сегнерировать кастомный оператор. Тогда мне пришла в голову мысль посетить не один метод, а несколько. Посетил сначала VisitColumn, который создал доступ к столбцу tableAlias.«JsonField», затем VisitSqlFragment, в который я прокинул "->>'insideFieldName'".


Я и не надеялся, но все заработало. Почти.


Когда я пытался фильтровать по тексту по точному совпадению почему то формировался такой фильтр tableAlias."JsonField"->>"insideProperty" = JSONB "value", что вызывало ошибку, так как привести текст к типу JSONB нельзя, если там не содержится валидный Json. Да и зачем мне что-то к чему-то приводить, когда я хочу текст?


Я было даже принял решение из маппинга модели убрать пометку со столбца Jsonb, что это Jsonb, добавив только эту пометку в MigrationContext, чтобы он генерировал правильные миграции. И это даже взлетело, но подход показался мне костыльным. Тем не менее на этом я и остановился.


После этого я принялся за CAST, ведь метод Value у меня универсальный и в Json свойствах могут быть различные типы данных, по которым тоже нужно сортировать и фильтровать.


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


когда я посмотрел на получившийся SQL при поиске по дате, я обнаружил, что к сравниваемое значение приводится к типу timestamp. timestamp 'some date value'. И тут до меня дошло. Предыдущая проблема, которую я решил костылем, ушла сама собой. Когда аксессор к полю Json кастился в текст, генератор больше не добавлял явное преобразование в JSONB, ведь слева операции сравнения уже был текст, а по умолчанию аксессор поля Jsonb возвращает тип Jsonb.


В завершении


В завершении хочу добавить, что я не нашел документации по тому, как мне добавить кастомные трансляторы свойств и методов. наверное, плохо искал. Если у кого-то есть замечания по подходу, по коду и тд, пишите в комментариях.


Если кто-нибудь захочет расширить библиотеку в форках, пишите в личку, я постараюсь помочь. Ну или кидайте пулреквесты.


Вот ссылка на исходники

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


  1. Dansoid
    15.08.2019 20:37

    Думаю вам надо было сразу смотреть в исходники Npgsql.EntityFrameworkCore, где они добавляли поддержку Range.
    Очень надеюсь что это все не поломается в 3.0, кстати проверьте.

    Если станет совсем не в моготу запустить нужную выборку — попробуйте нашу зверушку (ну так как-то называем) linq2db.EntityFrameworkCore. Легким тюнингом, можно Json и в linq ганять, например при использовании LATERAL и jsonb_to_recordset.


    1. iRumba Автор
      16.08.2019 02:50

      Npgsql же провайдер с нуля. Они делали все на инфраструктуре ef core, а мне надо было вклиниться именно в их инфраструктуру.


      А можно живых примеров тюнинга? Просто я пока не могу оценить вашу зверушку, но про jsonb_to_recordset сразу могу сказать, это не то что нужно. У нас ведь динамический набор полей. Все, что я бы выиграл, это исключил необходимость танцев вокруг оператора ->>


      1. Dansoid
        16.08.2019 18:57

        Скажем так, в linq2db такие экстеншины пишутся с полпинка

        public static class Jsonb
        {
            [Sql.Expression("{0}->>{1}", ServerSideOnly = true, InlinePrameters = true)]
            public static T Value<T>(object field, string propName)
            {
                throw new NotImplementedException();
            }
        }
        


        А зверушка позволяет перенаправлять дерево выражений на наш Linq парсер. Да и по ссылке просто почитайте BulkCopy, Delete From, Update From, Insert From.
        То что EF от вас спрятал и заставил писать на хранимках или Dapper, вполне себе легко пишется на linq.

        Вот вполне себе может понадобиться апдейтнуть одно поле в Jsonb:

        ctx.SomeItems
           .Where(e => Jsonb.Value<string>(e.Field, "SomeValue") == "Old Value")
           .Set(e => e.Field, e => Jsonb.Set(e.Field, "SomeValue", "New Value"))
           .Update();
        


        Для этого конечно же надо добавить экстеншин

        public static class Jsonb
        {
            [Sql.Expression("jsonb_set({0}, {1}, {2})", ServerSideOnly = true, InlinePrameters = true)]
            public static T Set<T>(T field, string path, string newValue)
            {
                throw new NotImplementedException();
            }
        }

        Вот так, в обход ChangeTracker, мы изменили поле в нескольких записях не потянув ни одной записи.


        1. iRumba Автор
          16.08.2019 20:08

          Потрясно! Вам следует поработать над документацией и живыми примерами. Ну и еще приложить бенчмарки. Без этого зверушка будет темной лошадкой. Как будет время, обязательно поковыряю исходники и найду применение. Огромное спасибо за пример!
          Хотя вот с изменением одного свойства перебор. Когда работаешь с динамическим Jsonb, без чего нибудь типа newtonsoft.json не обойтись. А учитывая факт, что изменять одно свойство внутри json нельзя (postgre все равно перезапишет все поле), производительность будет играть роль. К тому же я могу просто пришедший с фронта json скастить в стрингу и обновить поле.
          Кстати, в примере с Set, новое значение типа string. А что, если я захочу datetime или int? Уверен, решение и на это найдется. дайте документацию! :)


          1. Dansoid
            16.08.2019 21:47

            Мой пример высосан из пальца :) Я не работал с Jsonb в Postgres, просто почитал документацию и решил что можно показать как это гибко.
            Если найдете то, что мы не можем превратить в SQL — вперед, создали issue в linq2db репозитории и я лично пошаманю.

            С документацией проблема, только начинаешь ее писать — бац реквест на фичу и, к сожалению, плохие мы писатели документации. Спасаеют ссылки на тесты, где человек может увидеть как это работает.

            Про типы не переживайте, есть механизм перехвата создания такого экспрешина, крутим как хотим без изменения основного кода. На этом, кстати, создана поддержка оконных функций — работает как часы.

            Есть тестовый проект с бенчмарками, так и не оформился в статью, возможно будет вторая реинкарнация на BenchmarkDotNet. Итог очевиден, мы быстрее EF Core, да и Dapper, несмотря на их синтетические тесты — так вот же.
            Весь цирк этих тестов выбрать тривиальную вещь и замапать, и никто не ганял это в потоках, с асинками.

            Библиотека заточена на самый что ни на есть перформанс, в полоть до того что инлайнить в SQL, а что параметрами прокидывать. И часто бывает, что лучше параметры выкинуть. Гонка за производительностью заставляет библиотеку быть аскетичной но предсказуемой.

            Вот и можете, кстати, стать одним из тестеров зверушки, так как мы не работам с EF и поддержали только то что увидели. Но то что у нас SQL лучше и эффективней, я вам гарантирую, хотя бы по тому что библиотека развивается уже второе десятилетие людьми которые знают как готовить базы данных.


  1. SonicGD
    16.08.2019 08:47

    Буквально в последнем превью для .net core 3.0 добавили работу с json/jsonb полями — www.npgsql.org/efcore/mapping/json.html


    1. iRumba Автор
      16.08.2019 08:59

      Я не упоминал в статье нативную поддержку в 3.0, но я упоминал плагин Npgsql.Json.Net, который умеет только мапить json поле в тип Clr. И я объяснил, что там, где есть динамика, статическая типизация не подходит. А jsonb позволяет дополнять сущность дополнительными атрибутами. Как я создам Clr модели на динамические сущности? Разве что закопаться в IL инъекции и рефлексию. Но это будет дорого поддерживать.

      В общем мой инструмент решает другую задачу. Есть сущность, у нее есть ряд свойств, которые добавляет/удаляет пользователь. И он хочет по этим полям вести поиск и сортировку. То есть у меня нет возможности выбрать .Where(e => e.Customer.Name == «Joe») как в примере по ссылке, потому что на момент компиляции приложение ничего не знает про состав jsonb поля.