"""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."""