当限制为一对多时,在一对多对多桥接关系中强制执行唯一性

Mar*_*ers 5 postgresql trigger physical-design unique-constraint

我们定义了一系列配置,其中,在 RESTful API 的驱动下,最终用户可以构建新的修订版本。配置的某些组件可以有多个值;修订涉及具有一对多关系的多个表。

因为配置被运往别处,修订被标记为已部署,并且变得不可变。如果用户想对配置进行更改,他们必须创建一个新修订(可以从现有修订中克隆)。每个配置的一个 修订版可以标记为“当前”;这允许用户随意在过去的修订之间切换,或者通过不选择任何修订来完全禁用配置。当前版本已部署,当将不同版本标记为“当前”时,您将替换已部署的配置。

我们已经准备好了一切来强制部署修订的不变性;当您第一次使用修订作为当前修订时,该deployed列会自动转换为TRUE,并且所有进一步的INSERT,UPDATEDELETE与修订相关表中部署的修订 ID 匹配的行的操作都将被阻止。

但是,用于公共名称表中的name列的任何值在所有当前配置的所有“当前”修订中都必须是唯一的。我正在尝试找出执行此操作的最佳策略。

如果这是从配置到公共名称的简单一对多关系,则可以通过对name列使用唯一约束来解决。相反,这是一种一对多模式,revision充当桥接表,并将current_revision_id一对多对多关系“折叠”为从配置到虚拟的一对多关系公共名称。

这是一组简化的表格,用于说明我们的情况:

-- Configurations
CREATE TABLE config (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    current_revision_id INT
);

-- Have multiple revisions
CREATE TABLE revision (
    id INT PRIMARY KEY,
    config_id INT NOT NULL REFERENCES config(id),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    description VARCHAR,
    foo INT NOT NULL,
    bar BOOLEAN NOT NULL,
    deployed BOOLEAN NOT NULL DEFAULT FALSE
);

-- A configuration has one _current_ revision
ALTER TABLE config
  ADD CONSTRAINT current_revision_id_fk
  FOREIGN KEY (current_revision_id)
  REFERENCES revision(id);
  
-- Revisions are automatically numbered in a view
CREATE VIEW numbered_revision AS (
    SELECT *,
    row_number() OVER (
        PARTITION BY config_id
        ORDER BY created_at, id
    ) AS number
    FROM revision
);

-- Configurations have multiple 'public names'
CREATE TABLE public_name (
    id INT PRIMARY KEY,
    revision_id INT NOT NULL REFERENCES revision(id),
    name VARCHAR(100),

    UNIQUE (revision_id, name)
);
Run Code Online (Sandbox Code Playgroud)

该视图仅用于为每个配置提供具有无间隙编号的修订(修订永远不会被删除)。

作为ERD图:

表格图

一些示例数据来说明设置:

INSERT INTO config (id, name) VALUES
  (17, 'config_foo'),
  (42, 'config_bar');
  
INSERT INTO revision (id, config_id, created_at, description, foo, bar) VALUES
  (11, 17, '2021-05-29 09:07:18', 'Foo configuration, first draft', 81, TRUE),
  (19, 17, '2021-05-29 10:42:17', 'Foo configuration, second draft', 73, TRUE),
  (23, 42, '2021-05-29 09:36:52', 'Bar configuration, first draft', 118, FALSE);

INSERT INTO public_name (id, revision_id, name) VALUES
  -- public names for foo configuration, first draft
  (83, 11, 'some.name'),
  (84, 11, 'other.name'),
  -- public names for foo configuration, second draft
  (85, 19, 'revised.name'),
  (86, 19, 'other.name'),
  (87, 19, 'third.name'),
  -- public names for bar configuration, first draft;
  -- some of the names here are the same used by foo configurations
  (88, 23, 'some.name'),
  (89, 23, 'unique.name'),
  (90, 23, 'other.name');
  
-- Foo configuration has a current, published revision:
UPDATE config SET current_revision_id = 19 WHERE id = 17;
UPDATE revision SET deployed = TRUE WHERE id in (11, 19);
Run Code Online (Sandbox Code Playgroud)

这是显示示例数据集的查询:

SELECT
  c.name AS config,
  rev.number AS revision,
  rev.deployed,
  CASE WHEN c.current_revision_id = rev.id 
    THEN 'ACTIVE'
    ELSE ''
  END AS status,
  string_agg(p.name, ', ' ORDER BY p.name) AS names
FROM config c
JOIN numbered_revision AS rev ON c.id = rev.config_id
JOIN public_name p ON p.revision_id = rev.id
GROUP BY c.id, rev.id, rev.number, rev.deployed
ORDER BY c.id, rev.number;
Run Code Online (Sandbox Code Playgroud)
配置 修订 部署 地位 名字
config_foo 1 其他.name, some.name
config_foo 2 积极的 other.name , 修订.name, 第三名
配置栏 1 F other.name , some.name, unique.name

db<>在这里摆弄

在上面的输出表中,第二行表示“当前”修订,已公开部署),并且该行已被授予对names列中公共名称的独占访问权限。

第三行代表具有草稿修订的配置。任何将其设置为当前for 的尝试都config_bar应该失败,因为该名称other.name已用于config_foo,修订版 2。如果将来config_foo要创建不包含 的新修订版,则other.name只有这样才能使config_bar修订版 1 成为最新版。

我们确实预先验证了这个约束;当不满足前提条件时,API 会运行一些检查并阻止将配置标记为当前配置。public_name表中的名称也被限制为每个修订版是唯一的 ( UNIQUE (revision_id, name))。这些都不能防止竞争条件,它们只是降低了竞争条件发生的速度。

我希望 CONSTRAINT TRIGGER on configUPDATEcurrent_revision_id列的s上触发,足以强制执行此约束:

CREATE OR REPLACE FUNCTION unique_current_names() RETURNS trigger
   LANGUAGE plpgsql AS
$$BEGIN
   IF EXISTS (
     SELECT 1
     FROM public_name p
     WHERE
       p.revision_id = NEW.current_revision_id
     AND p.name IN (
       SELECT pp.name 
       FROM config AS pc
       JOIN public_name pp ON pp.revision_id = pc.current_revision_id
        AND pc.id != OLD.id
     )
   ) THEN
      RAISE EXCEPTION 'Public name is already published';
   END IF;
 
   RETURN NEW;
END;$$;
 
DROP TRIGGER IF EXISTS unique_current_names_trig ON config;
CREATE CONSTRAINT TRIGGER unique_current_names_trig
   AFTER UPDATE OF current_revision_id ON config
   DEFERRABLE INITIALLY DEFERRED
   FOR EACH ROW EXECUTE PROCEDURE unique_current_names();
Run Code Online (Sandbox Code Playgroud)

(请注意,之间的关系config,并public_name在,在一般情况下,许多一对多连接,但对于更具体的current_revision_id情况下,它是一个一对多的连接,并且可以使用config.current_version_id = public_name.version_id直接列表中的名称)。

我担心的是,即使这个触发器在事务的最后触发,仍然存在竞争条件的可能性,其中另一个连接也试图使修订版本与公共名称冲突。

OTOH,因为所有的更新和插入是基于REST的API操作的结果,绝不是包括多个操作(升级的交易public_name设置current_revision_id)。这足以防止这里的竞争条件,还是我错过了一些极端情况?

另一种选择可能是将当前修订的公共名称复制到单独的“已发布名称”表中(带有触发器;删除所有旧名称,插入所有新名称),在那里的名称列上有一个 UNIQUE 约束。这会比约束触发器更好吗?

请注意,我们不能使用名称空间或其他名称(公共互联网上的主机名)来使它们唯一。一旦部署,名称必须完全独立。

我们知道该设计允许配置引用revision_id属于不同配置的电流。这是我们在应用程序级别明确防范的一种可能性,但触发器也可以处理这种情况。

Mar*_*ers 1

正如Laurenz Albe 所证实的,使用约束触发器不会阻止竞争条件,除非我们将事务隔离级别SERIALIZABLE切换到。这会使我们的应用程序逻辑变得复杂,因为我们必须重试提交。

现在,我们不再使用约束触发器,而是使用ON INSERT OR UPDATE触发器将已发布的公共名称复制到新表中。然后,对该表强制执行唯一约束,因此不会遇到相同的事务隔离问题。如果两个事务尝试将具有冲突名称的修订版提升为公开,则其中一个事务将因唯一约束而失败。

这是published_public_name表和触发器config的样子:

CREATE TABLE published_public_name (
  id INT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  config_id INT NOT NULL REFERENCES config(id) ON DELETE CASCADE,
  name VARCHAR(100) NOT NULL UNIQUE
);

CREATE OR REPLACE FUNCTION copy_published_public_names()
RETURNS TRIGGER
AS $BODY$
BEGIN
  -- OLD / NEW is a row in the config table
  -- Trigger is called for both updates and inserts
  IF TG_OP = 'UPDATE' THEN
    IF OLD.current_revision_id IS NOT DISTINCT FROM NEW.current_revision_id THEN
      -- Nothing changed
      RETURN NEW;
    END IF;

    IF OLD.current_revision_id IS NOT NULL THEN
      DELETE FROM published_public_name
      WHERE id IN (
        SELECT ppn.id
        FROM published_public_name ppn
        WHERE ppn.config_id = OLD.id
        ORDER BY ppn.name
      );
    END IF;
  END IF;

  IF NEW.current_revision_id IS NOT NULL THEN
    INSERT INTO published_public_name(config_id, name)
    SELECT NEW.id, pn.name
    FROM public_name pn
    WHERE pn.revision_id = NEW.current_revision_id
    ORDER BY pn.name;
  END IF;
  
  RETURN NEW;
END
$BODY$
LANGUAGE plpgsql;

CREATE TRIGGER copy_published_public_names_trigger
BEFORE INSERT OR UPDATE OF current_revision_id ON config
FOR EACH ROW
EXECUTE FUNCTION copy_published_public_names();
Run Code Online (Sandbox Code Playgroud)

这处理INSERTUPDATE。删除是通过外键处理ON DELETE CASCADEpublished_public_name.config_id

我在触发器的and语句中包含了一个ORDER BY子句,以避免在涉及多个名称时出现潜在的死锁。我不能 100% 确定是否需要订购,但如果不需要也没有什么坏处。DELETEINSERTDELETE

当尝试发布具有已被另一个配置使用的名称的修订时,该语句失败并显示:

ERROR:  duplicate key value violates unique constraint "published_public_name_name_key"
DETAIL:  Key (name)=(other.name) already exists.
CONTEXT:  SQL statement "INSERT INTO published_public_name(config_id, name)
        SELECT NEW.id, pn.name
        FROM public_name pn
        WHERE pn.revision_id = NEW.current_revision_id"
PL/pgSQL function copy_published_public_names() line 18 at SQL statement
Run Code Online (Sandbox Code Playgroud)

请参阅我的新db<>fiddle 修订版