Kendala untuk menegakkan "setidaknya satu" atau "tepat satu" dalam database

24

Katakanlah kita memiliki pengguna dan setiap pengguna dapat memiliki beberapa alamat email

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

Beberapa baris sampel

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

Saya ingin menerapkan batasan bahwa setiap pengguna memiliki tepat satu alamat aktif. Bagaimana saya bisa melakukan ini di Postgres? Saya bisa melakukan ini:

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

Yang akan melindungi terhadap pengguna yang memiliki lebih dari satu alamat aktif, tetapi saya percaya, tidak akan melindungi terhadap semua alamat mereka yang disetel ke false.

Jika memungkinkan, saya lebih suka menghindari pemicu atau skrip pl / pgsql, karena saat ini kami tidak memiliki salah satunya & akan sulit untuk diatur. Tapi saya akan menghargai mengetahui "satu-satunya cara untuk melakukan ini adalah dengan pemicu atau pl / pgsql", jika itu masalahnya.

Kevin Burke
sumber

Jawaban:

17

Anda tidak perlu pemicu atau PL / pgSQL sama sekali.
Anda bahkan tidak perlu DEFERRABLE kendala.
Dan Anda tidak perlu menyimpan informasi apa pun secara berlebihan.

Sertakan ID email aktif dalam userstabel, menghasilkan referensi bersama. Orang mungkin berpikir kita perlu DEFERRABLEkendala untuk menyelesaikan masalah ayam-dan-telur dari memasukkan pengguna dan emailnya yang aktif, tetapi menggunakan CTE pengubah data kita bahkan tidak memerlukan itu.

Ini memberlakukan tepat satu email aktif per pengguna setiap saat:

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

Hapus NOT NULLbatasan dari users.email_iduntuk membuatnya "paling banyak satu email aktif". (Anda masih dapat menyimpan beberapa email per pengguna, tetapi tidak ada satu pun yang "aktif".)

Anda dapat membuat active_email_fkey DEFERRABLElebih banyak kelonggaran (masukkan pengguna dan email dalam perintah terpisah dari transaksi yang sama ), tetapi itu tidak perlu .

Aku meletakkan user_idpertama di UNIQUEkendala email_fk_uniuntuk cakupan indeks mengoptimalkan. Detail:

Tampilan opsional:

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

Begini cara Anda memasukkan pengguna baru dengan email aktif (sesuai kebutuhan):

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

Kesulitan khusus adalah bahwa kita tidak memiliki user_idatau tidak email_idmemulai. Keduanya adalah nomor seri yang disediakan dari masing-masing SEQUENCE. Itu tidak bisa diselesaikan dengan satu RETURNINGklausa (masalah ayam dan telur lainnya). Solusinya adalah nextval()sebagaimana dijelaskan secara terperinci dalam jawaban terkait di bawah .

Jika Anda tidak tahu nama urutan terlampir untuk serialkolom email.email_idAnda dapat mengganti:

nextval('email_email_id_seq'::regclass)

dengan

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

Berikut cara Anda menambahkan email "aktif" baru:

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.

Anda mungkin merangkum perintah SQL dalam fungsi sisi server jika beberapa ORM yang berpikiran sederhana tidak cukup pintar untuk mengatasinya.

Terkait erat, dengan banyak penjelasan:

Juga terkait:

Tentang DEFERRABLEkendala:

Tentang nextval()dan pg_get_serial_sequence():

Erwin Brandstetter
sumber
Bisakah ini diterapkan pada 1 untuk setidaknya satu hubungan? Bukan 1 -1 seperti yang ditunjukkan dalam jawaban ini.
CMCDragonkai
@ CMCDragonkai: Ya. Tepat satu email aktif per pengguna diberlakukan. Tidak ada yang menghalangi Anda menambahkan lebih banyak (tidak aktif) email untuk pengguna yang sama. Jika Anda tidak ingin peran khusus untuk email aktif, pemicu akan menjadi alternatif (kurang ketat). Tetapi Anda harus berhati-hati untuk menutupi semua pembaruan dan penghapusan. Saya sarankan Anda mengajukan pertanyaan jika Anda membutuhkan ini.
Erwin Brandstetter
Apakah ada cara untuk menghapus pengguna tanpa menggunakan ON DELETE CASCADE? Hanya ingin tahu (cascading berfungsi dengan baik untuk saat ini).
amoe
@ amoe: Ada berbagai cara. CTE pemodifikasi data, pemicu, aturan, beberapa pernyataan dalam transaksi yang sama, ... semuanya tergantung pada persyaratan yang tepat. Ajukan pertanyaan baru dengan spesifik Anda jika Anda membutuhkan jawaban. Anda selalu dapat menautkan ini untuk konteks.
Erwin Brandstetter
5

Jika Anda bisa menambahkan kolom ke tabel, skema berikut akan hampir 1 bekerja:

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

Diterjemahkan dari SQL Server asli saya, dengan bantuan dari a_horse_with_no_name

Seperti ypercube disebutkan dalam komentar, Anda bahkan bisa melangkah lebih jauh:

  • Jatuhkan kolom boolean; dan
  • Buat UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)

Efeknya sama, tetapi bisa dibilang lebih sederhana dan lebih rapi.


1 Masalahnya adalah bahwa kendala yang ada hanya memastikan bahwa baris yang disebut 'aktif' dengan baris lain ada , bukan bahwa itu juga sebenarnya aktif. Saya tidak tahu Postgres cukup baik untuk mengimplementasikan kendala ekstra sendiri (setidaknya tidak sekarang), tetapi dalam SQL Server, itu bisa dilakukan dengan demikian:

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

Upaya ini sedikit meningkatkan pada aslinya dengan menggunakan pengganti daripada menduplikasi alamat email lengkap.

Paul White mengatakan GoFundMonica
sumber
4

Satu-satunya cara untuk melakukan ini tanpa perubahan skema adalah dengan pemicu PL / PgSQL.

Untuk kasus "tepat satu", Anda dapat membuat referensi saling menguntungkan, dengan satu wujud DEFERRABLE INITIALLY DEFERRED. Jadi A.b_id(FK) referensi B.b_id(PK) dan B.a_id(FK) referensi A.a_id(PK). Banyak ORMs dll tidak dapat mengatasi kendala yang sulit ditangguhkan. Jadi, dalam hal ini Anda akan menambahkan FK yang ditangguhkan dari pengguna ke alamat pada kolom active_address_id, alih - alih menggunakan activebendera address.

Craig Ringer
sumber
FK bahkan tidak perlu DEFERRABLE.
Erwin Brandstetter