Skip to content

Commit 40e1fa5

Browse files
committed
feat: alembic migration converting uuid ids to integer
1 parent cc394c0 commit 40e1fa5

1 file changed

Lines changed: 225 additions & 0 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""convert all primary and foreign keys from UUID to integer
2+
3+
Revision ID: c1d2e3f4a5b6
4+
Revises: 8efb2c8c7b20
5+
Create Date: 2026-08-01 00:00:00.000000
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
# revision identifiers, used by Alembic.
14+
revision: str = 'c1d2e3f4a5b6'
15+
down_revision: Union[str, Sequence[str], None] = '8efb2c8c7b20'
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
# Parent tables (own a UUID PK), ordered so parents are converted before
20+
# their children are remapped. FK children are converted right after their
21+
# parent in the same step.
22+
TABLES = [
23+
'tenants',
24+
'users',
25+
'user_profiles',
26+
'user_settings',
27+
'user_security',
28+
'user_contacts',
29+
'user_addresses',
30+
'user_verifications',
31+
'user_sessions',
32+
'todos',
33+
'authorization_resources',
34+
'permissions',
35+
'roles',
36+
'role_permissions',
37+
'user_has_roles',
38+
'casbin_rules',
39+
'audit_logs',
40+
'error_traces',
41+
'login_attempts',
42+
]
43+
44+
# child table -> (fk_column, parent_table)
45+
FK_MAP = {
46+
'users': [('tenant_id', 'tenants')],
47+
'user_profiles': [('user_id', 'users'), ('tenant_id', 'tenants')],
48+
'user_settings': [('user_id', 'users'), ('tenant_id', 'tenants')],
49+
'user_security': [('user_id', 'users'), ('tenant_id', 'tenants')],
50+
'user_contacts': [('user_id', 'users'), ('tenant_id', 'tenants')],
51+
'user_addresses': [('user_id', 'users'), ('tenant_id', 'tenants')],
52+
'user_verifications': [('user_id', 'users'), ('tenant_id', 'tenants')],
53+
'user_sessions': [('user_id', 'users'), ('tenant_id', 'tenants')],
54+
'todos': [('user_id', 'users'), ('tenant_id', 'tenants')],
55+
'authorization_resources': [('tenant_id', 'tenants')],
56+
'permissions': [('resource_id', 'authorization_resources'), ('tenant_id', 'tenants')],
57+
'roles': [('tenant_id', 'tenants')],
58+
'role_permissions': [('role_id', 'roles'), ('permission_id', 'permissions'), ('tenant_id', 'tenants')],
59+
'user_has_roles': [('user_id', 'users'), ('role_id', 'roles'), ('tenant_id', 'tenants')],
60+
'casbin_rules': [('tenant_id', 'tenants')],
61+
'audit_logs': [('tenant_id', 'tenants')],
62+
'error_traces': [('tenant_id', 'tenants')],
63+
'login_attempts': [('tenant_id', 'tenants')],
64+
}
65+
66+
# Shadow table that records the old uuid -> new integer mapping so foreign
67+
# keys can be rewritten and the downgrade can restore the exact original
68+
# uuids (see the "Data-preserving downgrade" note in the task brief).
69+
LEGACY_IDS = '_legacy_ids'
70+
71+
72+
def _columns(bind) -> dict[str, list[str]]:
73+
inspector = sa.inspect(bind)
74+
return {
75+
table: [col["name"] for col in inspector.get_columns(table)]
76+
for table in inspector.get_table_names()
77+
}
78+
79+
80+
def _fk_constraint_name(bind, table: str, column: str) -> str | None:
81+
inspector = sa.inspect(bind)
82+
for fk in inspector.get_foreign_keys(table):
83+
if column in fk["constrained_columns"]:
84+
return fk["name"]
85+
return None
86+
87+
88+
def _convert_pk(bind, table: str) -> None:
89+
"""Add _id integer identity column, backfill via row_number over the
90+
old uuid PK, record the uuid -> int mapping, drop the uuid PK, rename."""
91+
# skip if already integer
92+
cols = sa.inspect(bind).get_columns(table)
93+
id_type = next(c["type"] for c in cols if c["name"] == "id")
94+
if "UUID" not in str(id_type):
95+
return
96+
97+
op.execute(sa.text(f'ALTER TABLE {table} ADD COLUMN _id INTEGER GENERATED BY DEFAULT AS IDENTITY'))
98+
op.execute(sa.text(
99+
f'UPDATE {table} SET _id = sub.rn FROM '
100+
f'(SELECT id, row_number() OVER (ORDER BY id) AS rn FROM {table}) AS sub '
101+
f'WHERE {table}.id = sub.id'
102+
))
103+
# Record the old uuid -> new integer mapping before the uuid is dropped.
104+
op.execute(sa.text(
105+
f'CREATE TABLE IF NOT EXISTS {LEGACY_IDS} '
106+
f'(table_name TEXT, legacy_uuid UUID, new_id INTEGER)'
107+
))
108+
op.execute(sa.text(
109+
f'INSERT INTO {LEGACY_IDS} (table_name, legacy_uuid, new_id) '
110+
f'SELECT \'{table}\', id, _id FROM {table}'
111+
))
112+
# CASCADE: incoming foreign keys from child tables depend on the PK
113+
# index; they are recreated by _convert_fk once the column is remapped.
114+
op.execute(sa.text(
115+
f'ALTER TABLE {table} DROP CONSTRAINT {table}_pkey CASCADE'
116+
))
117+
op.execute(sa.text(f'ALTER TABLE {table} DROP COLUMN id'))
118+
op.execute(sa.text(f'ALTER TABLE {table} RENAME COLUMN _id TO id'))
119+
op.execute(sa.text(
120+
f'ALTER TABLE {table} ADD PRIMARY KEY (id)'
121+
))
122+
op.execute(sa.text(
123+
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), (SELECT max(id) FROM {table}))"
124+
))
125+
126+
127+
def _convert_fk(bind, child: str, column: str, parent: str) -> None:
128+
"""Remap a child FK column to the parent's new integer ids."""
129+
op.execute(sa.text(f'ALTER TABLE {child} ADD COLUMN _fk INTEGER'))
130+
op.execute(sa.text(
131+
f'UPDATE {child} SET _fk = l.new_id FROM {LEGACY_IDS} l '
132+
f'WHERE l.table_name = \'{parent}\' AND {child}.{column} = l.legacy_uuid'
133+
))
134+
fk_name = _fk_constraint_name(bind, child, column)
135+
if fk_name:
136+
op.execute(sa.text(f'ALTER TABLE {child} DROP CONSTRAINT {fk_name}'))
137+
op.execute(sa.text(f'ALTER TABLE {child} DROP COLUMN {column}'))
138+
op.execute(sa.text(f'ALTER TABLE {child} RENAME COLUMN _fk TO {column}'))
139+
op.execute(sa.text(
140+
f'ALTER TABLE {child} ADD CONSTRAINT {child}_{column}_fkey '
141+
f'FOREIGN KEY ({column}) REFERENCES {parent} (id)'
142+
))
143+
144+
145+
def upgrade() -> None:
146+
bind = op.get_bind()
147+
tables = _columns(bind)
148+
149+
# tenants is converted first because every table references it
150+
if 'tenants' in tables:
151+
_convert_pk(bind, 'tenants')
152+
153+
for table in TABLES:
154+
if table == 'tenants' or table not in tables:
155+
continue
156+
for fk_column, parent in FK_MAP.get(table, []):
157+
if parent in tables:
158+
_convert_fk(bind, table, fk_column, parent)
159+
_convert_pk(bind, table)
160+
161+
# api_keys may exist in dev databases even though it has no migration
162+
if 'api_keys' in tables:
163+
_convert_pk(bind, 'api_keys')
164+
_convert_fk(bind, 'api_keys', 'tenant_id', 'tenants')
165+
166+
167+
def _restore_pk(bind, table: str) -> None:
168+
"""Downgrade: recreate the uuid PK, restoring the exact legacy uuids."""
169+
op.execute(sa.text(f'ALTER TABLE {table} ADD COLUMN _old_id UUID DEFAULT gen_random_uuid()'))
170+
op.execute(sa.text(
171+
f'UPDATE {table} SET _old_id = l.legacy_uuid FROM {LEGACY_IDS} l '
172+
f'WHERE l.table_name = \'{table}\' AND l.new_id = {table}.id'
173+
))
174+
# CASCADE: incoming foreign keys from child tables depend on the PK
175+
# index; they are recreated by _restore_fk once the parent is restored.
176+
op.execute(sa.text(f'ALTER TABLE {table} DROP CONSTRAINT {table}_pkey CASCADE'))
177+
op.execute(sa.text(f'ALTER TABLE {table} DROP COLUMN id'))
178+
op.execute(sa.text(f'ALTER TABLE {table} RENAME COLUMN _old_id TO id'))
179+
op.execute(sa.text(f'ALTER TABLE {table} ADD PRIMARY KEY (id)'))
180+
181+
182+
def _restore_fk(bind, child: str, column: str, parent: str) -> None:
183+
op.execute(sa.text(f'ALTER TABLE {child} ADD COLUMN _fk UUID DEFAULT gen_random_uuid()'))
184+
op.execute(sa.text(
185+
f'UPDATE {child} SET _fk = l.legacy_uuid FROM {LEGACY_IDS} l '
186+
f'WHERE l.table_name = \'{parent}\' AND l.new_id = {child}.{column}'
187+
))
188+
op.execute(sa.text(f'ALTER TABLE {child} DROP COLUMN {column}'))
189+
op.execute(sa.text(f'ALTER TABLE {child} RENAME COLUMN _fk TO {column}'))
190+
op.execute(sa.text(
191+
f'ALTER TABLE {child} ADD CONSTRAINT {child}_{column}_fkey '
192+
f'FOREIGN KEY ({column}) REFERENCES {parent} (id)'
193+
))
194+
195+
196+
def downgrade() -> None:
197+
bind = op.get_bind()
198+
tables = _columns(bind)
199+
200+
# Pass 1: restore every PK to uuid. All PKs must be restored before any
201+
# FK is re-added, because a foreign key cannot reference an integer id
202+
# with a uuid column (or vice versa).
203+
for table in reversed(TABLES):
204+
if table == 'tenants' or table not in tables:
205+
continue
206+
_restore_pk(bind, table)
207+
208+
if 'tenants' in tables:
209+
_restore_pk(bind, 'tenants')
210+
211+
if 'api_keys' in tables:
212+
_restore_pk(bind, 'api_keys')
213+
214+
# Pass 2: remap every FK back to the restored uuid ids.
215+
for table in reversed(TABLES):
216+
if table == 'tenants' or table not in tables:
217+
continue
218+
for fk_column, parent in FK_MAP.get(table, []):
219+
if parent in tables:
220+
_restore_fk(bind, table, fk_column, parent)
221+
222+
if 'api_keys' in tables:
223+
_restore_fk(bind, 'api_keys', 'tenant_id', 'tenants')
224+
225+
op.execute(sa.text(f'DROP TABLE IF EXISTS {LEGACY_IDS}'))

0 commit comments

Comments
 (0)