feat: canonicalise author names

Extractors wrote whatever the file said, so one person held several rows:
"Sam Newman" beside "Newman, Sam;" beside "Sam Newman.epub", the last because
the upload path never stripped the file extension. Tidy on write, in the
validator and in the uniqueness lookup alike, and merge the rows that collide.
This commit is contained in:
2026-08-15 21:56:35 -04:00
parent 373c96d6d6
commit 8ee406533c
6 changed files with 328 additions and 9 deletions
@@ -0,0 +1,134 @@
"""canonicalize author names
Revision ID: 49a9e85a0ffc
Revises: ed41acf21270
Create Date: 2026-08-15 15:59:47.331545
"""
import warnings
from typing import TYPE_CHECKING
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
if TYPE_CHECKING:
from collections.abc import Sequence
__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.
"""