按JSON数组中的匹配数查询和排序
在Postgres 9.4和Rails的jsonb
列中使用JSON数组,我可以设置一个范围,返回包含传递给范围方法的数组中的任何元素的所有行 – 如下所示:
scope :tagged, ->(tags) { where(["data->'tags' ?| ARRAY[:tags]", { tags: tags }]) }
我还想根据数组中匹配元素的数量来排序结果。
我很欣赏我可能需要超出ActiveRecord的范围才能做到这一点,所以一个vanilla 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
。
您的数组只包含原始值 ,嵌套文档会更复杂。
询问
在LATERAL
连接和计数匹配中使用jsonb_array_elements_text()
找到的行的JSON数组:
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
。 这是我们可以使用这个基本SQLfunction的极少数情况之一:
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'
中不匹配的重复项data->'tags'
第一个变体的data->'tags'
。 有关详情,请参阅下面的演示。
还演示了传递数组参数的另一种方法:as array literal: '{foo, bar}'
。 对于某些客户来说,这可能更容易处理:
- PostgreSQL:将数组传递给过程的问题
或者您可以使用VARIADIC
参数创建服务器端搜索function,并传递可变数量的纯text
值:
- 在单个参数中传递多个值
有关:
- 检查带有PL / pgSQL的JSON中是否存在密钥?
指数
一定要有一个函数GIN索引来支持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数组中的元素与数组参数中的元素进行比较:
- 4个标签符合任何搜索要素:
ct
。 - 集合中的3个标签相交 (可以匹配元素到元素):
ct_intsct_all
。 - 可以识别2个 不同的匹配标签:
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