Restrição para aplicar "pelo menos um" ou "exatamente um" em um banco de dados

24

Digamos que temos usuários e cada usuário pode ter vários endereços de email

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

Algumas linhas de amostra

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

Quero impor uma restrição de que todo usuário tenha exatamente um endereço ativo. Como posso fazer isso no Postgres? Eu poderia fazer isso:

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

O que protegeria contra um usuário com mais de um endereço ativo, mas, acredito, não protegeria contra todos os endereços configurados como falsos.

Se possível, eu preferiria evitar um gatilho ou um script pl / pgsql, pois atualmente não temos nenhum desses e seria difícil de configurar. Mas eu gostaria de saber "a única maneira de fazer isso é com um gatilho ou pl / pgsql", se for esse o caso.

Kevin Burke
fonte

Respostas:

17

Você não precisa de gatilhos ou PL / pgSQL.
Você nem precisa de DEFERRABLE restrições.
E você não precisa armazenar nenhuma informação de forma redundante.

Inclua o ID do email ativo na userstabela, resultando em referências mútuas. Pode-se pensar que precisamos de uma DEFERRABLErestrição para resolver o problema do ovo e da galinha de inserir um usuário e seu email ativo, mas usando CTEs modificadores de dados, nem precisamos disso.

Isso aplica exatamente um e-mail ativo por usuário em todos os momentos:

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

Remova a NOT NULLrestrição de users.email_idpara torná-la "no máximo um email ativo". (Você ainda pode armazenar vários e-mails por usuário, mas nenhum deles está "ativo".)

Você pode fazer active_email_fkey DEFERRABLEpara permitir mais liberdade (inserção de usuário e e-mail em comandos separados da mesma transação), mas isso é não é necessário .

Coloquei o user_idprimeiro na UNIQUErestrição email_fk_unipara otimizar a cobertura do índice. Detalhes:

Visualização opcional:

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

Veja como você insere novos usuários com um email ativo (conforme necessário):

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

A dificuldade específica é que não temos nem user_idnem email_idpara começar. Ambos são números de série fornecidos pelo respectivo SEQUENCE. Não pode ser resolvido com uma única RETURNINGcláusula (outro problema de galinha e ovo). A solução é nextval()como explicado em pormenor na resposta ligada abaixo .

Se você não souber o nome da sequência anexada para a serialcoluna, email.email_idpoderá substituir:

nextval('email_email_id_seq'::regclass)

com

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

Veja como você adiciona um novo email "ativo":

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.

Você pode encapsular os comandos SQL nas funções do servidor, se algum ORM simplificado não for inteligente o suficiente para lidar com isso.

Intimamente relacionado, com ampla explicação:

Também relacionado:

Sobre DEFERRABLErestrições:

Sobre nextval()e pg_get_serial_sequence():

Erwin Brandstetter
fonte
Isso pode ser aplicado a 1 em pelo menos um relacionamento? Não 1 -1, como mostrado nesta resposta.
precisa saber é o seguinte
@CMCDragonkai: Sim. Exatamente um e - mail ativo por usuário é imposto. Nada impede que você adicione mais emails (inativos) para o mesmo usuário. Se você não deseja a função especial para o email ativo, os acionadores seriam uma alternativa (menos rigorosa). Mas você deve ter cuidado para cobrir todas as atualizações e exclusões. Eu sugiro que você faça uma pergunta se precisar.
Erwin Brandstetter
Existe alguma maneira de excluir usuários sem usar ON DELETE CASCADE? Apenas curioso (em cascata está funcionando bem por enquanto).
27517
@amoe: Existem várias maneiras. CTEs, gatilhos, regras de modificação de dados, várias instruções na mesma transação, ... tudo depende dos requisitos exatos. Faça uma nova pergunta com suas especificações, se precisar de uma resposta. Você sempre pode vincular a este para contextualizar.
Erwin Brandstetter 29/07
5

Se você pode adicionar uma coluna à tabela, o seguinte esquema funcionaria quase 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

Traduzido do meu SQL Server nativo, com a ajuda de a_horse_with_no_name

Como o ypercube mencionado em um comentário, você pode ir além:

  • Solte a coluna booleana; e
  • Crie o UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

O efeito é o mesmo, mas é sem dúvida mais simples e mais puro.


1 O problema é que as restrições existentes apenas garantir que uma linha referida como 'ativo' por outra linha existe , não que isso também é realmente ativo. Eu não conheço o Postgres suficientemente bem para implementar a restrição extra (pelo menos não agora), mas no SQL Server, isso poderia ser feito da seguinte maneira:

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

Esse esforço melhora um pouco o original, usando um substituto em vez de duplicar o endereço de email completo.

Paul White diz que a GoFundMonica
fonte
4

A única maneira de fazer um desses sem alterações de esquema é com um gatilho PL / PgSQL.

Para o caso "exatamente um", você pode tornar as referências mútuas, com um ser DEFERRABLE INITIALLY DEFERRED. Portanto, A.b_idreferências (FK) B.b_id(PK) e B.a_idreferências A.a_id( FK) (PK). Muitos ORMs etc não podem lidar com restrições adiadas. Portanto, nesse caso, você adicionaria um FK adiado do usuário para endereçar em uma coluna active_address_id, em vez de usar um activesinalizador address.

Craig Ringer
fonte
O FK nem precisa ser DEFERRABLE.
Erwin Brandstetter