"""canonicalize author names Revision ID: 49a9e85a0ffc Revises: ed41acf21270 Create Date: 2026-08-15 15:59:47.331545 """ import warnings import sqlalchemy as sa from alembic import op from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend from advanced_alchemy.types.encrypted_string import PGCryptoBackend from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher from advanced_alchemy.types.password_hash.passlib import PasslibHasher from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher from sqlalchemy import Text # noqa: F401 __all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"] sa.GUID = GUID sa.DateTimeUTC = DateTimeUTC sa.ORA_JSONB = ORA_JSONB sa.EncryptedString = EncryptedString sa.EncryptedText = EncryptedText sa.StoredObject = StoredObject sa.PasswordHash = PasswordHash sa.Argon2Hasher = Argon2Hasher sa.PasslibHasher = PasslibHasher sa.PwdlibHasher = PwdlibHasher sa.FernetBackend = FernetBackend sa.PGCryptoBackend = PGCryptoBackend # revision identifiers, used by Alembic. revision = '49a9e85a0ffc' down_revision = 'ed41acf21270' branch_labels = None depends_on = None def upgrade() -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) with op.get_context().autocommit_block(): schema_upgrades() data_upgrades() def downgrade() -> None: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) with op.get_context().autocommit_block(): data_downgrades() schema_downgrades() def schema_upgrades() -> None: """schema upgrade migrations go here.""" pass def schema_downgrades() -> None: """schema downgrade migrations go here.""" pass def data_upgrades() -> None: """ Rewrite every author into the canonical form, merging the rows that collide. `Author.name` is only now guaranteed tidy — until this revision extractors wrote whatever the file said, so one person could hold several rows: "Sam Newman" beside "Newman, Sam;" beside "Sam Newman.epub" (the last from a filename whose extension was never stripped). Each showed up as its own author in the sidebar and its own filter, and no amount of fixing the extractors repairs a row already written. Rows that canonicalize onto one name are merged into the lowest id, which keeps whichever row the library has been referring to longest. `book.path` is stored, not derived, so renaming an author moves nothing on disk. """ from chitai.services.matching import format_author_name, normalize_author connection = op.get_bind() authors = connection.execute(sa.text("SELECT id, name FROM authors")).fetchall() groups: dict[str, list[int]] = {} for id, name in sorted(authors): # A name with nothing left of it after tidying is left exactly as it was: # merging those together would invent one author out of several unrelated # broken rows, which is worse than leaving the mess visible. if canonical := format_author_name(name): groups.setdefault(canonical, []).append(id) for canonical, ids in groups.items(): winner, losers = ids[0], ids[1:] for loser in losers: # A book credited to both rows would otherwise breach the # (book_id, author_id) unique constraint the moment the link is repointed. connection.execute( sa.text( "DELETE FROM book_author_links WHERE author_id = :loser AND book_id IN" " (SELECT book_id FROM book_author_links WHERE author_id = :winner)" ), {"loser": loser, "winner": winner}, ) connection.execute( sa.text( "UPDATE book_author_links SET author_id = :winner" " WHERE author_id = :loser" ), {"loser": loser, "winner": winner}, ) connection.execute( sa.text("DELETE FROM authors WHERE id = :loser"), {"loser": loser} ) # Only after the losers are gone, or this collides with the unique index. connection.execute( sa.text( "UPDATE authors SET name = :name, normalized_name = :key WHERE id = :id" ), {"id": winner, "name": canonical, "key": normalize_author(canonical)}, ) def data_downgrades() -> None: """ Nothing to undo. The rows a merge removed are gone, and the spellings it replaced were never recorded anywhere else — there is nothing to restore them from. """