mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-27 07:54:19 +00:00
feat: integrate Alembic for database migrations
Replace manual if-sqlite/if-postgres branching with Alembic: - Add alembic dependency - Create programmatic alembic env (no CLI/alembic.ini needed) - Support async engines via run_sync passthrough - render_as_batch=True for SQLite ALTER TABLE compatibility - Auto-stamp baseline on first run (existing DB at version 25) - Run alembic upgrade head after legacy migrations - Include sample migration showing schema + data migration patterns - Add alembic dir to package-data for distribution
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"""Alembic environment for LangBot.
|
||||
|
||||
This env.py is designed to be called programmatically (not via CLI).
|
||||
It supports both SQLite and PostgreSQL.
|
||||
|
||||
The sync connection is passed via config attributes by the runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
|
||||
url = context.config.get_main_option('sqlalchemy.url')
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={'paramstyle': 'named'},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations with a live sync connection passed via config attributes."""
|
||||
connection: Connection = context.config.attributes.get('connection')
|
||||
if connection is None:
|
||||
raise RuntimeError('connection not provided in alembic config attributes')
|
||||
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
# render_as_batch=True is critical for SQLite ALTER TABLE support
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
# Alembic script.py.mako — template for auto-generated revisions
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""baseline: stamp existing schema (db version 25)
|
||||
|
||||
This is a no-op migration that marks the starting point for Alembic.
|
||||
All tables already exist via create_all() + legacy DBMigration system.
|
||||
|
||||
Revision ID: 0001_baseline
|
||||
Revises: None
|
||||
Create Date: 2026-04-08
|
||||
"""
|
||||
|
||||
revision = '0001_baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# No-op: existing schema is already at database_version=25
|
||||
# This revision serves as the Alembic baseline.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,62 @@
|
||||
"""example: sample migration demonstrating Alembic patterns
|
||||
|
||||
This is a SAMPLE showing how to write migrations that work
|
||||
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
|
||||
|
||||
Revision ID: 0002_sample
|
||||
Revises: 0001_baseline
|
||||
Create Date: 2026-04-08
|
||||
|
||||
Patterns demonstrated:
|
||||
1. Schema change (add column) — works on both DBs via render_as_batch
|
||||
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
|
||||
"""
|
||||
|
||||
revision = '0002_sample'
|
||||
down_revision = '0001_baseline'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""
|
||||
EXAMPLE: Uncomment to use. This shows the patterns.
|
||||
|
||||
# --- Pattern 1: Schema change (add/drop column) ---
|
||||
# render_as_batch=True in env.py makes this work on SQLite too.
|
||||
#
|
||||
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
|
||||
|
||||
# --- Pattern 2: Data migration (read + modify JSON field) ---
|
||||
# No if/else for sqlite vs postgres needed!
|
||||
#
|
||||
# conn = op.get_bind()
|
||||
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
|
||||
# for row in rows:
|
||||
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
|
||||
# # Modify the config
|
||||
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
|
||||
# conn.execute(
|
||||
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
|
||||
# {"cfg": json.dumps(config), "uuid": row[0]}
|
||||
# )
|
||||
|
||||
# --- Pattern 3: Create a new table ---
|
||||
#
|
||||
# op.create_table(
|
||||
# 'audit_log',
|
||||
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
# sa.Column('action', sa.String(255), nullable=False),
|
||||
# sa.Column('detail', sa.Text),
|
||||
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
|
||||
# )
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""
|
||||
# op.drop_column('pipelines', 'description')
|
||||
# op.drop_table('audit_log')
|
||||
"""
|
||||
pass
|
||||
Reference in New Issue
Block a user