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.
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""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.
|
|
"""
|