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

Kev*_*rke 28 postgresql database-design constraint referential-integrity ddl

假设我们有用户,每个用户可以有多个电子邮件地址

CREATE TABLE emails (
    user_id integer,
    email_address text,
    is_active boolean
)
Run Code Online (Sandbox Code Playgroud)

一些示例行

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
Run Code Online (Sandbox Code Playgroud)

我想强制执行一个约束,即每个用户都只有一个活动地址。我怎样才能在 Postgres 中做到这一点?我可以这样做:

CREATE UNIQUE INDEX "user_email" ON emails(user_id) WHERE is_active=true;
Run Code Online (Sandbox Code Playgroud)

这可以防止用户拥有多个活动地址,但我相信不会防止他们的所有地址都设置为 false。

如果可能的话,我更愿意避免使用触发器或 pl/pgsql 脚本,因为我们目前没有这些脚本,而且设置起来会很困难。但我很感激知道“唯一的方法是使用触发器或 pl/pgsql”,如果是这样的话。

Erw*_*ter 21

您根本不需要触发器或 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);
Run Code Online (Sandbox Code Playgroud)

删除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);
Run Code Online (Sandbox Code Playgroud)

以下是使用活动电子邮件插入新用户的方法(根据需要):

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);
Run Code Online (Sandbox Code Playgroud)

具体的困难在于我们既没有user_id也没有email_id开始。两者都是由各自提供的序列号SEQUENCE。它不能用一个RETURNING子句解决(另一个鸡和蛋的问题)。解决方案nextval()下面的链接答案中详细解释

如果您不知道serial列的附加序列的名称,email.email_id您可以替换:

nextval('email_email_id_seq'::regclass)
Run Code Online (Sandbox Code Playgroud)

nextval(pg_get_serial_sequence('email', 'email_id'))
Run Code Online (Sandbox Code Playgroud)

以下是添加新“活动”电子邮件的方法:

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;
Run Code Online (Sandbox Code Playgroud)

SQL小提琴。

如果某些头脑简单的 ORM 不够聪明,无法处理这个问题,您可以将 SQL 命令封装在服务器端函数中。

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

还相关:

关于DEFERRABLE约束:

关于nextval()pg_get_serial_sequence()


Pau*_*ite 6

如果您可以向表中添加一列,则以下方案几乎可以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;
Run Code Online (Sandbox Code Playgroud)

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';
Run Code Online (Sandbox Code Playgroud)

通过使用代理而不是复制完整的电子邮件地址,这项工作在原来的基础上有所改进。


Cra*_*ger 5

在不更改架构的情况下执行上述任一操作的唯一方法是使用 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