Ограничение для применения «хотя бы одного» или «ровно одного» в базе данных

24

Скажем, у нас есть пользователи, и каждый пользователь может иметь несколько адресов электронной почты

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

Несколько примеров строк

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

Я хочу применить ограничение, чтобы у каждого пользователя был ровно один активный адрес. Как я могу сделать это в Postgres? Я мог бы сделать это:

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

Это защитило бы от пользователя, имеющего более одного активного адреса, но, я считаю, не защитило бы от того, что все его адреса были установлены в ложь.

Если возможно, я бы предпочел избегать триггера или сценария pl / pgsql, так как у нас в настоящее время нет ни одного из них, и было бы сложно его настроить. Но я был бы признателен, если бы знал, что «единственный способ сделать это с помощью триггера или pl / pgsql», если это так.

Кевин Берк
источник

Ответы:

17

Вам не нужны триггеры или PL / pgSQL вообще.
Вам даже не нужны DEFERRABLE ограничения.
И вам не нужно хранить любую информацию с избыточностью.

Включите идентификатор активной электронной почты в usersтаблицу, что приведет к взаимным ссылкам. Кто-то может подумать, что нам нужно DEFERRABLEограничение для решения проблемы вставки пользователя и его активной электронной почты, но при использовании CTE с модификацией данных нам это даже не нужно.

Это обеспечивает использование только одного активного электронного письма для каждого пользователя :

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

Снимите NOT NULLограничение, users.email_idчтобы сделать его "не более одного активного письма". (Вы можете хранить несколько электронных писем для каждого пользователя, но ни одно из них не является «активным».)

Вы можете сделать так, active_email_fkey DEFERRABLEчтобы предоставить больше возможностей (вставить пользователя и адрес электронной почты в отдельные команды одной и той же транзакции), но это не обязательно .

Я поставил на user_idпервое место UNIQUEограничение email_fk_uniпо оптимизации охвата индекса. Детали:

Дополнительный вид:

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

Вот как вы добавляете новых пользователей с активной электронной почтой (по мере необходимости):

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

Особая трудность в том, что у нас нет ни того, user_idни другого email_id. Оба являются серийными номерами, предоставленными соответствующими SEQUENCE. Это не может быть решено с помощью одного RETURNINGпредложения (еще одна проблема курицы и яйца). Решение nextval()как подробно объяснено в связанном ответе ниже .

Если вы не знаете имя прикрепленной последовательности для serialстолбца, email.email_idвы можете заменить:

nextval('email_email_id_seq'::regclass)

с

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

Вот как вы добавляете новый «активный» адрес электронной почты:

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.

Вы можете инкапсулировать команды SQL в функции на стороне сервера, если какой-то простой ORM недостаточно умен, чтобы справиться с этим.

Тесно связаны, с достаточным объяснением:

Также связано:

Об DEFERRABLEограничениях:

О nextval()и pg_get_serial_sequence():

Эрвин Брандштеттер
источник
Может ли это быть применено к отношениям от 1 до хотя бы одного? Не 1 -1, как показано в этом ответе.
CMCDragonkai
@CMCDragonkai: Да. Для каждого пользователя применяется только одно активное электронное письмо. Ничто не мешает вам добавлять больше (неактивных) писем для одного и того же пользователя. Если вам не нужна особая роль для активной электронной почты, триггеры будут (менее строгой) альтернативой. Но вы должны быть осторожны, чтобы покрыть все обновления и удаления. Я предлагаю вам задать вопрос, если вам это нужно.
Эрвин Брандштеттер
Есть ли способ удалить пользователей без использования ON DELETE CASCADE? Просто любопытно (пока что работает нормально).
amoe
@amoe: есть разные способы. CTE, модифицирующие данные, триггеры, правила, несколько операторов в одной транзакции, ... все зависит от конкретных требований. Задайте новый вопрос со своей спецификой, если вам нужен ответ. Вы всегда можете связаться с этим для контекста.
Эрвин Брандштеттер
5

Если вы можете добавить столбец в таблицу, следующая схема будет работать почти 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

Перевод с моего собственного SQL Server, с помощью a_horse_with_no_name

Как упомянуто в комментарии ypercube , вы можете даже пойти дальше:

  • Оставьте логическую колонку; а также
  • Создать UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

Эффект тот же, но он, возможно, проще и аккуратнее.


1 Проблема заключается в том, что существующие ограничениях только гарантировать , что строка упоминается как «активные» другая строка существует , не то, что это на самом деле также активно. Я не знаю Postgres достаточно хорошо, чтобы самостоятельно реализовать дополнительное ограничение (по крайней мере, сейчас), но в SQL Server это можно сделать так:

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

Эти усилия немного улучшают оригинал, используя суррогат, а не дублируя полный адрес электронной почты.

Пол Уайт говорит, что GoFundMonica
источник
4

Единственный способ сделать это без изменений схемы - использовать триггер PL / PgSQL.

Для «точно одного» случая вы можете сделать ссылки взаимными, с одним существом DEFERRABLE INITIALLY DEFERRED. Так A.b_id(FK) ссылки B.b_id(PK) и B.a_id(FK) ссылки A.a_id(PK). Многие ORM и т. Д. Не могут справиться с отложенными ограничениями. Таким образом, в этом случае вы бы добавили отложенный FK от пользователя к адресу в столбце active_address_id, вместо использования activeфлага address.

Крейг Рингер
источник
ФК даже не должен быть DEFERRABLE.
Эрвин Брандштеттер,