在数据库中强制执行“至少一个”或“完全一个”的约束


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;

这样做可以防止用户拥有多个活动地址,但我相信不能防止其所有地址都设置为false。

如果可能的话,我宁愿避免使用触发器或pl / pgsql脚本,因为我们目前没有任何触发器,并且设置起来很困难。但是,如果是这种情况,我很高兴知道“唯一的方法就是使用触发器或pl / pgsql”。

Answers:


17

您根本不需要触发器或PL / pgSQL。
您甚至不需要 DEFERRABLE约束。
而且您不需要冗余地存储任何信息。

users表中包括活动电子邮件的ID ,从而产生相互引用。也许有人认为我们需要一个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放在第一位以优化索引覆盖率。细节:UNIQUEemail_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提琴。

如果一些头脑简单的ORM不够智能,无法解决此问题,则可以将SQL命令封装在服务器端函数中。

密切相关,有充分的解释:

也相关:

关于DEFERRABLE约束:

关于nextval()pg_get_serial_sequence()


可以将其应用于至少一对一的关系吗?不是1 -1,如该答案所示。
CMCDragonkai '17

@CMCDragonkai:是的。整整一个活跃的每个用户的电子邮件强制执行。没有什么可以阻止您为同一用户添加更多(非活动)电子邮件的。如果您不希望活动电子邮件具有特殊角色,则可以使用触发器(不太严格)。但是您必须小心涵盖所有更新和删除。我建议您问一个问题是否需要这个。
Erwin Brandstetter

有没有不用删除用户的方法ON DELETE CASCADE?只是好奇(级联现在可以正常工作)。
Amoe

@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

a_horse_with_no_name的帮助下从我的本机SQL Server转换而来

正如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


FK甚至不必如此DEFERRABLE
Erwin Brandstetter
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.