Vincolo per imporre "almeno uno" o "esattamente uno" in un database


24

Supponiamo che abbiamo utenti e che ogni utente possa avere più indirizzi e-mail

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

Alcune righe di esempio

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

Voglio imporre il vincolo che ogni utente abbia esattamente un indirizzo attivo. Come posso farlo in Postgres? Potrei farlo:

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

Il che proteggerebbe da un utente che ha più di un indirizzo attivo, ma credo che non proteggerebbe dal fatto che tutti i loro indirizzi siano impostati su false.

Se possibile, preferirei evitare un trigger o uno script pl / pgsql, poiché al momento non ne abbiamo nessuno e sarebbe difficile configurarlo. Ma apprezzerei sapere "l'unico modo per farlo è con un trigger o pl / pgsql", se è così.

Risposte:


17

Non hai bisogno di trigger o PL / pgSQL.
Non hai nemmeno bisogno di DEFERRABLE vincoli.
E non è necessario archiviare alcuna informazione in modo ridondante.

Includi l'ID dell'email attiva nella userstabella, risultando in riferimenti reciproci. Si potrebbe pensare che abbiamo bisogno di un DEFERRABLEvincolo per risolvere il problema del pollo e delle uova dell'inserimento di un utente e della sua e-mail attiva, ma utilizzando CTE che modificano i dati non ne abbiamo nemmeno bisogno.

Ciò impone sempre esattamente un'e-mail attiva per utente :

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);

Rimuovere il NOT NULLvincolo da users.email_idper renderlo "al massimo una e-mail attiva". (Puoi comunque memorizzare più e-mail per utente, ma nessuna di esse è "attiva".)

È possibile fare active_email_fkey DEFERRABLEper consentire più spazio di manovra (inserto utente e-mail a comandi separati della stessa operazione), ma che è non è necessario .

Ho messo user_idprima il UNIQUEvincolo email_fk_uniper ottimizzare la copertura dell'indice. Dettagli:

Vista opzionale:

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

Ecco come inserire nuovi utenti con un'e-mail attiva (come richiesto):

WITH new_data(username, email) AS (
   VALUES
      ('usr1', 'abc@d.com')   -- new users with *1* active email
    , ('usr2', 'def3@d.com')
    , ('usr3', 'ghi1@d.com')
   )
, 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 difficoltà specifica è che non abbiamo user_idemail_idper cominciare. Entrambi sono numeri di serie forniti dai rispettivi SEQUENCE. Non può essere risolto con una sola RETURNINGclausola (un altro problema di galline e uova). La soluzione è nextval()come spiegato in dettaglio nella risposta collegata di seguito .

Se non conosci il nome della sequenza allegata per la serialcolonna email.email_idpuoi sostituire:

nextval('email_email_id_seq'::regclass)

con

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

Ecco come aggiungere una nuova e-mail "attiva":

WITH e AS (
   INSERT INTO email (user_id, email)
   VALUES  (3, 'new_active@d.com')
   RETURNING *
   )
UPDATE users u
SET    email_id = e.email_id
FROM   e
WHERE  u.user_id = e.user_id;

SQL Fiddle.

È possibile incapsulare i comandi SQL nelle funzioni lato server se alcuni ORM semplici non sono abbastanza intelligenti da far fronte a questo.

Strettamente correlato, con ampia spiegazione:

Correlati anche:

Informazioni sui DEFERRABLEvincoli:

Informazioni nextval()e pg_get_serial_sequence():


Questo può essere applicato a 1 per almeno una relazione? Non 1 -1 come mostrato in questa risposta.
CMCDragonkai,

@CMCDragonkai: Sì. Viene applicata esattamente un'e-mail attiva per utente. Nulla ti impedisce di aggiungere più e-mail (non attive) per lo stesso utente. Se non si desidera il ruolo speciale per l'e-mail attiva, i trigger sarebbero un'alternativa (meno rigorosa). Ma devi fare attenzione a coprire tutti gli aggiornamenti e le eliminazioni. Ti suggerisco di fare una domanda se ne hai bisogno.
Erwin Brandstetter,

Esiste un modo per eliminare gli utenti senza usare ON DELETE CASCADE? Solo curioso (per il momento funziona a cascata).
Amoe

@amoe: ci sono vari modi. CTE, trigger, regole, istruzioni multiple che modificano i dati nella stessa transazione, ... tutto dipende da requisiti esatti. Fai una nuova domanda con i tuoi dettagli se hai bisogno di una risposta. Puoi sempre collegarti a questo per il contesto.
Erwin Brandstetter,

5

Se puoi aggiungere una colonna alla tabella, il seguente schema funzionerebbe quasi 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

Tradotto dal mio SQL Server nativo, con l'aiuto di a_horse_with_no_name

Come ypercube menzionato in un commento, potresti anche andare oltre:

  • Rilascia la colonna booleana; e
  • Crea il UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

L'effetto è lo stesso, ma è probabilmente più semplice e ordinato.


1 Il problema è che i vincoli esistenti assicurano soltanto che una riga denominata 'attivo' da un'altra riga esiste , non che è effettivamente attivo. Non conosco Postgres abbastanza bene da implementare me stesso il vincolo aggiuntivo (almeno non in questo momento), ma in SQL Server si potrebbe fare così:

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';

Questo sforzo migliora un po 'l'originale utilizzando un surrogato anziché duplicare l'indirizzo e-mail completo.


4

L'unico modo per eseguire una di queste operazioni senza modifiche allo schema è con un trigger PL / PgSQL.

Per il caso "esattamente uno", puoi rendere reciproci i riferimenti, con un essere DEFERRABLE INITIALLY DEFERRED. Quindi A.b_idriferimenti (FK) B.b_id(PK) e B.a_idriferimenti A.a_id( FK) (PK). Molti ORM, ecc., Tuttavia, non possono far fronte a vincoli differibili. Quindi in questo caso aggiungeresti un FK differibile dall'utente all'indirizzo su una colonna active_address_id, invece di usare un activeflag su address.


L'FK non deve nemmeno esserlo DEFERRABLE.
Erwin Brandstetter,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.