Einschränkung, um "mindestens einen" oder "genau einen" in einer Datenbank zu erzwingen

24

Angenommen, wir haben Benutzer und jeder Benutzer kann mehrere E-Mail-Adressen haben

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

Einige Beispielzeilen

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

Ich möchte eine Einschränkung erzwingen, dass jeder Benutzer genau eine aktive Adresse hat. Wie kann ich das in Postgres machen? Ich könnte das machen:

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

Das würde vor einem Benutzer schützen, der mehr als eine aktive Adresse hat, aber ich glaube nicht, dass alle seine Adressen auf falsch gesetzt werden.

Wenn möglich, würde ich es vorziehen, einen Trigger oder ein pl / pgsql-Skript zu vermeiden, da wir derzeit keines davon haben und es schwierig wäre, es einzurichten. Aber ich würde es begrüßen zu wissen, dass "der einzige Weg, dies zu tun, ein Trigger oder pl / pgsql ist", wenn dies der Fall ist.

Kevin Burke
quelle

Antworten:

17

Sie brauchen überhaupt keine Trigger oder PL / pgSQL.
Sie haben nicht einmal müssen DEFERRABLE Einschränkungen.
Und Sie müssen keine Informationen redundant speichern.

Fügen Sie die ID der aktiven E-Mail in die usersTabelle ein, wodurch sich gegenseitige Verweise ergeben. Man könnte meinen, wir brauchen eine DEFERRABLEEinschränkung, um das Henne-Ei-Problem des Einfügens eines Benutzers und seiner aktiven E-Mail zu lösen, aber mit datenmodifizierenden CTEs brauchen wir das nicht einmal.

Dies erzwingt zu jeder Zeit genau eine aktive E-Mail pro Benutzer :

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

Entfernen Sie die NOT NULLEinschränkung von users.email_id, um es zu "höchstens einer aktiven E-Mail" zu machen. (Sie können immer noch mehrere E-Mails pro Benutzer speichern, aber keine davon ist "aktiv".)

Sie können machen active_email_fkey DEFERRABLEmehr Spielraum ermöglichen (insert Benutzer und E - Mail in separaten Befehlen der gleichen Transaktion), aber das ist nicht notwendig .

Ich habe user_iddie UNIQUEEinschränkung email_fk_unizur Optimierung der Indexabdeckung an erster Stelle gestellt . Einzelheiten:

Optionale Ansicht:

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

So fügen Sie neue Benutzer mit einer aktiven E-Mail ein (nach Bedarf):

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

Die besondere Schwierigkeit besteht darin, dass wir weder haben user_idnoch email_idanfangen. Beides sind die Seriennummern der jeweiligen SEQUENCE. Es kann nicht mit einer einzigen RETURNINGKlausel gelöst werden (ein weiteres Henne-Ei-Problem). Die Lösung ist nextval()wie in der verknüpften Antwort unten ausführlich erläutert .

Wenn Sie den Namen der angehängten Sequenz für die Spalte nicht kennen , können Sie Folgendes ersetzen:serialemail.email_id

nextval('email_email_id_seq'::regclass)

mit

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

So fügen Sie eine neue "aktive" E-Mail hinzu:

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-Geige.

Sie können die SQL-Befehle in serverseitigen Funktionen einkapseln, wenn ein einfacher ORM nicht klug genug ist, um damit umzugehen.

Eng verwandt mit ausführlichen Erklärungen:

Auch verwandt:

Über DEFERRABLEEinschränkungen:

Über nextval()und pg_get_serial_sequence():

Erwin Brandstetter
quelle
Kann dies auf 1 für mindestens eine Beziehung angewendet werden? Nicht 1 -1 wie in dieser Antwort gezeigt.
CMCDragonkai
@ CMCDragonkai: Ja. Es wird genau eine aktive E-Mail pro Benutzer erzwungen. Nichts hindert Sie daran, weitere (nicht aktive) E-Mails für denselben Benutzer hinzuzufügen. Wenn Sie die spezielle Rolle für die aktive E-Mail nicht möchten, sind Trigger eine (weniger strenge) Alternative. Sie müssen jedoch vorsichtig sein, um alle Aktualisierungen und Löschvorgänge abzudecken. Ich schlage vor, Sie stellen eine Frage, wenn Sie dies benötigen.
Erwin Brandstetter
Gibt es eine Möglichkeit, Benutzer ohne Verwendung von zu löschen ON DELETE CASCADE? Einfach nur neugierig (Cascading funktioniert im Moment einwandfrei).
Amoe
@amoe: Es gibt verschiedene Möglichkeiten. Datenmodifizierende CTEs, Trigger, Regeln, mehrere Anweisungen in derselben Transaktion ... alles hängt von den genauen Anforderungen ab. Stellen Sie eine neue Frage mit Ihren Angaben, wenn Sie eine Antwort benötigen. Sie können immer einen Link zu diesem für den Kontext erstellen.
Erwin Brandstetter
5

Wenn Sie der Tabelle eine Spalte hinzufügen können, würde das folgende Schema fast 1 funktionieren:

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

Übersetzt von meinem nativen SQL Server mit Hilfe von a_horse_with_no_name

Wie ypercube in einem Kommentar erwähnt hat, können Sie sogar noch weiter gehen:

  • Löschen Sie die Boolesche Spalte. und
  • Erstellen Sie die UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

Der Effekt ist der gleiche, aber wohl einfacher und ordentlicher.


1 Das Problem ist, dass die vorhandenen Einschränkungen nur sicherstellen, dass eine Zeile, die von einer anderen Zeile als "aktiv" bezeichnet wird, vorhanden ist , nicht, dass sie auch tatsächlich aktiv ist. Ich kenne Postgres nicht gut genug, um die zusätzliche Einschränkung selbst zu implementieren (zumindest momentan nicht), aber in SQL Server könnte dies folgendermaßen erfolgen:

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

Dieser Aufwand verbessert das Original ein wenig, indem ein Ersatz verwendet wird, anstatt die vollständige E-Mail-Adresse zu duplizieren.

Paul White sagt GoFundMonica
quelle
4

Die einzige Möglichkeit, beide ohne Schemaänderungen auszuführen, ist ein PL / PgSQL-Trigger.

Für den "genau einen" Fall können Sie die Referenzen gegenseitig machen, mit einem Wesen DEFERRABLE INITIALLY DEFERRED. Also A.b_id(FK) Referenzen B.b_id(PK) und B.a_id(FK) Referenzen A.a_id(PK). Viele ORMs usw. können jedoch nicht mit aufschiebbaren Einschränkungen umgehen. In diesem Fall würden Sie also eine verschiebbare FK vom Benutzer zur Adresse in einer Spalte hinzufügen active_address_id, anstatt ein activeFlag an zu verwenden address.

Craig Ringer
quelle
Der FK muss nicht einmal sein DEFERRABLE.
Erwin Brandstetter