web-dev-qa-db-ja.com

データベースに「少なくとも1つ」または「1つだけ」を適用するための制約

ユーザーがいて、各ユーザーが複数のメールアドレスを持つことができるとします

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

一部のサンプル行

user_id | email_address | is_active
1       | [email protected]   | t
1       | [email protected]   | f
1       | [email protected]   | f
2       | [email protected]   | t

すべてのユーザーがアクティブなアドレスを1つだけ持つという制約を適用したいと思います。 Postgresでこれを行うにはどうすればよいですか?私はこれを行うことができます:

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

これは、1つ以上のアクティブなアドレスを持つユーザーからは保護しますが、すべてのアドレスがfalseに設定されていることからは保護しないと思います。

可能であれば、トリガーやpl/pgsqlスクリプトは避けたいと思います。現時点ではそれらがなく、セットアップが難しいためです。しかし、それが事実である場合、「これを行う唯一の方法はトリガーまたはpl/pgsqlを使用することです」と知っていただければ幸いです。

25
Kevin Burke

トリガーやPL/pgSQLはまったく必要ありません。
あなたはneedDEFERRABLE制約さえもしません。
また、情報を重複して保存する必要はありません。

usersテーブルにアクティブなメールのIDを含めると、相互参照が可能になります。ユーザーと彼のアクティブなメールを挿入するというニワトリとエッグの問題を解決するには、DEFERRABLE制約が必要だと思う人もいるかもしれませんが、データ変更CTEを使用する場合は必要ありません。

これにより、ユーザーごとに1つのアクティブな電子メールが常に適用されます。

_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_制約を削除して、「最大1つのアクティブなメール」にする。 (ユーザーごとに複数のメールを保存できますが、「アクティブ」なメールはありません。)

あなたはcan _active_email_fkey_ DEFERRABLEを作成して、さらに余裕を持たせることができます(ユーザーと電子メールをsameトランザクションの個別のコマンドに挿入する)が、不要

インデックスカバレッジを最適化するために、_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', '[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);
_

具体的な困難は、最初に_user_id_も_email_id_もないことです。どちらも、それぞれのSEQUENCEから提供されるシリアル番号です。単一のRETURNING句では解決できません(別の鶏と卵の問題)。解決策はnextval()です 以下のリンクされた回答で詳細に説明されています

knowでない場合、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, '[email protected]')
   RETURNING *
   )
UPDATE users u
SET    email_id = e.email_id
FROM   e
WHERE  u.user_id = e.user_id;
_

SQLフィドル

単純なORMがこれに対処するほど賢くない場合は、SQLコマンドをサーバー側の関数にカプセル化できます。

密接に関連しており、十分な説明があります:

関連もあります:

DEFERRABLE制約について:

nextval()およびpg_get_serial_sequence()について:

18

テーブルに列を追加できる場合、次のスキームはほぼになります。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

a_horse_with_no_name の助けを借りて、私のネイティブSQLサーバーから変換

ypercube がコメントで言及されているように、さらに先に進むこともできます。

  • ブール列を削除します。そして
  • UNIQUE INDEX ON emails (UserID) WHERE (EmailAddress = ActiveAddress)を作成します

効果は同じですが、間違いなく単純で端正です。


1 問題は、既存の制約が、別の行によって「アクティブ」と呼ばれる行existsのみを保証することであり、実際にアクティブであることではないことです。私は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';
_

この取り組みは、完全な電子メールアドレスを複製するのではなく、代理を使用することにより、元のものを少し改善します。

6
Paul White 9

スキーマを変更せずにこれらのいずれかを行う唯一の方法は、PL/PgSQLトリガーを使用することです。

「1つだけ」の場合は、DEFERRABLE INITIALLY DEFERREDを使用して相互参照を作成できます。したがって、A.b_id(FK)はB.b_id(PK)を参照し、B.a_id(FK)はA.a_id(PK)を参照します。ただし、多くのORMなどは、遅延可能な制約に対処できません。したがって、この場合は、ユーザーからの延期可能なFKを列のアドレスに追加しますactive_address_id代わりにactiveaddressフラグを使用すること。

4
Craig Ringer