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:
@@ -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)}")
|
||||
|
||||
Reference in New Issue
Block a user