Skip to content

Commit 64d2da2

Browse files
authored
Merge pull request #579 from chetanr25/alembic_setup
Alembic setup
2 parents 42b141f + 15d69e4 commit 64d2da2

9 files changed

Lines changed: 286 additions & 8 deletions

File tree

.github/workflows/tests.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ jobs:
2727
env:
2828
PYTHONPATH: .
2929
run: |
30-
python -m pytest tests/ -v --tb=short
30+
python -m pytest tests/ -v --tb=short
31+
32+
- name: Check for un-migrated model changes
33+
env:
34+
PYTHONPATH: .
35+
run: |
36+
python -m alembic check

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean
1+
.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean migrate migration
22

33
COMPOSE = docker compose -f docker/dev/compose.yml --env-file docker/.env.dev
44
ENV_DEV = docker/.env.dev
@@ -29,6 +29,8 @@ help:
2929
@echo "make shell - Open shell in running app container"
3030
@echo "make pull-model - Pull Ollama model from .env.dev ($(OLLAMA_MODEL))"
3131
@echo "make test - Run test suite"
32+
@echo "make migrate - Run pending Alembic migrations"
33+
@echo "make migration - Generate new migration (msg='description')"
3234
@echo "make clean - Stop containers (preserves volumes)"
3335
@echo "make super-clean - [CAUTION] Stop containers, delete volumes, prune Docker"
3436

@@ -93,6 +95,12 @@ pull-model:
9395
test:
9496
@$(COMPOSE) exec -T app python3 -m pytest tests/ -v
9597

98+
migrate:
99+
@$(COMPOSE) exec -T app alembic upgrade head
100+
101+
migration:
102+
@$(COMPOSE) exec -T app alembic revision --autogenerate -m "$(msg)"
103+
96104
clean:
97105
@$(COMPOSE) down
98106

alembic.ini

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[alembic]
2+
script_location = alembic
3+
prepend_sys_path = .
4+
path_separator = os
5+
6+
[loggers]
7+
keys = root,sqlalchemy,alembic
8+
9+
[handlers]
10+
keys = console
11+
12+
[formatters]
13+
keys = generic
14+
15+
[logger_root]
16+
level = WARN
17+
handlers = console
18+
19+
[logger_sqlalchemy]
20+
level = WARN
21+
handlers =
22+
qualname = sqlalchemy.engine
23+
24+
[logger_alembic]
25+
level = INFO
26+
handlers =
27+
qualname = alembic
28+
29+
[handler_console]
30+
class = StreamHandler
31+
args = (sys.stderr,)
32+
level = NOTSET
33+
formatter = generic
34+
35+
[formatter_generic]
36+
format = %(levelname)-5.5s [%(name)s] %(message)s
37+
datefmt = %H:%M:%S

alembic/env.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from logging.config import fileConfig
2+
3+
from alembic import context
4+
from sqlalchemy import engine_from_config, pool
5+
6+
from app.core.config import DATABASE_URL
7+
from app.models.models import SQLModel
8+
from app.models import Template, FormSubmission
9+
10+
config = context.config
11+
12+
if not config.get_main_option("sqlalchemy.url"):
13+
config.set_main_option("sqlalchemy.url", DATABASE_URL)
14+
15+
if config.config_file_name is not None:
16+
fileConfig(config.config_file_name)
17+
18+
target_metadata = SQLModel.metadata
19+
20+
21+
def run_migrations_offline():
22+
url = config.get_main_option("sqlalchemy.url")
23+
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
24+
with context.begin_transaction():
25+
context.run_migrations()
26+
27+
28+
def run_migrations_online():
29+
connectable = config.attributes.get("connection", None)
30+
31+
if connectable is None:
32+
connectable = engine_from_config(
33+
config.get_section(config.config_ini_section, {}),
34+
prefix="sqlalchemy.",
35+
poolclass=pool.NullPool,
36+
)
37+
with connectable.connect() as connection:
38+
context.configure(connection=connection, target_metadata=target_metadata)
39+
with context.begin_transaction():
40+
context.run_migrations()
41+
else:
42+
context.configure(connection=connectable, target_metadata=target_metadata)
43+
with context.begin_transaction():
44+
context.run_migrations()
45+
46+
47+
if context.is_offline_mode():
48+
run_migrations_offline()
49+
else:
50+
run_migrations_online()

alembic/script.py.mako

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
${imports if imports else ""}
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = ${repr(up_revision)}
17+
down_revision: Union[str, None] = ${repr(down_revision)}
18+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20+
21+
22+
def upgrade() -> None:
23+
${upgrades if upgrades else "pass"}
24+
25+
26+
def downgrade() -> None:
27+
${downgrades if downgrades else "pass"}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Initial schema — Template and FormSubmission tables.
2+
3+
Revision ID: 001
4+
Revises:
5+
Create Date: 2026-06-22
6+
7+
"""
8+
from collections.abc import Sequence
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
import sqlmodel
13+
14+
revision: str = "001"
15+
down_revision: str | None = None
16+
branch_labels: str | Sequence[str] | None = None
17+
depends_on: str | Sequence[str] | None = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"template",
23+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
24+
sa.Column("name", sqlmodel.sql.sqltypes.AutoString, nullable=False),
25+
sa.Column("fields", sa.JSON, nullable=False),
26+
sa.Column("pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False),
27+
sa.Column(
28+
"created_at",
29+
sa.DateTime,
30+
nullable=False,
31+
server_default=sa.text("now()"),
32+
),
33+
)
34+
35+
op.create_table(
36+
"formsubmission",
37+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
38+
sa.Column("template_id", sa.Integer, sa.ForeignKey("template.id"), nullable=False),
39+
sa.Column("input_text", sqlmodel.sql.sqltypes.AutoString, nullable=False),
40+
sa.Column("output_pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False),
41+
sa.Column(
42+
"created_at",
43+
sa.DateTime,
44+
nullable=False,
45+
server_default=sa.text("now()"),
46+
),
47+
)
48+
49+
50+
def downgrade() -> None:
51+
op.drop_table("formsubmission")
52+
op.drop_table("template")

app/db/init_db.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
11
import datetime
22
import logging
3+
from pathlib import Path
34

4-
from sqlmodel import Session, SQLModel, select
5+
from alembic import command
6+
from alembic.config import Config
7+
from sqlmodel import Session, select
58

69
from app.core.config import DEFAULT_TEMPLATE_DIR
710
from app.db.database import engine
8-
911
from app.models import FormSubmission, Template # noqa: F401
1012

1113
logger = logging.getLogger(__name__)
1214

15+
ALEMBIC_INI = str(Path(__file__).resolve().parents[2] / "alembic.ini")
16+
17+
18+
def _alembic_cfg() -> Config:
19+
cfg = Config(ALEMBIC_INI)
20+
return cfg
21+
22+
23+
def run_migrations():
24+
logger.info("Running Alembic migrations...")
25+
command.upgrade(_alembic_cfg(), "head")
26+
logger.info("Migrations complete.")
27+
1328

1429
def seed_db():
1530
with Session(engine) as session:
16-
# Check if we already have templates
1731
statement = select(Template)
1832
try:
1933
results = session.exec(statement).first()
2034
except Exception:
21-
# Table might not exist yet if called at a weird time
2235
results = None
2336

2437
if not results:
@@ -33,7 +46,6 @@ def seed_db():
3346
"Date": "string",
3447
}
3548

36-
# Using ID 2 as agreed to avoid any ID 1 corruption
3749
default_template = Template(
3850
id=2,
3951
name="Manual Test Template",
@@ -47,7 +59,7 @@ def seed_db():
4759

4860

4961
def init_db():
50-
SQLModel.metadata.create_all(engine)
62+
run_migrations()
5163
seed_db()
5264

5365

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ requests-cache
1919
retry-requests
2020
pandas
2121
geopy
22+
alembic

tests/test_migrations.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for Alembic migration infrastructure.
2+
3+
Runs migrations against an in-memory SQLite database to verify:
4+
- upgrade to head works
5+
- downgrade to base works
6+
- round-trip (up → down → up) works
7+
- schema matches expected columns and foreign keys
8+
"""
9+
10+
import pytest
11+
from alembic import command
12+
from alembic.config import Config
13+
from sqlalchemy import create_engine, inspect
14+
15+
ALEMBIC_INI = "alembic.ini"
16+
17+
18+
@pytest.fixture()
19+
def alembic_engine():
20+
return create_engine("sqlite://")
21+
22+
23+
@pytest.fixture()
24+
def alembic_cfg(alembic_engine):
25+
cfg = Config(ALEMBIC_INI)
26+
cfg.set_main_option("sqlalchemy.url", "sqlite://")
27+
cfg.attributes["connection"] = alembic_engine.connect()
28+
return cfg
29+
30+
31+
def test_upgrade_head(alembic_cfg, alembic_engine):
32+
command.upgrade(alembic_cfg, "head")
33+
34+
inspector = inspect(alembic_engine)
35+
tables = inspector.get_table_names()
36+
assert "template" in tables
37+
assert "formsubmission" in tables
38+
assert "alembic_version" in tables
39+
40+
41+
def test_downgrade_base(alembic_cfg, alembic_engine):
42+
command.upgrade(alembic_cfg, "head")
43+
command.downgrade(alembic_cfg, "base")
44+
45+
inspector = inspect(alembic_engine)
46+
tables = inspector.get_table_names()
47+
assert "template" not in tables
48+
assert "formsubmission" not in tables
49+
50+
51+
def test_round_trip(alembic_cfg, alembic_engine):
52+
command.upgrade(alembic_cfg, "head")
53+
command.downgrade(alembic_cfg, "-1")
54+
command.upgrade(alembic_cfg, "head")
55+
56+
inspector = inspect(alembic_engine)
57+
tables = inspector.get_table_names()
58+
assert "template" in tables
59+
assert "formsubmission" in tables
60+
61+
62+
def test_template_columns(alembic_cfg, alembic_engine):
63+
command.upgrade(alembic_cfg, "head")
64+
65+
inspector = inspect(alembic_engine)
66+
columns = {c["name"] for c in inspector.get_columns("template")}
67+
assert columns == {"id", "name", "fields", "pdf_path", "created_at"}
68+
69+
70+
def test_formsubmission_columns(alembic_cfg, alembic_engine):
71+
command.upgrade(alembic_cfg, "head")
72+
73+
inspector = inspect(alembic_engine)
74+
columns = {c["name"] for c in inspector.get_columns("formsubmission")}
75+
assert columns == {"id", "template_id", "input_text", "output_pdf_path", "created_at"}
76+
77+
78+
def test_formsubmission_fk(alembic_cfg, alembic_engine):
79+
command.upgrade(alembic_cfg, "head")
80+
81+
inspector = inspect(alembic_engine)
82+
fks = inspector.get_foreign_keys("formsubmission")
83+
assert len(fks) == 1
84+
assert fks[0]["referred_table"] == "template"
85+
assert fks[0]["referred_columns"] == ["id"]

0 commit comments

Comments
 (0)