Предисловие

Ох, наконец-то можно про родные бекенды пописать, а не вот эти ваши жабаскрипты.

Впрочем, оставим лирику в стороне.

Проблема у меня появилась такая: данные сущности ввиду невозможности применения строгой схемы хранятся в jsonb. И тут приходит начальник/аналитик/заказчик/кто-угодно и говорит: “А давай-ка мы сделаем поиск в админке для операторов”.

Сказано — сделано.

Подразумевается использование гема Ransack, так как в моём случае на нём уже был накручен поиск на обычные поля сущности, а мне нужно было делать поиск по содержимому jsonb-поля.

Условная модель

Предположим, что у нас сервис управления заказами пиццы. Каждый заказ имеет строго одного заказчика, но может включать в себя несколько продуктов. Каждый продукт имеет свой адрес доставки.

Json пусть лежит в jsonb-колонке params.

Таким образом условный json может выглядеть так:

{
  "client": {
    "firstName": "Василий",
    "lastName": "Пупкин"
  },
  "products": [
    {
      "type": "Пепперони",
      "address": {
        "street": "Ленина",
        "house": "15 корп. 1",
        "flat": "122"
      }
    },
    {
      "type": "Стромболи",
      "address": {
        "street": "К. Маркса",
        "house": "22 стр. 3",
        "flat": "67"
      }
    }
  ]
}

Простой случай: поиск по имени и фамилии

Почему простой? Потому что тут действительно не надо ничего хитрого.

Достаточно сходить в модель и добавить вот такой рансакер:

# app/models/order.rb
ransacker :firstname do
  Arel.sql("params->'client'->>'firstName'")
end

И дальше можно пользоваться рансаком как в любых других ситуациях:

-# app/views/admin/orders/index.haml
= search_form_for @q, do |f|
  = f.input :firstname_eq

Более заморочный случай: поиск по квартире

Начну с квартиры только лишь потому, что поиск по квартире осуществляется не по частичному совпадению, а по полному.

Но уже здесь начинаются проблемы. Точнее проблема (она одна, зато какая): данные хранятся в массиве.

Для меня самым простым способом оказался поиск с помощью оператора @> (то есть включение одного jsonb-объекта в другой).

SQL был бы примерно таким (пишу вслепую, поэтому могу ошибиться):

SELECT * FROM orders WHERE params->'products' @> '[{"address": {"flat": "67"}}]'

Добавляем оператор @>

Проблема в том, что в рансаке нет оператора @>. Но его можно добавить. Для этого в config/initializers/arel.rb надо добавить следующее содержимое:

module Arel::Predications
  def matches_json(right)
    Arel::Nodes::MatchesJson.new(self, quoted_node(right))
  end
end

class Arel::Nodes::MatchesJson < Arel::Nodes::Binary
  def operator
    :"@>"
  end
end

class Arel::Visitors::PostgreSQL
  private

  def visit_Arel_Nodes_MatchesJson(o, collector)
    infix_value o, collector, " #{Arel::Nodes::MatchesJson.new(nil, nil).operator} "
  end
end

Используем новый оператор

Тут начинается костылезация.

Для начала напишем рансакер:

ransacker :flat, formatter: proc { |value| "[{\"address\": {\"flat\": \"#{value}\"}}]" } do
  Arel.sql("params->'products")
end

Тут мы формируем в формате тот самый json, который должен быть поддеревом содержимого поля params. Ну, такое.

А теперь самое неприятное — код шаблона с формой:

-# app/views/admin/orders/index.haml
= search_form_for @q, do |f|
  = f.input :firstname_eq
  = f.input :flat_matches_json

То есть больше нельзя использовать рансаковые суффиксы (_eq, _cont и проч.), только лишь _matches_json. Получается мало того, что жуткий хардкод, так ещё и размазанный по рансакерам и шаблонам.

Ну и очевидно, что так можно искать только по полному совпадению поля (нет возможности запихнуть LIKE).

Вобщем, подход работает, но выглядит это так себе. Да и, подозреваю (хотя не измерял), из-за поиска поддерева в дереве скорость должна страдать.

Поиск по улице

И вот тут начинается самое интересное. Из-за того что искать нужно по частичному совпадению (начальник так сказал) предыдущий способ нам не подходит.

Честно признаюсь, я не эксперт в СУБД (а надо бы по-хорошему). Но гугл мне подсказал: надо использовать LATERAL-запрос. Приведу SQL:

SELECT * FROM orders, LATERAL jsonb_array_elements(orders.params->'products') AS products WHERE products->'address'->>'street' = 'Маркс';

Самым (не)интересным для меня было запихивание этого всего в рансак.

Покопавшись минут 15 в Arel и Ransack я понял, что всунуть это дело красивенько в рансак не получится. Пришлось пилить сомнительные костыли.

Добавляем LATERAL-выборку

Для этого надо добавить в модель scope:

scope :with_products, -> { from("orders, LATERAL jsonb_array_elements(orders.params->'products') as products") }

Делаем рансакер

ransacker :street do
  Arel.sql("products->'address'->>'street'")
end

Используем

-# app/views/admin/orders/index.haml
= search_form_for @q, do |f|
  = f.input :firstname_eq
  = f.input :flat_matches_json
  = f.input :street_cont

Как видно, в отличие от предыдущего метода тут уже можно использовать суффиксы рансака. И выглядит код более человечно.

Однако, теперь везде, где используется поиск по этому полю надо использовать новый scope, который замедляет выборку вне зависимости от того, нужно нам искать по полям в продукте или нет.

Выводы

Я бы не назвал это “дружбой”, но это хотя бы работает. Так что если у вас на проекте (как у меня) приходится использовать jsonb, то выход (хоть какой-то) есть.