跨表唯一

Thi*_*ter 5 postgresql foreign-key database-design constraint unique-constraint

我有一个包含用户的表。每个用户都有一个主电子邮件和一个指示该用户是否被删除的标志(我们从不硬删除用户)。

但是,每个用户还可以拥有额外的电子邮件。

无论如何,电子邮件地址必须是唯一的,我想在数据库级别强制执行这一点。对于主要电子邮件地址来说这很简单。我可以简单地添加一个带有WHERE not is_deleted限制的 UNIQUE 索引,以便可以重新使用已删除的用户的电子邮件。

然而,对于辅助电子邮件来说,这就更加棘手。

  • 如果我将它们存储在数组字段中的同一个表中,我可能不仅会失去对它们进行索引的能力(我们有超过 5 万的用户,并且需要能够通过电子邮件进行搜索,所以这是不行的),而且到目前为止据我所知,我也不能将唯一的索引/约束放入数组中。
  • 如果我使用一个单独的表(这显然更干净),我必须将is_deleted用户的标志复制到该表,这也很丑陋,但允许我简单地使用 UNIQUE 索引与WHERE not is_deleted.

有没有更好的解决方案来实现我想要做的事情?

Erw*_*ter 5

为了强制使用唯一的电子邮件地址,我将删除所有竞争电子邮件列,并将它们存储在email所有活动电子邮件的一个中央表中。还有另一个表用于删除电子邮件:

CREATE TABLE users (
  user_id  serial PRIMARY KEY
, username text UNIQUE NOT NULL
, email    text UNIQUE -- FK added below  -- can also be NOT NULL
);

CREATE TABLE email (
  email    text PRIMARY KEY
, user_id  int NOT NULL REFERENCES users ON DELETE CASCADE
, UNIQUE (user_id, email)  -- seems redundant, but required for FK
);

ALTER TABLE users ADD CONSTRAINT users_primary_email_fkey
FOREIGN KEY (user_id, email) REFERENCES email (user_id, email);

CREATE TABLE email_deleted (
  email_id serial PRIMARY KEY
, email    text NOT NULL  -- not necessarily unique
, user_id  int NOT NULL REFERENCES users ON DELETE CASCADE
);
Run Code Online (Sandbox Code Playgroud)

这边走:

  • 活动电子邮件是唯一的,由 的 PK 约束强制执行email
  • 每个用户可以拥有任意数量的活动和已删除的电子邮件,但是......
  • 每个用户只能有一个主要电子邮件。
  • 每封电子邮件始终归一个用户所有,并随该用户一起删除。
  • 要软删除电子邮件(不丢失电子邮件及其与用户的从属关系),请将行从email移至email_deleted.
    • 用户的主电子邮件不能通过这种方式删除,因为主电子邮件不能被删除。
  • 我将 FK 约束设计users_primary_email_fkey为 span (user_id, email),乍一看这似乎是多余的。但这样主电子邮件只能是同一用户实际拥有的电子邮件。
    由于MATCH SIMPLEFK 约束的默认行为,您仍然可以输入没有主要电子邮件的用户,因为如果任何列为空,则不会强制执行 FK 约束。
    细节:

UNIQUE对这个解决方案的约束是users.email多余的,但出于其他原因它可能很有用。自动创建的索引应该会派上用场(例如对于此答案中的最后一个查询)。

唯一不以这种方式强制执行的是每个用户都有一个主要电子邮件。你也可以这样做。添加NOT NULL约束到users.email

UNIQUE (user_id, email)FK 约束需要:

您无疑已经发现了上述模型中的循环引用。与人们的预期相反,这确实有效。

只要users.email可以NULL,这都是微不足道的:

  1. INSERT没有电子邮件的用户。
  2. INSERT引用拥有者的电子邮件user_id
  3. UPDATE用户设置其主要电子邮件(如果适用)。

它甚至可以与users.emailset to一起使用NOT NULL。不过,您必须同时插入用户和电子邮件:

WITH u AS (
   INSERT INTO users(username, email)
   VALUES ('user_foo', 'foo@mail.com')
   RETURNING email, user_id
   )
INSERT INTO email (email, user_id)
SELECT email, user_id
FROM   u;
Run Code Online (Sandbox Code Playgroud)

IMMEDIATEFK 约束(默认)在每个语句的末尾进行检查。以上是一种说法。这就是为什么它可以在两个单独的语句失败的情况下起作用。详细解释:

要将用户的所有电子邮件作为数组获取,首先是主电子邮件:

SELECT u.*, e.emails
FROM   users u
     , LATERAL (
      SELECT ARRAY (
      SELECT email
      FROM   email
      WHERE  user_id = u.user_id
      ORDER  BY (email <> u.email)  -- sort primary email first
      ) AS emails
   ) e
WHERE  user_id = 1;
Run Code Online (Sandbox Code Playgroud)

您可以用VIEW它创建一个以便于使用。
LATERAL需要 Postgres 9.3。在 pg 9.2 中使用相关子查询:

SELECT *, ARRAY (
             SELECT email
             FROM   email
             WHERE  user_id = u.user_id
             ORDER  BY (email <> u.email)  -- sort primary email first
             ) AS emails
FROM   users u
WHERE  user_id = 1;
Run Code Online (Sandbox Code Playgroud)

要软删除电子邮件:

WITH del AS (
   DELETE FROM email
   WHERE  email = 'spam@mail.com'
   RETURNING email, user_id
   )
INSERT INTO email_deleted (email, user_id)
SELECT email, user_id FROM del;
Run Code Online (Sandbox Code Playgroud)

要软删除给定用户的主电子邮件:

WITH upd AS (
   UPDATE users u
   SET    email = NULL
   FROM   (SELECT user_id, email FROM users WHERE user_id = 123 FOR UPDATE) old
   WHERE  old.user_id = u.user_id
   AND    u.user_id = 1
   RETURNING old.*
   )
,    del AS (
   DELETE FROM email
   USING  upd
   WHERE  email.email = upd.email
   )
INSERT INTO email_deleted (email, user_id)
SELECT email, user_id FROM upd;
Run Code Online (Sandbox Code Playgroud)

细节:

快速测试上述所有内容:SQL Fiddle


Dav*_*nes 3

考虑这个问题的一种方法是,您有两类用户,它们的规则略有不同:已删除和未删除。已删除用户的电子邮件可能会发生冲突,未删除用户的电子邮件必须是唯一的。

因为这两个类具有不同的规则(即约束),所以我不会使用标志来指示用户是否被删除,而是复制这些表:一组用于已删除的用户,一组用于未删除的用户。然后,我只需在表上为未删除的用户的电子邮件创建唯一约束。

优点:

  • 假设大多数操作都是针对“未删除”用户的,此模型提供了更好的性能。“未删除”用户表仅代表当前的活动用户集,因此它们会相应地增长和缩小。已删除的用户表只会增长(除非您允许取消删除用户)。
  • 查询所有用户仍然相对简单,如果您想让它像以前一样简单,您可以简单地创建一个视图来联合这两个集合(并根据is_deleted行的来源生成一个标志)。
  • 这不依赖CREATE INDEX...WHERE. 我提到这一点的原因是部分索引并未得到普遍支持(例如MS-SQL 和DB2)。显然 PostgreSQL 没有任何问题,但避免未来潜在的迁移障碍总没有坏处。

缺点:

  • 删除(或取消删除)用户更加复杂。现在您不必翻转简单的标志字段,而是需要移动行。但是,您提到您正在使用 SQLAlchemy(不错的选择!)。如果我没记错的话,构建这样的语句不会有任何问题,INSERT..SELECT FROM这样应该可以使将条目从未删除到已删除(反之亦然)变得简单。
  • 用户表数量是原来的两倍(+ 联合的可选视图)。这不会影响性能,但可能会使任何模式图变得复杂!