Contrainte d'imposer «au moins un» ou «exactement un» dans une base de données

24

Disons que nous avons des utilisateurs et que chaque utilisateur peut avoir plusieurs adresses e-mail

CREATE TABLE emails (
    user_id integer,
    email_address text,
    is_active boolean
)

Quelques exemples de lignes

user_id | email_address | is_active
1       | foo@bar.com   | t
1       | baz@bar.com   | f
1       | bar@foo.com   | f
2       | ccc@ddd.com   | t

Je veux imposer une contrainte selon laquelle chaque utilisateur a exactement une adresse active. Comment puis-je faire cela dans Postgres? Je pourrais faire ça:

CREATE UNIQUE INDEX "user_email" ON emails(user_id) WHERE is_active=true;

Ce qui protégerait contre un utilisateur ayant plus d'une adresse active, mais ne protégerait pas, je crois, contre toutes leurs adresses définies sur false.

Si possible, je préfère éviter un déclencheur ou un script pl / pgsql, car nous n'en avons actuellement aucun et il serait difficile à configurer. Mais j'apprécierais de savoir "la seule façon de le faire est avec un déclencheur ou pl / pgsql", si tel est le cas.

Kevin Burke
la source

Réponses:

17

Vous n'avez pas du tout besoin de déclencheurs ou de PL / pgSQL.
Vous n'avez même pas besoin de DEFERRABLE contraintes.
Et vous n'avez pas besoin de stocker des informations de manière redondante.

Incluez l'ID de l'e-mail actif dans le userstableau, ce qui entraîne des références mutuelles. On pourrait penser que nous avons besoin d'une DEFERRABLEcontrainte pour résoudre le problème de l'insertion d'un utilisateur et de son e-mail actif, mais en utilisant des CTE modificateurs de données, nous n'en avons même pas besoin.

Cela applique à tout moment exactement un e-mail actif par utilisateur :

CREATE TABLE users (
  user_id  serial PRIMARY KEY
, username text NOT NULL
, email_id int NOT NULL  -- FK to active email, constraint added below
);

CREATE TABLE email (
  email_id serial PRIMARY KEY
, user_id  int NOT NULL REFERENCES users ON DELETE CASCADE ON UPDATE CASCADE 
, email    text NOT NULL
, CONSTRAINT email_fk_uni UNIQUE(user_id, email_id)  -- for FK constraint below
);

ALTER TABLE users ADD CONSTRAINT active_email_fkey
FOREIGN KEY (user_id, email_id) REFERENCES email(user_id, email_id);

Supprimez la NOT NULLcontrainte de users.email_idpour en faire "au plus un e-mail actif". (Vous pouvez toujours stocker plusieurs e-mails par utilisateur, mais aucun n'est "actif".)

Vous pouvez faire active_email_fkey DEFERRABLEpour autoriser plus de latitude (insérer l'utilisateur et l'e-mail dans des commandes distinctes de la même transaction), mais ce n'est pas nécessaire .

Je mets d' user_idabord dans la UNIQUEcontrainte email_fk_unid'optimiser la couverture de l'indice. Détails:

Vue optionnelle:

CREATE VIEW user_with_active_email AS
SELECT * FROM users JOIN email USING (user_id, email_id);

Voici comment insérer de nouveaux utilisateurs avec un e-mail actif (si nécessaire):

WITH new_data(username, email) AS (
   VALUES
      ('usr1', '[email protected]')   -- new users with *1* active email
    , ('usr2', '[email protected]')
    , ('usr3', '[email protected]')
   )
, u AS (
   INSERT INTO users(username, email_id)
   SELECT n.username, nextval('email_email_id_seq'::regclass)
   FROM   new_data n
   RETURNING *
   )
INSERT INTO email(email_id, user_id, email)
SELECT u.email_id, u.user_id, n.email
FROM   u
JOIN   new_data n USING (username);

La difficulté spécifique est que nous n'avons ni user_idni email_idpour commencer. Les deux sont des numéros de série fournis par le respectif SEQUENCE. Il ne peut pas être résolu avec une seule RETURNINGclause (un autre problème de poulet et d'oeuf). La solution est nextval()comme expliqué en détail dans la réponse liée ci-dessous .

Si vous ne connaissez pas le nom de la séquence jointe pour la serialcolonne, email.email_idvous pouvez remplacer:

nextval('email_email_id_seq'::regclass)

avec

nextval(pg_get_serial_sequence('email', 'email_id'))

Voici comment ajouter un nouvel e-mail "actif":

WITH e AS (
   INSERT INTO email (user_id, email)
   VALUES  (3, '[email protected]')
   RETURNING *
   )
UPDATE users u
SET    email_id = e.email_id
FROM   e
WHERE  u.user_id = e.user_id;

SQL Fiddle.

Vous pouvez encapsuler les commandes SQL dans des fonctions côté serveur si certains ORM simples ne sont pas assez intelligents pour y faire face.

Étroitement liés, avec de nombreuses explications:

Également lié:

A propos des DEFERRABLEcontraintes:

À propos nextval()et pg_get_serial_sequence():

Erwin Brandstetter
la source
Peut-on appliquer cela à 1 à au moins une relation? Pas 1 -1 comme indiqué dans cette réponse.
CMCDragonkai
@CMCDragonkai: Oui. Un seul e-mail actif par utilisateur est appliqué. Rien ne vous empêche d'ajouter d'autres e-mails (non actifs) pour le même utilisateur. Si vous ne voulez pas le rôle spécial pour l'e-mail actif, les déclencheurs seraient une alternative (moins stricte). Mais vous devez faire attention à couvrir toutes les mises à jour et suppressions. Je vous suggère de poser une question si vous en avez besoin.
Erwin Brandstetter
Existe-t-il un moyen de supprimer des utilisateurs sans utiliser ON DELETE CASCADE? Juste curieux (la cascade fonctionne bien pour l'instant).
amoe
@amoe: Il existe différentes manières. CTE modifiant les données, déclencheurs, règles, plusieurs instructions dans la même transaction, ... tout dépend des exigences exactes. Posez une nouvelle question avec vos détails si vous avez besoin d'une réponse. Vous pouvez toujours lier à celui-ci pour le contexte.
Erwin Brandstetter
5

Si vous pouvez ajouter une colonne au tableau, le schéma suivant fonctionnerait presque 1 :

CREATE TABLE emails 
(
    UserID integer NOT NULL,
    EmailAddress varchar(254) NOT NULL,
    IsActive boolean NOT NULL,

    -- New column
    ActiveAddress varchar(254) NOT NULL,

    -- Obvious PK
    CONSTRAINT PK_emails_UserID_EmailAddress
        PRIMARY KEY (UserID, EmailAddress),

    -- Validate that the active address row exists
    CONSTRAINT FK_emails_ActiveAddressExists
        FOREIGN KEY (UserID, ActiveAddress)
        REFERENCES emails (UserID, EmailAddress),

    -- Validate the IsActive value makes sense    
    CONSTRAINT CK_emails_Validate_IsActive
    CHECK 
    (
        (IsActive = true AND EmailAddress = ActiveAddress)
        OR
        (IsActive = false AND EmailAddress <> ActiveAddress)
    )
);

-- Enforce maximum of one active address per user
CREATE UNIQUE INDEX UQ_emails_One_IsActive_True_PerUser
ON emails (UserID, IsActive)
WHERE IsActive = true;

Test SQLFiddle

Traduit de mon serveur SQL natif, avec l'aide de a_horse_with_no_name

Comme ypercube l'a mentionné dans un commentaire, vous pouvez même aller plus loin:

  • Déposez la colonne booléenne; et
  • Créez le UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

L'effet est le même, mais il est sans doute plus simple et plus net.


1 Le problème est que les contraintes existantes ne garantissent qu'une ligne appelée « active » par une autre ligne existe , non pas qu'il est réellement actif. Je ne connais pas assez bien Postgres pour implémenter la contrainte supplémentaire moi-même (du moins pas en ce moment), mais dans SQL Server, cela pourrait se faire de la manière suivante:

CREATE TABLE Emails 
(
    EmailID integer NOT NULL UNIQUE,
    UserID integer NOT NULL,
    EmailAddress varchar(254) NOT NULL,
    IsActive bit NOT NULL,

    -- New columns
    ActiveEmailID integer NOT NULL,
    ActiveIsActive AS CONVERT(bit, 'true') PERSISTED,

    -- Obvious PK
    CONSTRAINT PK_emails_UserID_EmailAddress
        PRIMARY KEY (UserID, EmailID),

    CONSTRAINT UQ_emails_UserID_EmailAddress_IsActive
        UNIQUE (UserID, EmailID, IsActive),

    -- Validate that the active address exists and is active
    CONSTRAINT FK_emails_ActiveAddressExists_And_IsActive
        FOREIGN KEY (UserID, ActiveEmailID, ActiveIsActive)
        REFERENCES emails (UserID, EmailID, IsActive),

    -- Validate the IsActive value makes sense    
    CONSTRAINT CK_emails_Validate_IsActive
    CHECK 
    (
        (IsActive = 'true' AND EmailID = ActiveEmailID)
        OR
        (IsActive = 'false' AND EmailID <> ActiveEmailID)
    )
);

-- Enforce maximum of one active address per user
CREATE UNIQUE INDEX UQ_emails_One_IsActive_PerUser
ON emails (UserID, IsActive)
WHERE IsActive = 'true';

Cet effort améliore un peu l'original en utilisant un substitut plutôt que de dupliquer l'adresse e-mail complète.

Paul White dit GoFundMonica
la source
4

La seule façon de procéder sans modification de schéma est d'utiliser un déclencheur PL / PgSQL.

Pour le cas «exactement un», vous pouvez rendre les références mutuelles, l'une étant DEFERRABLE INITIALLY DEFERRED. Donc A.b_id(FK) référence B.b_id(PK) et B.a_id(FK) référence A.a_id(PK). De nombreux ORM, etc. ne peuvent cependant pas faire face à des contraintes reportables. Donc, dans ce cas, vous ajouteriez un FK différable de l'utilisateur à l'adresse sur une colonne active_address_id, au lieu d'utiliser un activeindicateur address.

Craig Ringer
la source
Le FK n'a même pas à l'être DEFERRABLE.
Erwin Brandstetter