Дружим Ransack и jsonb
Предисловие
Ох, наконец-то можно про родные бекенды пописать, а не вот эти ваши жабаскрипты.
Впрочем, оставим лирику в стороне.
Проблема у меня появилась такая: данные сущности ввиду невозможности применения строгой схемы хранятся в 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, то выход (хоть какой-то) есть.