Запрос и порядок по количеству совпадений в массиве JSON
использование массивов JSON в jsonb
столбец в Postgres 9.4 и Rails, я могу настроить область, которая возвращает все строки, содержащие любой элементы из массива, переданного в метод scope-например:
scope :tagged, ->(tags) {
where(["data->'tags' ?| ARRAY[:tags]", { tags: tags }])
}
Я также хотел бы заказать результаты на основе количества совпадающих элементов в массиве.
Я ценю, что мне, возможно, придется выйти за пределы ActiveRecord, чтобы сделать это, поэтому ванильный ответ Postgres SQL тоже полезен, но бонусные баллы, если он может быть завернут в ActiveRecord, чтобы он мог быть цепным.
по запросу, вот пример таблицы. (Фактическая схема намного сложнее, но это все, о чем я беспокоюсь.)
id | data
----+-----------------------------------
1 | {"tags": ["foo", "bar", "baz"]}
2 | {"tags": ["bish", "bash", "baz"]}
3 |
4 | {"tags": ["foo", "foo", "foo"]}
прецедент заключается в поиске связанного контента на основе тегов. Более подходящие теги более релевантны, поэтому результаты должны быть упорядочены по количеству совпадений. В Ruby у меня был бы простой метод:
Page.tagged(['foo', 'bish', 'bash', 'baz']).all
который должен вернуть страницы в следующем порядке: 2, 1, 4
.
2 ответов
ваши массивы содержат только примитивных значений вложенные документы будут более сложными.
запрос
Unnest массивы JSON найденных строк с jsonb_array_elements_text()
на LATERAL
регистрация и подсчет матчей:
SELECT *
FROM (
SELECT *
FROM tbl
WHERE data->'tags' ?| ARRAY['foo', 'bar']
) t
, LATERAL (
SELECT count(*) AS ct
FROM jsonb_array_elements_text(t.data->'tags') a(elem)
WHERE elem = ANY (ARRAY['foo', 'bar']) -- same array parameter
) ct
ORDER BY ct.ct DESC; -- more expressions to break ties?
альтернативный вариант INSTERSECT
. Это один из редких случаев, когда мы можем использовать эту базовую функцию SQL:
SELECT *
FROM (
SELECT *
FROM tbl
WHERE data->'tags' ?| '{foo, bar}'::text[] -- alt. syntax w. array
) t
, LATERAL (
SELECT count(*) AS ct
FROM (
SELECT * FROM jsonb_array_elements_text(t.data->'tags')
INTERSECT ALL
SELECT * FROM unnest('{foo, bar}'::text[]) -- same array literal
) i
) ct
ORDER BY ct.ct DESC;
Примечание a тонкие разница: это потребляет каждый элемент при сопоставлении, поэтому он не учитывает непревзойденные дубликаты в data->'tags'
как и первый вариант совсем. Подробнее см. демо ниже.
также демонстрирует альтернативный способ передачи параметра-массива: как литерал массива: '{foo, bar}'
. Это может быть проще обрабатывать для некоторые клиенты:
или вы можете создать функцию поиска на стороне сервера, взяв VARIADIC
параметр и передать переменное число plain text
значения:
по теме:
индекс
обязательно функциональные Джин индекс в поддержку jsonb
оператор существование ?|
:
CREATE INDEX tbl_dat_gin ON tbl USING gin (data->'tags');
- индекс для поиска элемента в массиве JSON
- каков правильный индекс для запроса структур в массивах в Postgres jsonb?
нюансы с дубликатами
разъяснить, за запрос в комментарии. Скажем, у нас есть массив JSON с два дублированные теги (всего 4):
jsonb '{"tags": ["foo", "bar", "foo", "bar"]}'
и поиск с параметром массива SQL, включая и теги один из них дублированные (всего 3):
'{foo, bar, foo}'::text[]
рассмотрим результаты этой демонстрации:
SELECT *
FROM (SELECT jsonb '{"tags":["foo", "bar", "foo", "bar"]}') t(data)
, LATERAL (
SELECT count(*) AS ct
FROM jsonb_array_elements_text(t.data->'tags') e
WHERE e = ANY ('{foo, bar, foo}'::text[])
) ct
, LATERAL (
SELECT count(*) AS ct_intsct_all
FROM (
SELECT * FROM jsonb_array_elements_text(t.data->'tags')
INTERSECT ALL
SELECT * FROM unnest('{foo, bar, foo}'::text[])
) i
) ct_intsct_all
, LATERAL (
SELECT count(DISTINCT e) AS ct_dist
FROM jsonb_array_elements_text(t.data->'tags') e
WHERE e = ANY ('{foo, bar, foo}'::text[])
) ct_dist
, LATERAL (
SELECT count(*) AS ct_intsct
FROM (
SELECT * FROM jsonb_array_elements_text(t.data->'tags')
INTERSECT
SELECT * FROM unnest('{foo, bar, foo}'::text[])
) i
) ct_intsct;
результат:
data | ct | ct_intsct_all | ct_dist | ct_intsct
-----------------------------------------+----+---------------+---------+----------
'{"tags": ["foo", "bar", "foo", "bar"]}' | 4 | 3 | 2 | 2
сравнение элементов в массиве JSON с элементами в параметре array:
-
4 теги соответствуют любому из поиска элементы:
ct
. -
3 теги в наборе пересечение (можно соответствовать элемент-к-элементу):
ct_intsct_all
. -
2 distinct соответствующие теги могут быть выявлены:
ct_dist
илиct_intsct
.
если у вас нет дураков или если вы не хотите их исключать, используйте один из первых двух методов. Другие два немного медленнее (кроме другого результата), потому что они должны проверить на дураков.
я публикую детали моего решения в Ruby, в случае, если это полезно для тех, кто решает ту же проблему.
В конце концов я решил, что область не подходит, поскольку метод вернет массив объектов (не цепной ActiveRecord::Relation
), поэтому я написал метод класса и предоставил способ передать ему цепную область через блок:
def self.with_any_tags(tags, &block)
composed_scope = (
block_given? ? yield : all
).where(["data->'tags' ?| ARRAY[:tags]", { tags: tags }])
t = Arel::Table.new('t', ActiveRecord::Base)
ct = Arel::Table.new('ct', ActiveRecord::Base)
arr_sql = Arel.sql "ARRAY[#{ tags.map { |t| Arel::Nodes::Quoted.new(t).to_sql }.join(', ') }]"
any_tags_func = Arel::Nodes::NamedFunction.new('ANY', [arr_sql])
lateral = ct
.project(Arel.sql('e').count(true).as('ct'))
.from(Arel.sql "jsonb_array_elements_text(t.data->'tags') e")
.where(Arel::Nodes::Equality.new Arel.sql('e'), any_tags_func)
query = t
.project(t[Arel.star])
.from(composed_scope.as('t'))
.join(Arel.sql ", LATERAL (#{ lateral.to_sql }) ct")
.order(ct[:ct].desc)
find_by_sql query.to_sql
end
Это можно использовать так:
Page.with_any_tags(['foo', 'bar'])
# SELECT "t".*
# FROM (
# SELECT "pages".* FROM "pages"
# WHERE data->'tags' ?| ARRAY['foo','bar']
# ) t,
# LATERAL (
# SELECT COUNT(DISTINCT e) AS ct
# FROM jsonb_array_elements_text(t.data->'tags') e
# WHERE e = ANY(ARRAY['foo', 'bar'])
# ) ct
# ORDER BY "ct"."ct" DESC
Page.with_any_tags(['foo', 'bar']) do
Page.published
end
# SELECT "t".*
# FROM (
# SELECT "pages".* FROM "pages"
# WHERE pages.published_at <= '2015-07-19 15:11:59.997134'
# AND pages.deleted_at IS NULL
# AND data->'tags' ?| ARRAY['foo','bar']
# ) t,
# LATERAL (
# SELECT COUNT(DISTINCT e) AS ct
# FROM jsonb_array_elements_text(t.data->'tags') e
# WHERE e = ANY(ARRAY['foo', 'bar'])
# ) ct
# ORDER BY "ct"."ct" DESC