Dedupe org third parties and add unique index
Add the partial unique index that enforces one org ThirdParty per catalog vendor per organization, on (organization_id, common_third_party_id) where common_third_party_id is not null. Manually created vendors (null common id) stay unconstrained. Existing data may already hold duplicates left by the old auto-create race, which would make the index build fail, so a preceding DO block merges them first. It maps each duplicate onto the earliest-created survivor and, driven by pg_constraint / pg_index introspection, repoints every foreign key that references third_parties(id) onto the survivor, removing link rows that would collide on a referencing table's unique key before repointing, then deletes the extras. The dedupe is destructive and introspection-driven; validate it against a production dump before deploying. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
134
pkg/coredata/migrations/20260611T120000Z.sql
Normal file
134
pkg/coredata/migrations/20260611T120000Z.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- Enforce a single org ThirdParty per catalog vendor per organization.
|
||||
--
|
||||
-- The tracker-mapping worker used to materialize org third parties from
|
||||
-- the catalog and could race the load-then-create check, leaving several
|
||||
-- rows that share the same (organization_id, common_third_party_id).
|
||||
-- Org third parties are now created only through the explicit import
|
||||
-- action; this migration both cleans up the historical duplicates and
|
||||
-- adds the partial unique index that makes the invariant enforceable.
|
||||
--
|
||||
-- DESTRUCTIVE: the DO block below merges duplicate org third parties onto
|
||||
-- the earliest-created survivor by repointing every foreign key that
|
||||
-- references third_parties(id) and then deleting the extras. It is driven
|
||||
-- by catalog introspection (pg_constraint / pg_index) so it covers every
|
||||
-- referencing table without hard-coding the list, and it removes link
|
||||
-- rows that would collide on a referencing table's unique key before
|
||||
-- repointing. Validate it against a production dump before deploying.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
fk RECORD;
|
||||
uq RECORD;
|
||||
has_dupes boolean;
|
||||
BEGIN
|
||||
-- Map each duplicate row to the survivor it should be merged into:
|
||||
-- the earliest-created row for the (organization_id,
|
||||
-- common_third_party_id) pair, ties broken by id.
|
||||
CREATE TEMP TABLE _tp_dedupe ON COMMIT DROP AS
|
||||
SELECT t.id AS dup_id, k.survivor_id
|
||||
FROM third_parties t
|
||||
JOIN (
|
||||
SELECT
|
||||
organization_id,
|
||||
common_third_party_id,
|
||||
(array_agg(id ORDER BY created_at ASC, id ASC))[1] AS survivor_id
|
||||
FROM third_parties
|
||||
WHERE common_third_party_id IS NOT NULL
|
||||
GROUP BY organization_id, common_third_party_id
|
||||
HAVING count(*) > 1
|
||||
) k
|
||||
ON t.organization_id = k.organization_id
|
||||
AND t.common_third_party_id = k.common_third_party_id
|
||||
WHERE t.id <> k.survivor_id;
|
||||
|
||||
SELECT EXISTS (SELECT 1 FROM _tp_dedupe) INTO has_dupes;
|
||||
|
||||
IF has_dupes THEN
|
||||
-- For every single-column foreign key that references
|
||||
-- third_parties(id) ...
|
||||
FOR fk IN
|
||||
SELECT c.conrelid::regclass::text AS tbl,
|
||||
a.attname::text AS col
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f'
|
||||
AND c.confrelid = 'third_parties'::regclass
|
||||
AND array_length(c.conkey, 1) = 1
|
||||
LOOP
|
||||
-- ... drop duplicate-side rows that would collide with a
|
||||
-- survivor-side row on any unique key that includes the FK
|
||||
-- column (the survivor's row wins; this realizes the union of
|
||||
-- the two vendors' links).
|
||||
FOR uq IN
|
||||
SELECT (
|
||||
SELECT string_agg(
|
||||
format('s.%I IS NOT DISTINCT FROM d.%I',
|
||||
att.attname, att.attname),
|
||||
' AND ')
|
||||
FROM pg_attribute att
|
||||
WHERE att.attrelid = i.indrelid
|
||||
AND att.attnum = ANY (i.indkey)
|
||||
AND att.attname <> fk.col
|
||||
) AS match_pred
|
||||
FROM pg_index i
|
||||
WHERE i.indrelid = fk.tbl::regclass
|
||||
AND (i.indisunique OR i.indisprimary)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_attribute att
|
||||
WHERE att.attrelid = i.indrelid
|
||||
AND att.attnum = ANY (i.indkey)
|
||||
AND att.attname = fk.col
|
||||
)
|
||||
LOOP
|
||||
IF uq.match_pred IS NOT NULL AND length(uq.match_pred) > 0 THEN
|
||||
EXECUTE format(
|
||||
'DELETE FROM %1$s d
|
||||
USING _tp_dedupe m
|
||||
WHERE d.%2$I = m.dup_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM %1$s s
|
||||
WHERE s.%2$I = m.survivor_id
|
||||
AND %3$s
|
||||
)',
|
||||
fk.tbl, fk.col, uq.match_pred
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- Repoint the remaining references onto the survivor.
|
||||
EXECUTE format(
|
||||
'UPDATE %1$s d
|
||||
SET %2$I = m.survivor_id
|
||||
FROM _tp_dedupe m
|
||||
WHERE d.%2$I = m.dup_id',
|
||||
fk.tbl, fk.col
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- Drop the now-unreferenced duplicate rows.
|
||||
DELETE FROM third_parties t
|
||||
USING _tp_dedupe m
|
||||
WHERE t.id = m.dup_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE UNIQUE INDEX third_parties_org_common_key
|
||||
ON third_parties (organization_id, common_third_party_id)
|
||||
WHERE common_third_party_id IS NOT NULL;
|
||||
Reference in New Issue
Block a user