79 lines
2.6 KiB
Python
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")
|