Search code examples
sqlpostgresqldatabase-designddlunique-constraint

How to create a Postgres table with unique combined primary key?


I have two tables named players & matches in a Postgres DB as follows:

CREATE TABLE players (
    name text NOT NULL,
    id serial PRIMARY KEY
);

CREATE TABLE matches (
    winner int REFERENCES players (id),
    loser int REFERENCES players (id),
    -- to prevent rematch btw players
    CONSTRAINT unique_matches
    PRIMARY KEY (winner, loser)
);

How can I ensure that only a unique combination of either (winner, loser) or (loser, winner) is used for matches primary key so that the matches table won't allow the insertion of:

INSERT INTO matches VALUES (2, 1);

If it already has a row containing VALUES (1, 2) like :

 winner | loser
--------+-------
      1 |     2

The goal is to avoid entry of matches between the same players.


Solution

  • Create a unique index:

    CREATE UNIQUE INDEX matches_uni_idx ON matches
       (greatest(winner, loser), least(winner, loser));
    

    Can't be a UNIQUE or PRIMARY KEY constraint, since those only work with columns, not expressions.

    You might add a serial column to serve as PK, but with just two integer columns, your original PK is very efficient, too (see comments). And it makes both columns NOT NULL automatically. (Else, add NOT NULL constraints.)

    You also might add a CHECK constraint to rule out players playing against themselves:

    CHECK (winner <> loser)
    

    Hint: To search for a pair of IDs (where you don't know who won), build the same expressions into your query, and the index will be used:

    SELECT * FROM matches
    WHERE  greatest(winner, loser) = 3  -- the greater value, obviously
    AND    least(winner, loser) = 1;
    

    If you deal with unknown parameters and you don't know which is greater ahead of time:

    WITH input AS (SELECT $id1 AS _id1, $id2 AS _id2)  -- input once
    SELECT * FROM matches, input
    WHERE  greatest(winner, loser) = greatest(_id1, _id2)
    AND    least(winner, loser) = least(_id1, _id2);
    

    The CTE wrapper is just for convenience to enter parameters once only and not necessary in some contexts.