Skip to content

Commit f26a6d6

Browse files
committed
feat(db): add auto-migration to create missing columns on existing tables
SQLModel's create_all only creates new tables but does not alter existing ones to add new columns. This introduces a _auto_migrate_sync helper that inspects the live schema and issues ALTER TABLE … ADD COLUMN for any column present in the model but absent from the database. Only column additions are supported (safe, non-destructive). Column renames, type changes, and removals are intentionally not handled.
1 parent cc0d526 commit f26a6d6

1 file changed

Lines changed: 51 additions & 0 deletions

File tree

pkgs/bay/app/db/session.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from collections.abc import AsyncGenerator
66
from contextlib import asynccontextmanager
77

8+
import structlog
9+
from sqlalchemy import inspect, text
810
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
911
from sqlalchemy.orm import sessionmaker
1012
from sqlmodel import SQLModel
@@ -14,6 +16,8 @@
1416
import app.models # noqa: F401
1517
from app.config import get_settings
1618

19+
logger = structlog.get_logger()
20+
1721
# Lazy initialization - engine created on first use
1822
_engine = None
1923
_async_session_factory = None
@@ -44,6 +48,50 @@ def _get_session_factory():
4448
return _async_session_factory
4549

4650

51+
def _auto_migrate_sync(conn) -> None:
52+
"""Auto-migrate existing tables to add missing columns.
53+
54+
SQLModel's create_all only creates new tables — it does NOT alter
55+
existing tables to add new columns. This helper inspects the live
56+
schema and issues ALTER TABLE … ADD COLUMN for any column present
57+
in the model but absent from the database.
58+
59+
Only supports column *additions* (safe, non-destructive).
60+
Does NOT handle column renames, type changes, or removals.
61+
"""
62+
inspector = inspect(conn)
63+
existing_tables = inspector.get_table_names()
64+
65+
for table in SQLModel.metadata.sorted_tables:
66+
if table.name not in existing_tables:
67+
# Table will be created by create_all — skip
68+
continue
69+
70+
existing_cols = {c["name"] for c in inspector.get_columns(table.name)}
71+
72+
for col in table.columns:
73+
if col.name in existing_cols:
74+
continue
75+
76+
# Build column type DDL string
77+
col_type = col.type.compile(dialect=conn.dialect)
78+
nullable = "NULL" if col.nullable else "NOT NULL"
79+
default_clause = ""
80+
if col.server_default is not None:
81+
default_clause = f" DEFAULT {col.server_default.arg}"
82+
elif col.nullable:
83+
default_clause = " DEFAULT NULL"
84+
85+
ddl = f"ALTER TABLE {table.name} ADD COLUMN {col.name} {col_type} {nullable}{default_clause}"
86+
logger.info(
87+
"db.auto_migrate.add_column",
88+
table=table.name,
89+
column=col.name,
90+
ddl=ddl,
91+
)
92+
conn.execute(text(ddl))
93+
94+
4795
async def init_db() -> None:
4896
"""Initialize database tables.
4997
@@ -52,6 +100,9 @@ async def init_db() -> None:
52100
"""
53101
engine = _get_engine()
54102
async with engine.begin() as conn:
103+
# Auto-migrate existing tables first (add missing columns)
104+
await conn.run_sync(_auto_migrate_sync)
105+
# Then create any entirely new tables
55106
await conn.run_sync(SQLModel.metadata.create_all)
56107

57108

0 commit comments

Comments
 (0)