SQL 查询以查找具有特定关联数的行

SQL query to find a row with a specific number of associations

使用 Postgres 我有一个包含 conversationsconversationUsers 的模式。每个 conversation 有很多 conversationUsers。我希望能够找到具有确切指定数量 conversationUsers 的对话。换句话说,如果提供了一个 userIds(比如 [1, 4, 6])的数组,我希望能够找到仅包含那些用户的对话,而不是更多。

到目前为止我试过这个:

SELECT c."conversationId"
FROM "conversationUsers" c
WHERE c."userId" IN (1, 4)
GROUP BY c."conversationId"
HAVING COUNT(c."userId") = 2;

不幸的是,这似乎也 return 包括这 2 个用户在内的对话。 (例如,如果对话还包含 "userId" 5,则结果为 return)。

您可以像这样修改您的查询,它应该可以工作:

SELECT c."conversationId"
FROM "conversationUsers" c
WHERE c."conversationId" IN (
    SELECT DISTINCT c1."conversationId"
    FROM "conversationUsers" c1
    WHERE c1."userId" IN (1, 4)
    )
GROUP BY c."conversationId"
HAVING COUNT(DISTINCT c."userId") = 2;

这可能更容易理解。你想要对话 ID,按它分组。添加 HAVING 子句,根据匹配用户 ID 的总和等于组内所有可能的用户 ID。这会起作用,但由于没有预限定符,处理时间会更长。

select
      cu.ConversationId
   from
      conversationUsers cu
   group by
      cu.ConversationID
   having 
      sum( case when cu.userId IN (1, 4) then 1 else 0 end ) = count( distinct cu.UserID )

为了进一步简化列表,请预先查询至少有一个人参与的对话...如果他们一开始就不参与,为什么还要考虑其他此类对话。

select
      cu.ConversationId
   from
      ( select cu2.ConversationID
           from conversationUsers cu2
           where cu2.userID = 4 ) preQual
      JOIN conversationUsers cu
         preQual.ConversationId = cu.ConversationId
   group by
      cu.ConversationID
   having 
      sum( case when cu.userId IN (1, 4) then 1 else 0 end ) = count( distinct cu.UserID )

这是 的情况 - 添加了特殊要求,即同一对话不得有 其他 用户。

假设是table"conversationUsers"的PK,它强制组合的唯一性,NOT NULL并且还隐含地提供对性能至关重要的索引. 这个顺序的多列PK的列!否则你必须做更多。
关于索引列的顺序:

对于基本查询,有 "brute force" 方法来计算 all 对话的匹配用户数所有给定的用户,然后过滤匹配所有给定用户的用户。对于小 tables and/or 只有短输入数组 and/or 每个用户的对话很少,但是 扩展性不好 :

SELECT "conversationId"
FROM   "conversationUsers" c
WHERE  "userId" = ANY ('{1,4,6}'::int[])
GROUP  BY 1
HAVING count(*) = array_length('{1,4,6}'::int[], 1)
AND    NOT EXISTS (
   SELECT FROM "conversationUsers"
   WHERE  "conversationId" = c."conversationId"
   AND    "userId" <> ALL('{1,4,6}'::int[])
   );

使用 NOT EXISTS 反半连接消除与其他用户的对话。更多:

  • How do I (or can I) SELECT DISTINCT on multiple columns?

替代技术:

  • Select rows which are not present in other table

还有其他各种(快得多) 查询技术。但是最快的并不适合 动态 数量的用户 ID。

  • How to filter SQL results in a has-many-through relation

对于还可以处理动态数量的用户 ID 的快速查询,请考虑 recursive CTE:

WITH RECURSIVE rcte AS (
   SELECT "conversationId", 1 AS idx
   FROM   "conversationUsers"
   WHERE  "userId" = ('{1,4,6}'::int[])[1]

   UNION ALL
   SELECT c."conversationId", r.idx + 1
   FROM   rcte                r
   JOIN   "conversationUsers" c USING ("conversationId")
   WHERE  c."userId" = ('{1,4,6}'::int[])[idx + 1]
   )
SELECT "conversationId"
FROM   rcte r
WHERE  idx = array_length(('{1,4,6}'::int[]), 1)
AND    NOT EXISTS (
   SELECT FROM "conversationUsers"
   WHERE  "conversationId" = r."conversationId"
   AND    "userId" <> ALL('{1,4,6}'::int[])
   );

为了便于使用,将其包装在函数中或 prepared statement。喜欢:

PREPARE conversations(int[]) AS
WITH RECURSIVE rcte AS (
   SELECT "conversationId", 1 AS idx
   FROM   "conversationUsers"
   WHERE  "userId" = [1]

   UNION ALL
   SELECT c."conversationId", r.idx + 1
   FROM   rcte                r
   JOIN   "conversationUsers" c USING ("conversationId")
   WHERE  c."userId" = [idx + 1]
   )
SELECT "conversationId"
FROM   rcte r
WHERE  idx = array_length(, 1)
AND    NOT EXISTS (
   SELECT FROM "conversationUsers"
   WHERE  "conversationId" = r."conversationId"
   AND    "userId" <> ALL();

致电:

EXECUTE conversations('{1,4,6}');

db<>fiddle here(还演示了一个函数

仍有改进空间:要获得 top 性能,您必须将对话最少的用户放在输入数组中的第一位,以尽早消除尽可能多的行。为了获得最佳性能,您可以动态生成非动态、非递归查询(使用第一个 link 中的 fast 技术之一)并依次执行。您甚至可以将它包装在一个带有动态 SQL ...

的单个 plpgsql 函数中

更多解释:

  • Using same column multiple times in WHERE clause

备选:MV为稀疏写table

如果 table "conversationUsers" 大部分是只读的(旧对话不太可能改变),您可以使用 MATERIALIZED VIEW 和排序数组中的预聚合用户并创建一个该数组列上的普通 btree 索引。

CREATE MATERIALIZED VIEW mv_conversation_users AS
SELECT "conversationId", array_agg("userId") AS users  -- sorted array
FROM (
   SELECT "conversationId", "userId"
   FROM   "conversationUsers"
   ORDER  BY 1, 2
   ) sub
GROUP  BY 1
ORDER  BY 1;

CREATE INDEX ON mv_conversation_users (users) INCLUDE ("conversationId");

演示的覆盖索引需要 Postgres 11。参见:

关于对子查询中的行进行排序:

在旧版本中,在 (users, "conversationId") 上使用普通的多列索引。对于非常长的数组,散列索引在 Postgres 10 或更高版本中可能有意义。

那么更快的查询就是:

SELECT "conversationId"
FROM   mv_conversation_users c
WHERE  users = '{1,4,6}'::int[];  -- sorted array!

db<>fiddle here

您必须权衡存储、写入和维护的额外成本与读取性能的好处。

旁白:考虑不带双引号的合法标识符。 conversation_id 而不是 "conversationId" 等等:

  • Are PostgreSQL column names case-sensitive?