PostgreSQL针对另一列创建唯一索引

3
例如,我有这个表(postgresql):
CREATE TABLE t(
 a TEXT,
 b TEXT
);
CREATE UNIQUE INDEX t_a_uniq_idx ON t(a);

我想为ba列创建唯一约束/索引。但不是简单的ADD CONSTRAINT t_ab UNIQUE (a, b)。我希望a通过b实现唯一性:
INSERT INTO t(a,b) VALUES('123', null); -- this is ok
INSERT INTO t(a,b) VALUES('456', '123'); -- this is not ok, because duplicate '123'

我应该怎么做?

编辑:

为什么我需要这样做?例如,如果我有一个用户表格,并且我想创建一个更改电子邮件的功能,我需要像这样的结构:

CREATE TABLE users(
 email TEXT,
 unconfirmed_email TEXT
 -- some other data
);
CREATE UNIQUE INDEX unq_users_email_idx ON users(email);

用户可以将值设置到unconfirmed_email列中,但前提是该值未在email列中使用。


需要更多的例子和解释。 - Gordon Linoff
@GordonLinoff 更新了问题 - Kroid
你的更新仍然存在解释的空间。如果用户将他的“电子邮件”设置为已经存在于“未确认电子邮件”的值,该怎么办?在同一行中允许“电子邮件=未确认电子邮件”吗?“未确认电子邮件唯一”? - Erwin Brandstetter
不可以。每个电子邮件必须通过两列是唯一的。 - Kroid
3个回答

1
如果需要在两列之间保持唯一性,我认为你选择的数据模型可能不正确。不应该将成对的数据存储在单行中,而是应该使用两个表格:
create table pairs (
    pairid int generated always as identity,
    . . .   -- more information about the pair, if needed
);

create table pairElements (
    pairElementId int generated always as identity,
    pairId int references pairs(pairid),
    which int check (which in (1, 2)),
    value text,
    unique (pairid, which)
);

那么这个条件很简单:
create constraint unq_pairelements_value unique pairelements(value);

是的,这个方法可以实现。但我不想为每个搜索请求都连接两个表格。难道没有更简单的解决方案吗? - Kroid

1

据我理解,您想要在 ab 组合上创建一个 UNIQUE 索引。
您缩小了更新范围(b 不应存在于 a 中)。这个解决方案更为严格。

解决方案

经过尝试和调查(见下文!),我得出了以下结论:

ALTER TABLE tbl ADD CONSTRAINT a_not_equal_b CHECK (a <> b);
ALTER TABLE tbl ADD CONSTRAINT ab_unique
   EXCLUDE USING gist ((ARRAY[hashtext(COALESCE(a, ''))
                            , hashtext(COALESCE(b, ''))]) gist__int_ops WITH &&);

db<>fiddle 這裡

由於排除約束目前(pg 12)無法與text[]一起使用,因此我們使用哈希值的int4[]hashtext()是內置的哈希函數,也用於哈希分區(等等其他用途)。 看起來非常適合這個工作。

操作類gist__int_ops由附加模塊intarray提供,每個數據庫需要安裝一次。它是可選的,解決方案也可以使用默認的數組操作類。 只需刪除gist__int_ops即可回退。 但是intarray更快。 相關:

注意事项

  • int4 可能不足以充分排除哈希冲突。您可能需要改用 bigint。但这更昂贵,无法使用 gist__int_ops 操作符类来提高性能。由您决定。

  • Unicode 具有令人沮丧的特性,即相等的字符串可以以不同的方式编码。如果您使用 Unicode(典型编码为 UTF8)并使用非 ASCII 字符(并且这对您很重要),请比较规范化形式以排除此类重复项。即将推出的 Postgres 13 添加了函数 normalize() 用于此目的。然而,这是字符类型重复的一般警告,而不是特定于我的解决方案。

  • NULL 值是允许的,但与空字符串 ('') 冲突。我宁愿使用 NOT NULL 列,并从表达式中删除 COALESCE()

到达排除约束的障碍赛道

我的第一反应是:排除约束。但它不起作用:

ALTER TABLE tbl ADD CONSTRAINT ab_unique EXCLUDE USING gist ((ARRAY[a,b]) WITH &&);
ERROR:  data type text[] has no default operator class for access method "gist"
HINT:  You must specify an operator class for the index or define a default operator class for the data type.

这个问题有一个待完成项。相关内容:

但我们不能为 text[] 使用GIN索引吗?遗憾的是,不行:

ALTER TABLE tbl ADD CONSTRAINT ab_unique EXCLUDE USING gin ((ARRAY[a,b]) WITH &&);
ERROR:  access method "gin" does not support exclusion constraints
为什么?手册:

访问方法必须支持 amgettuple(参见第61章);目前这意味着不能使用GIN。

看起来实现起来很困难,所以别抱太大希望。

如果 abinteger 列,我们可以使用整数数组来实现它的工作:

ALTER TABLE tbl ADD CONSTRAINT ab_unique EXCLUDE USING gist ((ARRAY[a,b]) WITH &&);

或者使用附加模块 intarray 中的 gist__int_ops 运算符类(通常更快):

ALTER TABLE tbl ADD CONSTRAINT ab_unique EXCLUDE USING gist ((ARRAY[a,b]) gist__int_ops WITH &&);

若想在同一行内禁止重复,可以添加一个 CHECK 约束:

ALTER TABLE tbl ADD CONSTRAINT a_not_equal_b CHECK (a <> b);

未解决的问题:如何处理NULL值。

解决方法

添加一个辅助表,将ab的值存储在一个列中:

CREATE TABLE tbl_ab(ab text PRIMARY KEY);

主表,就像你原来的那样,加上外键约束。

CREATE TABLE tbl (
  a text REFERENCES tbl_ab ON UPDATE CASCADE ON DELETE CASCADE
, b text REFERENCES tbl_ab ON UPDATE CASCADE ON DELETE CASCADE
);

使用如下函数来进行INSERT操作:INSERT
CREATE OR REPLACE FUNCTION f_tbl_insert(_a text, _b text)
 RETURNS void
 LANGUAGE sql AS
$func$
WITH ins_ab AS (
   INSERT INTO tbl_ab(ab)
   SELECT _a WHERE _a IS NOT NULL  -- NULL is allowed (?)
   UNION ALL
   SELECT _b WHERE _b IS NOT NULL
   )
INSERT INTO tbl(a,b)
VALUES (_a, _b);
$func$;

db<>fiddle 这里

或者在后台实现一个触发器来处理它。

CREATE OR REPLACE FUNCTION trg_tbl_insbef()
  RETURNS trigger AS
$func$
BEGIN
   INSERT INTO tbl_ab(ab)
   SELECT NEW.a WHERE NEW.a IS NOT NULL  -- NULL is allowed (?)
   UNION ALL
   SELECT NEW.b WHERE NEW.b IS NOT NULL;

   RETURN NEW;
END
$func$ LANGUAGE plpgsql;

CREATE TRIGGER tbl_insbef
BEFORE INSERT ON tbl
FOR EACH ROW EXECUTE PROCEDURE trg_tbl_insbef();

db<>fiddle 这里

NULL 处理可以根据需要更改。

无论哪种方式,虽然添加了(可选的)FK约束强制执行我们不能规避助手表tbl_ab,并允许在tbl_ab中进行 UPDATE DELETE 级联,但仍然需要将 UPDATEDELETE 投影到辅助表中(或实现更多触发器)。棘手的边角情况,但有解决方案。在找到上述使用hashtext()的排除约束的解决方案后,不再深入探讨...

相关:


哇,谢谢你详细的回答!我也在考虑排除约束。太遗憾了,我们不能用数组来处理字符串。 - Kroid
@Kroid:我们可以使用整数数组使其正常工作。请考虑添加的解决方案。 :) - Erwin Brandstetter

1
虽然这引出了一个有趣的问题,但我同意数据可以更好地建模 - 具体而言,列unconfirmed_email可以被视为结合了两个属性:地址和用户之间的关联,它与email列共享;以及该地址是否已确认的状态,这取决于用户和地址的组合,而不是其中之一。
这意味着应该提取一个新的表user_email_addresses
  • user_id - 对应 users 表中的外键
  • email - 不能为空
  • is_confirmed boolean 类型
有趣的是,正如通常情况下所发现的那样,这个提取的表有自然的可添加数据:
  • 地址是什么时候添加的?
  • 什么时候确认过?
  • 发送给用户的验证代码是什么?
  • 如果允许用户使用多个地址,哪个是主要地址,或者哪个用于特定目的?

我们现在可以对这个表格建模,加入各种约束条件(在某些情况下使用唯一索引,因为您不能在唯一约束上指定Where):
  • 每个用户只能与特定电子邮件地址有一个关联(无论是否确认):Constraint Unique ( user_id, email )
  • 一个电子邮件地址只能被一个用户确认:Unique Index On user_emails ( email ) Where is_confirmed Is True;
  • 每个用户只能有一个确认地址:Unique Index On user_emails ( user_id ) Where is_confirmed Is True;。您可能希望调整此设置,以允许用户确认多个地址,但只有一个“主”地址。
  • 每个用户只能有一个未确认的地址:Unique Index On user_emails ( user_id ) Where is_confirmed Is False;。这在您当前的设计中是隐含的,但实际上可能并不必要。

这让我们得到了一个重新表述的问题:如何禁止与已确认行具有相同email未确认行,但允许多个相同的未确认行。

一种方法是对匹配emailis_confirmed 不匹配的行使用Exclude约束。将其转换为int是必要的,因为在boolean上创建gist索引会失败。

Alter Table user_emails
Add Constraint unconfirmed_must_not_match_confirmed
Exclude Using gist ( 
    email With =,
    Cast(is_confirmed as Int) With <>
);

在这种情况下,只要所有复制的email具有相同的is_confirmed值,就可以允许多个副本。但是,由于我们已经限制了is_confirmed为True的多行重复项,因此仅剩下的重复项将是在所有匹配行上is_confirmed为False的行。

这里有一个db<>fiddle演示了上面的设计: https://dbfiddle.uk/?rdbms=postgres_12&fiddle=fd8e4e6a4cce79d9bc6bf07111e68df9


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接