2

I have a many to many relationship implemented:

CREATE TABLE public.message (
    id BIGSERIAL PRIMARY KEY,
    name varchar(40) UNIQUE NOT NULL
);

CREATE TABLE public.package(
    id BIGSERIAL PRIMARY KEY,
    name varchar(40) UNIQUE NOT NULL
);

CREATE TABLE public.package_to_message (
    message_id BIGINT NOT NULL,
    package_id BIGINT NOT NULL,
    CONSTRAINT package_to_message_pk PRIMARY KEY (message_id, package_id)
);

I need to select a package that has an association with a precisely defined set of messages [message_name_1, message_name_2, message_name_3]. With all of the above and none more. Is it possible to do this using a more or less optimized query? Nothing comes to mind.

0

2 Answers 2

1

The key word here is "relational division".

For the given specifications, it won't get faster than this:

SELECT package_id
FROM   package_to_message p1
JOIN   package_to_message p2 USING (package_id)
JOIN   package_to_message p3 USING (package_id)
WHERE  p1.message_id = (SELECT m.id FROM message m WHERE m.name = 'message_name_1')
AND    p2.message_id = (SELECT m.id FROM message m WHERE m.name = 'message_name_2')
AND    p3.message_id = (SELECT m.id FROM message m WHERE m.name = 'message_name_3')
AND    NOT EXISTS (  -- no other!
   SELECT FROM package_to_message p0
   WHERE  p0.package_id = p1.package_id
   AND    p0.message_id NOT IN (p1.message_id, p2.message_id, p3.message_id)
   );

But maybe you need a more generic/dynamic query ...

PREPARE q1(text[]) AS
WITH m AS (                              -- translate names to IDs
   SELECT id AS message_id
   FROM   message
   WHERE  name = ANY($1)
   )
SELECT p.package_id
FROM   m
JOIN   package_to_message p USING (message_id)
GROUP  BY 1
HAVING count(*) = cardinality($1)        -- length of array
AND    NOT EXISTS (                      -- no other message
   SELECT FROM package_to_message p0
   WHERE  p0.package_id = p.package_id
   AND    p0.message_id <> ALL(TABLE m)  -- m holds IDs
   );

Call:

EXECUTE q1('{message_name_1, message_name_2, message_name_3}');

My queries assume that you pass distinct message names (no duplicates). Plus, message.name is defined UNIQUE.

Note how ANY and ALL accept an array or a set. See:

About the short syntax TABLE m:

Indexes & performance

Index 1

The UNIQUE constraint on table message covers the lookup nicely.
If performance is crucial (and you meet preconditions for index-only scans) a covering index would be slightly better:

ALTER TABLE message
  DROP CONSTRAINT message_name_key  -- actual constraint name here!
, ADD  CONSTRAINT message_name_id_key UNIQUE (name) INCLUDE (id)
;

See:

Index 2

The PRIMARY KEY on package_to_message(message_id, package_id) provides the perfect index for the next step.

Index 3

Ideally, you add another index on package_to_message(package_id, message_id) for the final step. See:

With these 3 indexes in place, and if your tables are vacuumed enough, both queries can make do with index-only scans exclusively. Then it takes a few ms at most, even for huge tables, or even below 1 ms execution time.

--

Related:

2
  • Thanks for the answer. The second query, unfortunately, will not help in any way in the case where there are extra relations besides those in the array. m.name = ANY($1) will limit COUNT to the length of the array. Commented Jul 22 at 6:41
  • @Александр: Note the fixed NOT EXISTS for the 2nd query. And the added explanation. Commented Jul 22 at 22:46
-1

Another option uses counts to verify we have all matches. Note that your question is specifically an example of multi-way Relational Division Without Remainder, not With.

PREPARE q1(text[]) AS
SELECT
    pm.package_id
FROM   message m
JOIN   package_to_message pm ON pm.message_id = m.id
GROUP BY
    pm.package_id
HAVING count(*) = cardinality($1)
   AND count(*) = count(*) FILTER (WHERE m.name = ANY($1));

This is a variation on a more generalized solution to any multi-way Relational Division, which is more efficient by using an array

SELECT
    pm.package_id
FROM   message m
JOIN   package_to_message pm ON pm.message_id = m.id
LEFT JOIN inputData i ON i.value = m.name
GROUP BY
    pm.package_id
HAVING count(*) = (SELECT count(*) FROM inputData)
   AND count(*) = count(i.value);

For the same thing for With Remainder, change the HAVING clause to HAVING count(*) >= (SELECT count(*) FROM inputData); and change the LEFT JOIN to a JOIN.

See also this article among others for further options for Relational Division.

3
  • HAVING is allowed without GROUP BY, but then you cannot use an un-grouped package_id. DISTINCT cannot replace it. After fixing, both queries perform terribly because they process the whole package_to_message table before filtering. Commented Jul 22 at 22:54
  • True I got confused when removing the GROUP BY (was thinking about a single-way division). Performance may be better or worse, I think the NOT EXISTS in your query also has a non-trivial impact: eg what happens if there are divisors with a large number of members, but are an exact match? Then you would end up querying the whole divisor twice. If you want to get really performance-y, best to take three most common values from the array, match each against the join table and unioning the result, finally checking the remaining values. YMMV. Commented Jul 22 at 23:07
  • 1
    I know how either query performs because I ran extensive tests. And I know a bit about Postgres. Your queries begin to make sense once the passed message names cover a large portion of the link table package_to_message. An unlikely scenario. Commented Jul 22 at 23:13

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.