关于rails上的ruby:按JSON数组中的匹配数查询和排序

Query and order by number of matches in JSON array

在Postgres 9.4和Rails的jsonb列中使用JSON数组,我可以设置一个范围,返回包含传递给范围方法的数组中的任何元素的所有行 - 如下所示:

1
2
3
scope :tagged, ->(tags) {
  WHERE(["data->'tags' ?| ARRAY[:tags]", { tags: tags }])
}

我还想根据数组中匹配元素的数量来排序结果。

我很欣赏我可能需要超出ActiveRecord的范围才能做到这一点,所以一个vanilla Postgres SQL的答案也很有用,但如果它可以包含在ActiveRecord中,那么它可以是一个可链的范围。

根据要求,这是一个示例表。 (实际架构要复杂得多,但这就是我所关心的。)

1
2
3
4
5
6
 id |               DATA                
----+-----------------------------------
  1 | {"tags": ["foo","bar","baz"]}
  2 | {"tags": ["bish","bash","baz"]}
  3 |
  4 | {"tags": ["foo","foo","foo"]}

用例是基于标签查找相关内容。 更多匹配标签更相关,因此结果应按匹配数量排序。 在Ruby中,我有一个这样的简单方法:

1
Page.tagged(['foo', 'bish', 'bash', 'baz']).all

哪个应按以下顺序返回页面:2, 1, 4


您的数组只包含原始值,嵌套文档会更复杂。

询问

LATERAL连接和计数匹配中使用jsonb_array_elements_text()取消找到的行的JSON数组:

1
2
3
4
5
6
7
8
9
10
11
12
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功能的极少数情况之一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;

注意一个微妙的区别:这会在匹配时消耗每个元素,因此它不像第一个变体那样计算data->'tags'中不匹配的重复项。详情请见下面的演示。

还演示了传递数组参数的另一种方法:as array literal:'{foo, bar}'。对于某些客户来说,这可能更容易处理:

  • PostgreSQL:将数组传递给过程的问题

或者,您可以使用VARIADIC参数创建服务器端搜索功能,并传递可变数量的普通text值:

  • 在单个参数中传递多个值

有关:

  • 检查带有PL / pgSQL的JSON中是否存在密钥?

指数

一定要有一个功能GIN索引来支持jsonb存在运算符?|

1
CREATE INDEX tbl_dat_gin ON tbl USING gin (data->'tags');
  • 用于在JSON数组中查找元素的索引
  • 在Postgres jsonb中查询数组结构的正确索引是什么?

有重复的细微差别

根据评论中的要求进行澄清。比如,我们有一个带有两个重复标签的JSON数组(共4个):

1
jsonb '{"tags": ["foo","bar","foo","bar"]}'

并使用包含两个标记的SQL数组参数进行搜索,其中一个重复(总共3个):

1
'{foo, bar, foo}'::text[]

考虑一下这个演示的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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;

结果:

1
2
3
DATA                                     | ct | ct_intsct_all | ct_dist | ct_intsct
-----------------------------------------+----+---------------+---------+----------
'{"tags": ["foo","bar","foo","bar"]}' | 4  | 3             | 2       | 2

将JSON数组中的元素与数组参数中的元素进行比较:

  • 4个标签与任何搜索元素匹配:ct
  • 集合中的3个标签相交(可以匹配元素到元素):ct_intsct_all
  • 可以识别2个不同的匹配标签:ct_distct_intsct

如果您没有欺骗或者您不想排除它们,请使用前两种技术之一。另外两个有点慢(除了不同的结果),因为他们必须检查欺骗。


我在Ruby中发布我的解决方案的详细信息,以防它对处理同一问题的任何人都有用。

最后我决定一个范围是不合适的,因为该方法将返回一个对象数组(不是一个可链接的ActiveRecord::Relation),所以我编写了一个类方法,并提供了一种将链式范围传递给它的方法通过一个块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

这可以这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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