web-dev-qa-db-fra.com

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

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       | [email protected]   | t
1       | [email protected]   | f
1       | [email protected]   | f
2       | [email protected]   | 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.

25
Kevin Burke

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

Incluez l'ID de l'e-mail actif dans la table users, ce qui entraîne des références mutuelles. On pourrait penser que nous avons besoin d'une contrainte DEFERRABLE pour résoudre le problème du poulet et des œufs d'insérer un utilisateur et son e-mail actif, mais en utilisant des CTE modificateurs de données, nous n'en avons même pas besoin.

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

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 contrainte NOT NULL De users.email_id Pour en faire "au plus un e-mail actif". (Vous pouvez toujours stocker plusieurs e-mails par utilisateur, mais aucun n'est "actif".)

Vous pouvez créez active_email_fkeyDEFERRABLE pour permettre plus de latitude (insérez l'utilisateur et l'e-mail dans des commandes distinctes de la transaction identique) , mais c'est pas nécessaire.

J'ai placé user_id En premier dans la contrainte UNIQUEemail_fk_uni Pour optimiser la couverture de l'index. 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 (selon les besoins):

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_id Ni email_id Pour commencer. Les deux sont des numéros de série fournis par les SEQUENCE respectifs. Il ne peut pas être résolu avec une seule clause RETURNING (un autre problème de poulet et d'oeuf). La solution est nextval() as expliqué en détail dans la réponse liée ci-dessous .

Si vous ne faites pas savoir le nom de la séquence jointe pour la colonne serialemail.email_id, Vous 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 contraintes DEFERRABLE:

À propos de nextval() et pg_get_serial_sequence():

18
Erwin Brandstetter

Si vous pouvez ajouter une colonne à la table, le schéma suivant serait presque1 travail:

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 mentionné dans un commentaire, vous pouvez même aller plus loin:

  • Déposez la colonne booléenne; et
  • Créez la 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 garantissent uniquement qu'une ligne appelée "active" par une autre ligne existe, et non qu'elle est également réellement active. 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.

6
Paul White 9

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érences B.b_id (PK) et B.a_id (FK) références A.a_id (PK). De nombreux ORM, etc. ne peuvent cependant pas faire face à des contraintes reportables. Donc, dans ce cas, vous devez ajouter un FK différé de l'utilisateur à l'adresse sur une colonne active_address_id, à la place d'utiliser un indicateur active sur address.

4
Craig Ringer