Обмеження в застосуванні "принаймні одного" або "точно одного" в базі даних


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: Є різні способи. Змінення даних CTE, тригери, правила, кілька заяв в одній транзакції, ... все залежить від конкретних вимог. Задайте нове запитання зі своєю специфікою, якщо вам потрібна відповідь. Ви завжди можете зв’язатись із цим контекстом.
Erwin Brandstetter

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-сервера за допомогою імені 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';

Це зусилля трохи покращує оригінал, використовуючи сурогат, а не дублюючи повну електронну адресу.


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.
Erwin Brandstetter
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.