1919from sqlalchemy import MetaData
2020from sqlalchemy import and_
2121from sqlalchemy import delete as sql_delete
22+ from sqlalchemy import Dialect
23+ from sqlalchemy .sql .compiler import IdentifierPreparer
2224from sqlalchemy import event
2325from sqlalchemy import select
26+ from sqlalchemy import text
2427from sqlalchemy .engine import Engine
2528from sqlalchemy .engine import create_engine
2629from sqlalchemy .engine .interfaces import DBAPICursor
30+ from sqlalchemy .engine import Connection
31+ from sqlalchemy .engine .reflection import Inspector
2732from sqlalchemy .exc import ArgumentError
2833from sqlalchemy .ext .asyncio import AsyncEngine
2934from sqlalchemy .ext .asyncio import AsyncSession
@@ -122,6 +127,87 @@ def __init__(self, is_async: bool, db_url: str, metadata: Optional[MetaData] = N
122127 self .__db_url = db_url
123128 self .__kwargs = kwargs
124129
130+ def _migrate_missing_columns (self , connection : Connection ) -> None :
131+ """Add columns that exist in the ORM model but are missing from the database,
132+ for forward compatibility across version changes.
133+
134+ SQLAlchemy's create_all only creates tables — it never ALTERs existing
135+ tables. This helper bridges the gap for lightweight forward-only migrations.
136+ Only handles adding new columns (forward-only).
137+
138+ All-or-nothing semantics: on databases that support transactional DDL
139+ (e.g. PostgreSQL) the caller's transaction handles rollback. On databases
140+ where DDL auto-commits (e.g. MySQL), a compensating DROP COLUMN is issued
141+ for every column that was already added before the failure.
142+
143+ Args:
144+ connection: A synchronous SQLAlchemy Connection object.
145+ """
146+ insp : Inspector = inspect (connection )
147+ dialect : Dialect = connection .dialect
148+ preparer : IdentifierPreparer = dialect .identifier_preparer
149+ ddl_compiler = dialect .ddl_compiler (dialect , None )
150+
151+ pending_add_columns : list [tuple [str , str , str ]] = []
152+ for table_name , table in self .__metadata .tables .items ():
153+ if not insp .has_table (table_name ):
154+ continue
155+ existing : set [str ] = {col ["name" ] for col in insp .get_columns (table_name )}
156+ for column in table .columns :
157+ if column .name in existing :
158+ continue
159+ col_type : str = column .type .compile (dialect = dialect )
160+ # handle different types of default value
161+ nullable : str = "" if column .nullable else " NOT NULL"
162+ default : str = ""
163+ default_value = ddl_compiler .get_column_default_string (column )
164+ if default_value is not None :
165+ default = f" DEFAULT { default_value } "
166+ elif column .server_default is not None :
167+ # if the column has server_default, but it is not a DDL server_default, warning
168+ logger .warning (
169+ "Column '%s' on table '%s' has a non-DDL server_default "
170+ "(%s); skipping DEFAULT clause generation." ,
171+ column .name , table_name , type (column .server_default ).__name__ ,
172+ )
173+ elif not column .nullable :
174+ # if the column is NOT NULL and has no server_default, raise error
175+ logger .warning (
176+ "Column '%s' on table '%s' is NOT NULL without a server_default; "
177+ "migration may fail if the table already contains rows." ,
178+ column .name , table_name ,
179+ )
180+ quoted_table : str = preparer .quote_identifier (table_name )
181+ quoted_col : str = preparer .quote_identifier (column .name )
182+ stmt : str = f"ALTER TABLE { quoted_table } ADD COLUMN { quoted_col } { col_type } { default } { nullable } "
183+ pending_add_columns .append ((stmt , column .name , table_name ))
184+
185+ if not pending_add_columns :
186+ return
187+
188+ added_columns : list [tuple [str , str ]] = []
189+ try :
190+ for stmt , col_name , table_name in pending_add_columns :
191+ connection .execute (text (stmt ))
192+ added_columns .append ((col_name , table_name ))
193+ logger .info ("Auto-migrated: added column '%s' to table '%s'" , col_name , table_name )
194+ except Exception :
195+ logger .error ("Migration failed, compensating %d already-added column(s)." , len (added_columns ))
196+ for col_name , tbl_name in reversed (added_columns ):
197+ drop_stmt = (
198+ f"ALTER TABLE { preparer .quote_identifier (tbl_name )} "
199+ f"DROP COLUMN { preparer .quote_identifier (col_name )} "
200+ )
201+ try :
202+ connection .execute (text (drop_stmt ))
203+ logger .info ("Compensated: dropped column '%s' from table '%s'" , col_name , tbl_name )
204+ except Exception :
205+ logger .error (
206+ "Failed to compensate column '%s' on table '%s'; manual cleanup required." ,
207+ col_name , tbl_name ,
208+ )
209+ raise
210+
125211 async def create_sql_engine (self ):
126212 """Create the database engine."""
127213 if self ._db_engine :
@@ -137,16 +223,19 @@ async def _async_inspect():
137223 self .inspector = await _async_inspect ()
138224 async with db_engine .begin () as conn :
139225 await conn .run_sync (self .__metadata .create_all )
226+ await conn .run_sync (self ._migrate_missing_columns )
140227 self ._database_session_factory = async_sessionmaker (bind = db_engine )
141228 else :
142229 db_engine : SqlEngine = create_engine (self .__db_url , ** self .__kwargs )
143230 self .inspector = inspect (db_engine )
144231 self .__metadata .create_all (db_engine )
232+ with db_engine .begin () as conn :
233+ self ._migrate_missing_columns (conn )
145234 self ._database_session_factory = sessionmaker (bind = db_engine )
146235
147236 if db_engine .dialect .name == "sqlite" :
148- # Set sqlite pragma to enable foreign keys constraints
149- event .listen (db_engine , "connect" , _set_sqlite_pragma )
237+ listen_target = db_engine . sync_engine if isinstance ( db_engine , AsyncEngine ) else db_engine
238+ event .listen (listen_target , "connect" , _set_sqlite_pragma )
150239
151240 except Exception as ex : # pylint: disable=broad-except
152241 if isinstance (ex , ArgumentError ):
0 commit comments