Restricción para aplicar "al menos uno" o "exactamente uno" en una base de datos


24

Digamos que tenemos usuarios y cada usuario puede tener múltiples direcciones de correo electrónico

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

Algunas filas de muestra

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

Quiero imponer una restricción de que cada usuario tiene exactamente una dirección activa. ¿Cómo puedo hacer esto en Postgres? Yo podría hacer esto:

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

Lo que protegería contra un usuario que tenga más de una dirección activa, pero creo que no protegería contra todas sus direcciones configuradas como falsas.

Si es posible, preferiría evitar un desencadenador o un script pl / pgsql, ya que actualmente no tenemos ninguno de ellos y sería difícil de configurar. Pero agradecería saber que "la única forma de hacerlo es con un disparador o pl / pgsql", si ese es el caso.

Respuestas:


17

No necesita disparadores o PL / pgSQL en absoluto.
Ni siquiera necesitas DEFERRABLE restricciones.
Y no necesita almacenar ninguna información de forma redundante.

Incluya la ID del correo electrónico activo en la userstabla, lo que resulta en referencias mutuas. Uno podría pensar que necesitamos una DEFERRABLErestricción para resolver el problema del huevo y la gallina de insertar un usuario y su correo electrónico activo, pero usando CTE modificadores de datos ni siquiera necesitamos eso.

Esto aplica exactamente un correo electrónico activo por usuario en todo momento:

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

Elimine la NOT NULLrestricción de users.email_idpara que sea "como máximo un correo electrónico activo". (Todavía puede almacenar varios correos electrónicos por usuario, pero ninguno de ellos está "activo").

Usted puede hacer active_email_fkey DEFERRABLEpara permitir más libertad de acción (inserto de usuario y correo electrónico en comandos separados de la misma transacción), pero eso es innecesario .

Puse user_idprimero en la UNIQUErestricción email_fk_unipara optimizar la cobertura del índice. Detalles:

Vista opcional:

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

Así es como inserta nuevos usuarios con un correo electrónico activo (según sea necesario):

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 dificultad específica es que no tenemos user_idni email_idpara empezar. Ambos son números de serie proporcionados por los respectivos SEQUENCE. No se puede resolver con una sola RETURNINGcláusula (otro problema de huevo y gallina). La solución es nextval()como se explica en detalle en la siguiente respuesta vinculada .

Si no conoce el nombre de la secuencia adjunta para la serialcolumna email.email_id, puede reemplazarla:

nextval('email_email_id_seq'::regclass)

con

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

Así es como agrega un nuevo correo electrónico "activo":

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.

Puede encapsular los comandos SQL en funciones del lado del servidor si algún ORM de mente simple no es lo suficientemente inteligente como para hacer frente a esto.

Estrechamente relacionado, con amplia explicación:

También relacionado:

Sobre DEFERRABLErestricciones:

Sobre nextval()y pg_get_serial_sequence():


¿Se puede aplicar esto a relaciones de 1 a al menos una? No 1 -1 como se muestra en esta respuesta.
CMCDragonkai

@CMCDragonkai: Sí. Se aplica exactamente un correo electrónico activo por usuario. Nada le impide agregar más correos electrónicos (no activos) para el mismo usuario. Si no desea el rol especial para el correo electrónico activo, los disparadores serían una alternativa (menos estricta). Pero debe tener cuidado de cubrir todas las actualizaciones y eliminaciones. Le sugiero que haga una pregunta si necesita esto.
Erwin Brandstetter

¿Hay alguna forma de eliminar usuarios sin usar ON DELETE CASCADE? Simplemente curioso (la conexión en cascada funciona bien por ahora).
amoe

@amoe: Hay varias formas. CTE modificadores de datos, disparadores, reglas, declaraciones múltiples en la misma transacción, ... todo depende de los requisitos exactos. Haga una nueva pregunta con sus detalles si necesita una respuesta. Siempre puede vincular a este para el contexto.
Erwin Brandstetter

5

Si usted puede agregar una columna a la tabla, el siguiente esquema haría casi 1 de trabajo:

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

Traducido de mi SQL Server nativo, con la ayuda de a_horse_with_no_name

Como mencionó ypercube en un comentario, incluso podría ir más allá:

  • Suelta la columna booleana; y
  • Crea el UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

El efecto es el mismo, pero podría decirse que es más simple y ordenado.


1 El problema es que las limitaciones existentes sólo aseguran que una fila se denomina 'activa' por otra fila existe , no que también es realmente activa. No conozco Postgres lo suficientemente bien como para implementar la restricción adicional yo mismo (al menos no en este momento), pero en SQL Server, podría hacerse de esta manera:

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

Este esfuerzo mejora un poco en el original mediante el uso de un sustituto en lugar de duplicar la dirección de correo electrónico completa.


4

La única forma de hacer cualquiera de estos sin cambios de esquema es con un disparador PL / PgSQL.

Para el caso "exactamente uno", puede hacer que las referencias sean mutuas, con un solo ser DEFERRABLE INITIALLY DEFERRED. Entonces A.b_id(FK) referencias B.b_id(PK) y B.a_id(FK) referencias A.a_id(PK). Sin embargo, muchos ORM, etc., no pueden hacer frente a restricciones diferibles. Entonces, en este caso, agregaría un FK diferido del usuario a la dirección en una columna active_address_id, en lugar de usar un activeindicador address.


El FK ni siquiera tiene que serlo DEFERRABLE.
Erwin Brandstetter
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.