patternsqlMinor
Unique across tables
Viewed 0 times
uniquetablesacross
Problem
I have a table containing users. Each user has a primary email and a flag indicating if the user is deleted or not (we never hard-delete users).
However, each user can also have additional emails.
In any case, an email address must be unique and I'd like to enforce this on the database level. For the primary email address that's easy. I can simply add a UNIQUE index with a
However, for the secondary emails that's more tricky.
Is there any better solution to achieve what I'm trying to do?
However, each user can also have additional emails.
In any case, an email address must be unique and I'd like to enforce this on the database level. For the primary email address that's easy. I can simply add a UNIQUE index with a
WHERE not is_deleted restriction so a deleted user's email can be re-used.However, for the secondary emails that's more tricky.
- If I stored them in the same table in an array field I'd probably not only lose the ability to index them (we have >50k users and need to be able to search by email, so that's a no-go) but as far as I know I also cannot put an UNIQUE index/constraint which goes into the arrays.
- If I use a separate table (which is obviously cleaner), I would have to replicate the
is_deletedflag from users to that table, which is also quite ugly, but would allow me to simply use the UNIQUE index withWHERE not is_deleted.
Is there any better solution to achieve what I'm trying to do?
Solution
To enforce unique email addresses, I would remove all competing email columns and store them in one central
This way:
Due to the default
Details:
The
The only thing that's not enforced this way is that every user has a primary email. You can do this, too. Add
You have doubtless spotted the circular reference in the above model. Contrary to what one might expect, this just works.
As long as
It even works with
To get all emails of a user as array, with the primary email first:
You could create a
To soft-delete an email:
To soft-delete the primary email of a given user:
Details:
Quick test for all of the above: SQL Fiddle.
email table for all active emails. And another table for deleted emails:CREATE TABLE users (
user_id serial PRIMARY KEY
, username text UNIQUE NOT NULL
, email text UNIQUE -- FK added below -- can also be NOT NULL
);
CREATE TABLE email (
email text PRIMARY KEY
, user_id int NOT NULL REFERENCES users ON DELETE CASCADE
, UNIQUE (user_id, email) -- seems redundant, but required for FK
);
ALTER TABLE users ADD CONSTRAINT users_primary_email_fkey
FOREIGN KEY (user_id, email) REFERENCES email (user_id, email);
CREATE TABLE email_deleted (
email_id serial PRIMARY KEY
, email text NOT NULL -- not necessarily unique
, user_id int NOT NULL REFERENCES users ON DELETE CASCADE
);This way:
- Active emails are unique, enforced by the PK constraint of
email.
- Each user can have any number of active and deleted emails, but ...
- Each user can only have one primary email.
- Every email is always owned by one user and is deleted with the user.
- To soft-delete an email (without losing it and its affiliation to its user, move the row from
emailtoemail_deleted.
- The primary email of a user cannot be deleted this way, because the primary email must not be deleted.
- I designed the FK constraint
users_primary_email_fkeyto span(user_id, email), which seems redundant at first. But this way the primary email can only be an email that is actually owned by the same user.
Due to the default
MATCH SIMPLE behavior of FK constraints, you can still enter a user without primary email, because the FK constraint is not enforced if any of the columns is null.Details:
- Two-column foreign key constraint only when third column is NOT NULL
The
UNIQUE constraint on users.email is redundant for this solution, but it may be useful for other reasons. The automatically created index should come in handy (for instance for the last query in this answer).The only thing that's not enforced this way is that every user has a primary email. You can do this, too. Add
NOT NULL constraint to users.emailUNIQUE (user_id, email) is required for the FK constraint:- How does PostgreSQL enforce the UNIQUE constraint / what type of index does it use?
You have doubtless spotted the circular reference in the above model. Contrary to what one might expect, this just works.
As long as
users.email can be NULL, it's trivial:INSERTuser without email.
INSERTemail referencing the owninguser_id.
UPDATEuser to set it's primary email if applicable.
It even works with
users.email set to NOT NULL. You have to insert user and email at the same time though:WITH u AS (
INSERT INTO users(username, email)
VALUES ('user_foo', 'foo@mail.com')
RETURNING email, user_id
)
INSERT INTO email (email, user_id)
SELECT email, user_id
FROM u;IMMEDIATE FK constraints (the default) are checked at the end of each statement. The above is one statement. That's why it works where two separate statements would fail. Detailed explanation:- How to deal with mutually recursive inserts
To get all emails of a user as array, with the primary email first:
SELECT u.*, e.emails
FROM users u
, LATERAL (
SELECT ARRAY (
SELECT email
FROM email
WHERE user_id = u.user_id
ORDER BY (email <> u.email) -- sort primary email first
) AS emails
) e
WHERE user_id = 1;You could create a
VIEW with this for ease of use.LATERAL requires Postgres 9.3. use a correlated subquery in pg 9.2:SELECT *, ARRAY (
SELECT email
FROM email
WHERE user_id = u.user_id
ORDER BY (email <> u.email) -- sort primary email first
) AS emails
FROM users u
WHERE user_id = 1;To soft-delete an email:
WITH del AS (
DELETE FROM email
WHERE email = 'spam@mail.com'
RETURNING email, user_id
)
INSERT INTO email_deleted (email, user_id)
SELECT email, user_id FROM del;To soft-delete the primary email of a given user:
WITH upd AS (
UPDATE users u
SET email = NULL
FROM (SELECT user_id, email FROM users WHERE user_id = 123 FOR UPDATE) old
WHERE old.user_id = u.user_id
AND u.user_id = 1
RETURNING old.*
)
, del AS (
DELETE FROM email
USING upd
WHERE email.email = upd.email
)
INSERT INTO email_deleted (email, user_id)
SELECT email, user_id FROM upd;Details:
- Return pre-UPDATE Column Values Using SQL Only - PostgreSQL Version
Quick test for all of the above: SQL Fiddle.
Code Snippets
CREATE TABLE users (
user_id serial PRIMARY KEY
, username text UNIQUE NOT NULL
, email text UNIQUE -- FK added below -- can also be NOT NULL
);
CREATE TABLE email (
email text PRIMARY KEY
, user_id int NOT NULL REFERENCES users ON DELETE CASCADE
, UNIQUE (user_id, email) -- seems redundant, but required for FK
);
ALTER TABLE users ADD CONSTRAINT users_primary_email_fkey
FOREIGN KEY (user_id, email) REFERENCES email (user_id, email);
CREATE TABLE email_deleted (
email_id serial PRIMARY KEY
, email text NOT NULL -- not necessarily unique
, user_id int NOT NULL REFERENCES users ON DELETE CASCADE
);WITH u AS (
INSERT INTO users(username, email)
VALUES ('user_foo', 'foo@mail.com')
RETURNING email, user_id
)
INSERT INTO email (email, user_id)
SELECT email, user_id
FROM u;SELECT u.*, e.emails
FROM users u
, LATERAL (
SELECT ARRAY (
SELECT email
FROM email
WHERE user_id = u.user_id
ORDER BY (email <> u.email) -- sort primary email first
) AS emails
) e
WHERE user_id = 1;SELECT *, ARRAY (
SELECT email
FROM email
WHERE user_id = u.user_id
ORDER BY (email <> u.email) -- sort primary email first
) AS emails
FROM users u
WHERE user_id = 1;WITH del AS (
DELETE FROM email
WHERE email = 'spam@mail.com'
RETURNING email, user_id
)
INSERT INTO email_deleted (email, user_id)
SELECT email, user_id FROM del;Context
StackExchange Database Administrators Q#95817, answer score: 5
Revisions (0)
No revisions yet.