"""oauth_account table; user.discord_only -> user.oauth_only Generalises "sign in with Discord" to any provider (see accounts/oauth.py). Three steps, each conditional so the revision is safe on a database created before or after the ORM gained these definitions — revision 0001 builds the schema with ``Base.metadata.create_all`` off *live* model metadata, so a freshly created database already has both the table and the renamed column, and an unconditional DDL would fail there with duplicate-object errors. The backfill is the load-bearing step. Discord identities used to live in ``user.discord_id``, and account resolution now keys on ``oauth_account``; without copying the existing rows across, every already-linked user would silently stop being recognised at login and would get a *second* account created on their next sign-in. ``user.discord_id`` is deliberately left in place — it is the DM delivery address notify.py reads, not merely an identity. Revision ID: 0005_oauth_accounts Revises: 0004_bookmarks Create Date: 2026-07-26 """ import time import sqlalchemy as sa from alembic import op # The same UUID type the ORM uses for user.id — a plain sa.Uuid renders differently # on SQLite and would not match the column create_all produces. from fastapi_users_db_sqlalchemy.generics import GUID revision = "0005_oauth_accounts" down_revision = "0004_bookmarks" branch_labels = None depends_on = None def _columns(table: str) -> set[str]: return {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)} def _tables() -> set[str]: return set(sa.inspect(op.get_bind()).get_table_names()) def upgrade() -> None: cols = _columns("user") # 1. Rename the flag. It no longer means "Discord created this account", it # means "no password its owner has ever seen, whichever provider made it". if "oauth_only" in cols: # A database built by 0001 already has oauth_only (create_all reads *current* # metadata), and 0003 then re-added an empty discord_only beside it, because # 0003 tests for the name it knew. Drop that vestige, or a fresh database # carries a column an upgraded one does not — schema drift that would only # surface much later. Safe: on this path the column was created empty # moments ago and nothing has ever read it. if "discord_only" in cols: op.drop_column("user", "discord_only") elif "discord_only" in cols: # The real upgrade path: a deployed database carrying live values. Renamed, # never dropped, so nothing is lost. op.alter_column("user", "discord_only", new_column_name="oauth_only") else: # Database predates even discord_only (never deployed with it). op.add_column("user", sa.Column("oauth_only", sa.Boolean(), nullable=False, server_default=sa.false())) # 2. The identity table. if "oauth_account" not in _tables(): op.create_table( "oauth_account", sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), sa.Column("user_id", GUID(), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False), sa.Column("provider", sa.String(length=32), nullable=False), sa.Column("subject", sa.String(length=64), nullable=False), sa.Column("email", sa.String(length=320), nullable=True), sa.Column("created_at", sa.Float(), nullable=False), sa.UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"), sa.UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"), ) op.create_index("idx_oauth_user", "oauth_account", ["user_id"]) # 3. Backfill every existing Discord link. Guarded by NOT EXISTS so re-running # (or running after create_all already produced the table) cannot violate # either unique constraint. op.execute(sa.text(""" INSERT INTO oauth_account (user_id, provider, subject, email, created_at) SELECT u.id, 'discord', u.discord_id, u.email, :now FROM "user" u WHERE u.discord_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM oauth_account o WHERE o.provider = 'discord' AND (o.subject = u.discord_id OR o.user_id = u.id) ) """).bindparams(now=time.time())) def downgrade() -> None: if "oauth_account" in _tables(): op.drop_index("idx_oauth_user", table_name="oauth_account") op.drop_table("oauth_account") cols = _columns("user") if "oauth_only" in cols and "discord_only" not in cols: op.alter_column("user", "oauth_only", new_column_name="discord_only")