fix(migration): drop tag_name_key up front; merge Phase 1 collisions

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 17:35:05 -04:00
parent e2df0aa675
commit fc70d56afb
@@ -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)}")