it-swarm.com.de

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

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

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

Dies würde vor einem Benutzer mit mehr als einer aktiven Adresse schützen, aber meiner Meinung nach nicht davor schützen, dass alle Adressen auf false gesetzt werden.

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

25
Kevin Burke

Sie benötigen überhaupt keine Trigger oder PL/pgSQL.
Sie haben nicht einmal brauchenDEFERRABLE Einschränkungen.
Und Sie müssen keine Informationen redundant speichern.

Fügen Sie die ID der aktiven E-Mail in die Tabelle users ein, was zu gegenseitigen Verweisen führt. Man könnte meinen, wir brauchen eine DEFERRABLE -Einschrä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 genau eine aktive E-Mail pro Benutzer zu jeder Zeit:

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 Einschränkung NOT NULL Aus users.email_id, Um "höchstens eine aktive E-Mail" zu erstellen. (Sie können immer noch mehrere E-Mails pro Benutzer speichern, aber keine davon ist "aktiv".)

Sie könnenactive_email_fkeyDEFERRABLE machen, um mehr Spielraum zu lassen (Benutzer und E-Mail in separate Befehle der Transaktion same einfügen) , aber das ist nicht notwendig.

Ich habe user_id Zuerst in die Einschränkung UNIQUEemail_fk_uni Eingefügt, um die Indexabdeckung zu optimieren. 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 (je 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 zunächst weder user_id Noch email_id Haben. Beides sind Seriennummern, die vom jeweiligen SEQUENCE bereitgestellt werden. Es kann nicht mit einer einzigen RETURNING -Klausel gelöst werden (ein weiteres Henne-Ei-Problem). Die Lösung ist nextval() as ausführlich in der unten verlinkten Antwort erläutert .

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

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

Sie können die SQL-Befehle in serverseitigen Funktionen kapseln, wenn ein einfältiger ORM nicht klug genug ist, um damit umzugehen.

Eng verwandt, mit ausführlicher Erklärung:

Auch verwandt:

Informationen zu DEFERRABLE Einschränkungen:

Über nextval() und pg_get_serial_sequence():

18

Wenn Sie der Tabelle eine Spalte hinzufügen können, würde das folgende Schema fast1 Arbeit:

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, könnten 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 er ist 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, existiert, 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 geschehen:

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.

6
Paul White 9

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

Für den Fall "genau eine" können Sie die Referenzen gegenseitig festlegen, wobei eine DEFERRABLE INITIALLY DEFERRED Ist. A.b_id (FK) verweist also auf B.b_id (PK) und B.a_id (FK) auf A.a_id (PK). Viele ORMs usw. können jedoch nicht mit aufschiebbaren Einschränkungen fertig werden. In diesem Fall würden Sie also eine aufschiebbare FK vom Benutzer zur Adresse in einer Spalte active_address_id Hinzufügen, stattdessen, um ein active -Flag für address zu verwenden .

4
Craig Ringer