8740ff4ee3
The security-audit branch added it as revision 009 while main had already taken 009 (day_assertion) and 010 (drop pinned/exclusion), leaving Alembic with two heads and every migration test failing after the merge. It now revises 010. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
"""Lowercase existing "user".email values.
|
|
|
|
Email lookup/storage is case-sensitive (`SQLiteUserRepository.get_by_email`
|
|
runs a bare `WHERE email = ?`), so `Foo@x.se` and `foo@x.se` used to register
|
|
as two distinct accounts. The API boundary now lowercases incoming email on
|
|
every register/login request (`app.models.auth.RegisterRequest`/
|
|
`LoginRequest`, `normalize_email` field validators), so all NEW rows are
|
|
already lowercase; this migration normalizes rows written before that fix.
|
|
|
|
If two existing rows would collapse to the same lowercased address (a
|
|
genuine pre-existing duplicate-account situation), blindly lowercasing both
|
|
would hit the `email` column's UNIQUE constraint and abort with an opaque
|
|
integrity error on whichever row happens to update second. Instead,
|
|
`upgrade()` scans for any such collision FIRST and raises a clear,
|
|
actionable `RuntimeError` naming the colliding rows before writing anything,
|
|
so an operator can merge or rename one of each colliding pair by hand and
|
|
re-run the migration, rather than facing a half-applied state.
|
|
|
|
`downgrade()` cannot restore the original casing (it was never recorded) —
|
|
it is a deliberate, documented no-op. Lowercasing is a one-way normalization.
|
|
|
|
Revision ID: 011
|
|
Revises: 010
|
|
Create Date: 2026-09-20
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "011"
|
|
down_revision: str | None = "010"
|
|
branch_labels: str | None = None
|
|
depends_on: str | None = None
|
|
|
|
|
|
def _collect_rows(conn: Any) -> list[Any]:
|
|
from sqlalchemy import text
|
|
|
|
return conn.execute(text('SELECT id, email FROM "user"')).fetchall()
|
|
|
|
|
|
def upgrade() -> None:
|
|
from sqlalchemy import text
|
|
|
|
conn = op.get_bind()
|
|
rows = _collect_rows(conn)
|
|
|
|
by_lower: dict[str, list[tuple[str, str]]] = {}
|
|
for row in rows:
|
|
by_lower.setdefault(row.email.lower(), []).append((str(row.id), row.email))
|
|
|
|
collisions = {lower: entries for lower, entries in by_lower.items() if len(entries) > 1}
|
|
if collisions:
|
|
detail = "; ".join(
|
|
f"{lower!r} <- {[f'{id_} ({email})' for id_, email in entries]}"
|
|
for lower, entries in collisions.items()
|
|
)
|
|
raise RuntimeError(
|
|
'Cannot lowercase "user".email: the following addresses would collide '
|
|
f"under case-insensitive comparison and must be resolved by hand first "
|
|
f"(merge or rename one account in each pair, then re-run this migration): "
|
|
f"{detail}"
|
|
)
|
|
|
|
for lower, entries in by_lower.items():
|
|
(id_, email) = entries[0]
|
|
if email != lower:
|
|
conn.execute(
|
|
text('UPDATE "user" SET email = :email WHERE id = :id'),
|
|
{"email": lower, "id": id_},
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
"""No-op: the original casing was never recorded, so it cannot be restored."""
|