Files
saas_backend/alembic/versions/c9e2a4b6d407_case_insensitive_email.py
T
2026-08-31 20:39:41 -04:00

79 lines
2.6 KiB
Python

"""One account per address, however it was typed.
`users.email` had a plain unique index, so `Alice@example.com` and
`alice@example.com` were two accounts. Nobody types their address the same way
twice — a signup gets one form, a password reset gets another, and the second
finds nothing.
This normalises what is stored and enforces uniqueness on the normalised form.
**It refuses rather than failing.** If two accounts already differ only by case,
creating the index would abort with a constraint violation naming an index and
nothing else. Instead the collisions are found first and reported by address, so
the person running it knows exactly what to merge. Nothing is changed in that
case — the migration is a no-op until the data is fixed.
Uniqueness stays **global**, not per workspace. That is the existing behaviour
and a deliberate constraint on multi-workspace customers: one address, one
account. (The base application scopes it per tenant; this does not, and changing
that is a product decision rather than a migration.)
Revision ID: c9e2a4b6d407
Revises: b8d1f4a6c306
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c9e2a4b6d407"
down_revision: Union[str, Sequence[str], None] = "b8d1f4a6c306"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
INDEX_NAME = "uq_users_email_lower"
def upgrade() -> None:
connection = op.get_bind()
collisions = connection.execute(
sa.text(
"""
SELECT lower(btrim(email)) AS address,
count(*) AS n,
string_agg(email, ', ' ORDER BY email) AS variants
FROM users
GROUP BY lower(btrim(email))
HAVING count(*) > 1
ORDER BY 1
"""
)
).fetchall()
if collisions:
detail = "\n".join(
f" {row.address}: {row.variants} ({row.n} accounts)" for row in collisions
)
raise RuntimeError(
"Cannot enforce case-insensitive email uniqueness — these addresses "
"already exist more than once, differing only by case:\n"
f"{detail}\n"
"Merge or remove the duplicates, then run this migration again. "
"Nothing has been changed."
)
connection.execute(
sa.text(
"UPDATE users SET email = lower(btrim(email)) "
"WHERE email <> lower(btrim(email))"
)
)
op.create_index(INDEX_NAME, "users", [sa.text("lower(email)")], unique=True)
def downgrade() -> None:
op.drop_index(INDEX_NAME, table_name="users")