Запрос и порядок по количеству совпадений в массиве 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 с два дублированные теги (всего 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