There is a set of users. A person can have multiple users, but ref1
and ref2
might be alike and can therefore link users together. ref1
and ref2
does not overlap, one value in ref1
does not exist in ref2
.
A user can own multiple assets. I want to "merge" users that has one or more refs alike and then count how many assets they own together. There could be missing entries in the user table, in that case I just want to propagate the owner into ref2 and set the asset_count and asset_ids.
Here is an example schema to illustrate:
Example assets
SELECT * FROM assets;
id | name | owner |
---|---|---|
1 | #1 | a |
2 | #2 | b |
3 | #3 | c |
4 | #4 | a |
5 | #5 | c |
6 | #6 | d |
7 | #7 | e |
8 | #8 | d |
9 | #9 | a |
10 | #10 | a |
11 | #11 | z |
Example users
SELECT * FROM users;
id | username | ref1 | ref2 |
---|---|---|---|
1 | bobo | a | d |
2 | toto | b | e |
3 | momo | c | d |
4 | lolo | a | f |
5 | popo | c | f |
What I want to get in the end
SELECT * FROM results;
ids | usernames | refs1 | refs2 | asset_ids | asset_count |
---|---|---|---|---|---|
1,3,4,5 | bobo,momo,lolo,popo | a,c | d,f | 1,3,4,5,6,8,9,10 | 8 |
2 | toto | b | e | 2,7 | 2 |
z | 11 | 1 |
I've tried different approaches, but this is what I currently have:
Closest I have got
SELECT
ARRAY_AGG(DISTINCT u.id) AS ids,
ARRAY_AGG(DISTINCT u.username) AS usernames,
ARRAY_AGG(DISTINCT u.ref1) AS refs1,
ARRAY_AGG(DISTINCT u.ref2) AS refs2,
COUNT(DISTINCT a.id) AS asset_count
FROM assets a
JOIN users u ON a.owner = u.ref1 OR a.owner = u.ref2
GROUP BY a.owner
ORDER BY MIN(a.id);
ids | usernames | refs1 | refs2 | asset_count |
---|---|---|---|---|
1,4 | bobo,lolo | a | d,f | 4 |
2 | toto | b | e | 1 |
3,5 | momo,popo | c | d,f | 2 |
1,3 | bobo,momo | a,c | d | 2 |
2 | toto | b | e | 1 |
If I merge the above table on ids, I almost get the result I want (without the missing entries in the user table). The merging can easily be done in code, but then I cannot paginate etc. I want to to this in DB layer if possible.
I want either a solution to the problem or a good explanation of why it is not possible to do (with examples).
Please check out my DB Fiddle.
There are two distinct parts to the question:
Identifying clusters of users based on common references reads like a graph-walking problem. That's a complex task in SQL, and requires a recursive query. The pattern is to unpivot users' references to generate nodes, then identify edges (nodes that have a ref in common), and finally walk through the graph (whitout looping) to generate groups.
In Postgres, arrays come handy to aggregate nodes:
with recursive
nodes as (
select u.id, r.ref
from users u
cross join lateral ( values (u.ref1), (u.ref2) ) r(ref)
),
edges as (
select distinct n1.id as id1, n2.id as id2
from nodes n1
inner join nodes n2 on n1.ref = n2.ref
),
rcte as (
select id1, id2, array[id1] as visited from edges where id1 = id2
union all
select r.id1, e.id2, r.visited || e.id2
from rcte r
inner join edges e on e.id1 = r.id2
where e.id2 <> all(r.visited)
),
groups as (
select id1 as id, array_agg(distinct id2 order by id2) as ids
from rcte
group by id1
)
select * from groups order by id
id | ids |
---|---|
1 | {1,3,4,5} |
2 | {2} |
3 | {1,3,4,5} |
4 | {1,3,4,5} |
5 | {1,3,4,5} |
left join
and aggregationNow that we identified the groups, we can check for assets. Since you want all assets in the result, we start from the assets
table, then bring the users and the groups with left join
s. We can still group by
the user groups, which puts all orphan assets in the same group (where the group is null
) - that's exactly what we want.
The last step is array aggregation; the "propagation" of the owners of orphan assets to ref2
can be handled with a case
expression.
with recursive
nodes as (...),
edges as (...),
rcte as (...),
groups as (...)
select g.ids,
array_agg(distinct u.username) as usernames,
array_agg(distinct u.ref1) as refs1,
case when g.ids is null then array_agg(distinct a.owner) else array_agg(distinct u.ref2) end as refs2,
array_agg(distinct a.id) as asset_ids,
count(distinct a.id) as asset_count
from assets a
left join users u on a.owner in (u.ref1, u.ref2)
left join groups g on g.id = u.id
group by g.ids
ids | usernames | refs1 | refs2 | asset_ids | asset_count |
---|---|---|---|---|---|
{1,3,4,5} | {bobo,lolo,momo,popo} | {a,c} | {d,f} | {1,3,4,5,6,8,9,10} | 8 |
{2} | {toto} | {b} | {e} | {2,7} | 2 |
null | {NULL} | {NULL} | {z} | {11} | 1 |
Performance will suffer on networks that have a lot of edges, especially if there are just few clusters in the graph, each containing with many users. We can try and optimize the query for such situation; the idea is to try and limit the number of paths that need to be walked, by aggregating all paths of each user at each iteration.
This query passes your test case with 200 users that all belong to the same cluster (whereas the first query exhausts the DB Fiddle resources):
with recursive
nodes as (
select u.id, r.ref
from users u
cross join lateral ( values (u.ref1), (u.ref2) ) r(ref)
),
edges as (
select distinct n1.id as id1, n2.id as id2
from nodes n1
inner join nodes n2 on n1.ref = n2.ref
),
rcte as (
select id1 as id, array[id1] as visited from edges where id1 = id2
union all
select id, array_agg(distinct visited) as visited
from (
select r.id, v.visited
from rcte r
inner join edges e on e.id1 = any(r.visited)
cross join lateral unnest(r.visited || e.id2) v(visited)
where e.id2 <> all(r.visited)
) as x
group by id
),
groups as (
select distinct on (id) id, visited as ids
from rcte
order by id, array_length(visited, 1) desc
)
select * from groups g order by id