From fc70d56afb6f7b90b685390112050fa59e19fa13 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:35:05 -0400 Subject: [PATCH] fix(migration): drop tag_name_key up front; merge Phase 1 collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original Phase 3 dropped the legacy global UNIQUE(tag.name) at the end, which meant Phase 1's prefix-strip UPDATEs ran with the old constraint still in place. On any DB that had a pre-existing bare-name row colliding with the stripped form of a prefixed row (e.g. 'character:Marcille Donato' stripping to 'Marcille Donato' where another 'Marcille Donato' already existed), the migration blew up with UniqueViolation on tag_name_key. Fix: drop tag_name_key at the very top of upgrade() so both phases operate against a uniqueness-free table. Add explicit collision handling to Phase 1 (mirrors the existing Phase 2 auto-merge): when the stripped name already matches an existing row under the shape of the incoming partial-index (same kind, or same kind+fandom_id for characters), reassign image_tags onto the winner, cascade-rename tag_reference_embedding, and delete the loser. Report now shows phase1_merged alongside phase1_renames. Backwards-compatible for DBs where the failing migration already rolled back — rerunning picks up the fix. The migration's transaction atomicity means the DB is still in pre-migration state after the earlier failure. Co-Authored-By: Claude Opus 4.7 --- ...e_tag_names_and_fandom_id_authoritative.py | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py b/migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py index 025d141..7e77363 100644 --- a/migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py +++ b/migrations/versions/j26042101_bare_tag_names_and_fandom_id_authoritative.py @@ -112,15 +112,23 @@ def _rename_tag_reference_embedding(conn, old_name: str, new_name: str) -> None: def upgrade(): conn = op.get_bind() + # Drop the old global UNIQUE(tag.name) up front so the per-row renames in + # Phase 1 / Phase 2 don't collide with the legacy constraint. The new + # partial unique indices aren't created until Phase 3, so the table is + # free of name-uniqueness during the data shuffle. Merge collisions are + # handled inline by the phase logic. + op.drop_constraint('tag_name_key', 'tag', type_='unique') + # ------------------------------------------------------------------------- # Phase 1 — strip kind: prefix from every non-user tag's name # ------------------------------------------------------------------------- phase1_renames = 0 + phase1_merged = 0 rows = conn.execute( - text("SELECT id, name, kind FROM tag ORDER BY id") + text("SELECT id, name, kind, fandom_id FROM tag ORDER BY id") ).fetchall() - for tag_id, old_name, kind in rows: + for tag_id, old_name, kind, fandom_id in rows: new_name, stripped_prefix = _strip_prefix(old_name) if new_name == old_name: continue @@ -131,6 +139,56 @@ def upgrade(): f"phase1 warning: tag {tag_id} name prefix={stripped_prefix!r} " f"but kind column={kind!r}; trusting kind" ) + + # Collision check against the target shape of the Phase 3 partial + # indices. If a matching row exists already, the prefixed row is a + # legacy duplicate — merge (image_tags reassign + tag_ref_embedding + # cascade + delete) and continue. + if kind == 'character': + if fandom_id is None: + collision = conn.execute( + text(""" + SELECT id FROM tag + WHERE kind = 'character' + AND name = :new_name + AND fandom_id IS NULL + AND id != :tag_id + """), + {'new_name': new_name, 'tag_id': tag_id}, + ).fetchone() + else: + collision = conn.execute( + text(""" + SELECT id FROM tag + WHERE kind = 'character' + AND name = :new_name + AND fandom_id = :fid + AND id != :tag_id + """), + {'new_name': new_name, 'fid': fandom_id, 'tag_id': tag_id}, + ).fetchone() + else: + collision = conn.execute( + text(""" + SELECT id FROM tag + WHERE kind = :kind + AND name = :new_name + AND id != :tag_id + """), + {'kind': kind, 'new_name': new_name, 'tag_id': tag_id}, + ).fetchone() + + if collision: + winner_id = collision[0] + _reassign_image_tags(conn, from_id=tag_id, to_id=winner_id) + _rename_tag_reference_embedding(conn, old_name=old_name, new_name=new_name) + conn.execute( + text("DELETE FROM tag WHERE id = :id"), + {'id': tag_id}, + ) + phase1_merged += 1 + continue + conn.execute( text("UPDATE tag SET name = :new_name WHERE id = :id"), {'new_name': new_name, 'id': tag_id}, @@ -225,15 +283,8 @@ def upgrade(): phase2_extracted += 1 # ------------------------------------------------------------------------- - # Phase 3 — drop old unique, create partial indices + # Phase 3 — create partial indices (old tag_name_key was dropped up front) # ------------------------------------------------------------------------- - # The unique index on tag.name was created as part of the initial table - # definition; its name in Postgres is typically tag_name_key (SQLAlchemy's - # default for UniqueConstraint on column tag.name). If your DB has a - # different name, adjust here. The drop + create is atomic with the rest - # of the migration. - op.drop_constraint('tag_name_key', 'tag', type_='unique') - op.create_index( 'tag_character_with_fandom_uniq', 'tag', ['name', 'fandom_id'], unique=True, @@ -256,6 +307,7 @@ def upgrade(): # ------------------------------------------------------------------------- print("=" * 72) print(f"j26042101: Phase 1 prefix-strips: {phase1_renames}") + print(f"j26042101: Phase 1 auto-merged duplicates: {phase1_merged}") print(f"j26042101: Phase 2 character extractions: {phase2_extracted}") print(f"j26042101: Phase 2 auto-merged duplicates: {phase2_merged}") print(f"j26042101: Phase 2 bare-name orphans (review in /tags): {len(phase2_orphans)}")