Ograniczenie do wymuszania „co najmniej jednego” lub „dokładnie jednego” w bazie danych

24

Powiedzmy, że mamy użytkowników i każdy użytkownik może mieć wiele adresów e-mail

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

Niektóre przykładowe wiersze

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

Chcę wymusić ograniczenie, że każdy użytkownik ma dokładnie jeden aktywny adres. Jak mogę to zrobić w Postgres? Mógłbym to zrobić:

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

Który chroniłby przed użytkownikiem mającym więcej niż jeden aktywny adres, ale, moim zdaniem, nie chroniłby przed ustawieniem wszystkich adresów na fałszywe.

Jeśli to możliwe, wolałbym unikać wyzwalacza lub skryptu pl / pgsql, ponieważ obecnie nie mamy żadnego z nich i byłoby to trudne do skonfigurowania. Ale doceniłbym wiedząc, że „jedynym sposobem na to jest użycie wyzwalacza lub pl / pgsql”, jeśli tak jest.

Kevin Burke
źródło

Odpowiedzi:

17

W ogóle nie potrzebujesz wyzwalaczy ani PL / pgSQL.
Nie potrzebujesz nawet DEFERRABLEograniczeń.
I nie musisz przechowywać żadnych informacji nadmiarowo.

Dołącz identyfikator aktywnej wiadomości e-mail do userstabeli, co spowoduje wzajemne odniesienia. Ktoś może pomyśleć, że potrzebujemy DEFERRABLEograniczenia, aby rozwiązać problem z kurczakiem i jajkami, polegający na wstawianiu użytkownika i jego aktywnego adresu e-mail, ale przy użyciu CTE modyfikujących dane nawet tego nie potrzebujemy.

Wymusza to zawsze dokładnie jeden aktywny e-mail na użytkownika :

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

Usuń NOT NULLograniczenie z, users.email_idaby „najwyżej jeden aktywny e-mail”. (Nadal możesz przechowywać wiele wiadomości e-mail na użytkownika, ale żaden z nich nie jest „aktywny”).

Państwo może zrobić active_email_fkey DEFERRABLE, aby umożliwić większą swobodę (insert użytkownika i adres e-mail w osobnych rozkazów samej transakcji), ale to nie jest konieczne .

Na user_idpierwszym miejscu stawiam UNIQUEograniczenie email_fk_unioptymalizacji indeksu. Detale:

Opcjonalny widok:

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

Oto jak wstawiać nowych użytkowników za pomocą aktywnego adresu e-mail (zgodnie z wymaganiami):

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

Szczególna trudność polega na tym, że nie mamy ani, user_idani email_idna początek. Oba są numerami seryjnymi dostarczonymi z odpowiednich SEQUENCE. Nie można tego rozwiązać za pomocą jednej RETURNINGklauzuli (kolejny problem z kurczakiem i jajkiem). Rozwiązanie to jest nextval()jak opisano szczegółowo w połączonej odpowiedzi poniżej .

Jeśli nie znasz nazwy dołączonej sekwencji dla serialkolumny, email.email_idmożesz ją zastąpić:

nextval('email_email_id_seq'::regclass)

z

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

Oto jak dodajesz nowy „aktywny” e-mail:

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.

Możesz zawrzeć polecenia SQL w funkcjach po stronie serwera, jeśli niektóre proste ORM nie są wystarczająco inteligentne, aby sobie z tym poradzić.

Ściśle powiązane, z dużym wyjaśnieniem:

Powiązane również:

O DEFERRABLEograniczeniach:

O nextval()i pg_get_serial_sequence():

Erwin Brandstetter
źródło
Czy można to zastosować do 1 do co najmniej jednego związku? Nie 1 -1, jak pokazano w tej odpowiedzi.
CMCDragonkai 30.04.17
@CMCDragonkai: Tak. Wymuszana jest dokładnie jedna aktywna wiadomość e-mail na użytkownika. Nic nie stoi na przeszkodzie, aby dodać więcej (nieaktywnych) e-maili dla tego samego użytkownika. Jeśli nie chcesz specjalnej roli dla aktywnego e-maila, wyzwalacze byłyby (mniej surową) alternatywą. Ale musisz być ostrożny, aby uwzględnić wszystkie aktualizacje i usunięcia. Proponuję zadać pytanie, jeśli jest to potrzebne.
Erwin Brandstetter
Czy istnieje sposób na usunięcie użytkowników bez użycia ON DELETE CASCADE? Po prostu ciekawy (kaskadowanie na razie działa dobrze).
amoe
@amoe: Istnieją różne sposoby. CTE modyfikujące dane, wyzwalacze, reguły, wiele wyciągów w tej samej transakcji ... wszystko zależy od dokładnych wymagań. Zadaj nowe pytanie ze swoją specyfiką, jeśli potrzebujesz odpowiedzi. Zawsze możesz utworzyć link do tego kontekstu.
Erwin Brandstetter,
5

Jeśli możesz dodać kolumnę do tabeli, poniższy schemat prawie działałby 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

Przetłumaczone z mojego macierzystego programu SQL Server, z pomocą a_horse_w_no_name

Jak wspomniano w komentarzu ypercube , możesz nawet pójść dalej:

  • Upuść kolumnę logiczną; i
  • Utwórz UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

Efekt jest taki sam, ale jest prawdopodobnie prostszy i schludniejszy.


1 Problem polega na tym, że istniejące ograniczenia zapewniają tylko, że wiersz określany jako „aktywny” przez inny wiersz istnieje , a nie że jest on faktycznie aktywny. Nie znam Postgresa wystarczająco dobrze, aby samodzielnie wdrożyć dodatkowe ograniczenie (przynajmniej nie teraz), ale w SQL Server można to zrobić w następujący sposób:

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

Wysiłek ten poprawia się nieco w stosunku do oryginału poprzez użycie surogatu zamiast duplikowania pełnego adresu e-mail.

Paul White mówi GoFundMonica
źródło
4

Jedynym sposobem na zrobienie jednego z nich bez zmian schematu jest użycie wyzwalacza PL / PgSQL.

W przypadku „dokładnie jednego” można uczynić odniesienia wzajemnymi, z jednym bytem DEFERRABLE INITIALLY DEFERRED. Tak więc A.b_id(FK) odniesienia B.b_id(PK) i B.a_id(FK) odniesienia A.a_id(PK). Wiele ORM itp. Nie jest jednak w stanie poradzić sobie z ograniczeniami, które można odłożyć. Więc w tym przypadku dodajesz odroczony FK od użytkownika do adresu w kolumnie active_address_id, zamiast używać activeflagi na address.

Craig Ringer
źródło
FK nawet nie musi DEFERRABLE.
Erwin Brandstetter,