From 0ec204296170ebcd38a34a26cb4b9d10cb16fc6b Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Mon, 22 Jun 2026 23:26:41 +0530 Subject: [PATCH 1/4] feat: Add Alembic migration infrastructure --- alembic.ini | 37 ++++++++++++++++++ alembic/env.py | 50 +++++++++++++++++++++++++ alembic/script.py.mako | 27 +++++++++++++ alembic/versions/001_initial_schema.py | 52 ++++++++++++++++++++++++++ app/db/init_db.py | 24 +++++++++--- requirements.txt | 3 +- 6 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/001_initial_schema.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..2788aaa --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..d503289 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,50 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import DATABASE_URL +from app.models.models import SQLModel +from app.models import Template, FormSubmission + +config = context.config + +if not config.get_main_option("sqlalchemy.url"): + config.set_main_option("sqlalchemy.url", DATABASE_URL) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = config.attributes.get("connection", None) + + if connectable is None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + else: + context.configure(connection=connectable, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_schema.py b/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..e6d9fad --- /dev/null +++ b/alembic/versions/001_initial_schema.py @@ -0,0 +1,52 @@ +"""Initial schema — Template and FormSubmission tables. + +Revision ID: 001 +Revises: +Create Date: 2026-06-22 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "template", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("fields", sa.JSON, nullable=False), + sa.Column("pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.text("now()"), + ), + ) + + op.create_table( + "formsubmission", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("template_id", sa.Integer, sa.ForeignKey("template.id"), nullable=False), + sa.Column("input_text", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column("output_pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.text("now()"), + ), + ) + + +def downgrade() -> None: + op.drop_table("formsubmission") + op.drop_table("template") diff --git a/app/db/init_db.py b/app/db/init_db.py index fc17147..ebfe09b 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -1,24 +1,37 @@ import datetime import logging +from pathlib import Path -from sqlmodel import Session, SQLModel, select +from alembic import command +from alembic.config import Config +from sqlmodel import Session, select from app.core.config import DEFAULT_TEMPLATE_DIR from app.db.database import engine - from app.models import FormSubmission, Template # noqa: F401 logger = logging.getLogger(__name__) +ALEMBIC_INI = str(Path(__file__).resolve().parents[2] / "alembic.ini") + + +def _alembic_cfg() -> Config: + cfg = Config(ALEMBIC_INI) + return cfg + + +def run_migrations(): + logger.info("Running Alembic migrations...") + command.upgrade(_alembic_cfg(), "head") + logger.info("Migrations complete.") + def seed_db(): with Session(engine) as session: - # Check if we already have templates statement = select(Template) try: results = session.exec(statement).first() except Exception: - # Table might not exist yet if called at a weird time results = None if not results: @@ -33,7 +46,6 @@ def seed_db(): "Date": "string", } - # Using ID 2 as agreed to avoid any ID 1 corruption default_template = Template( id=2, name="Manual Test Template", @@ -47,7 +59,7 @@ def seed_db(): def init_db(): - SQLModel.metadata.create_all(engine) + run_migrations() seed_db() diff --git a/requirements.txt b/requirements.txt index 8781c97..25cb0a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ openmeteo-requests requests-cache retry-requests pandas -geopy \ No newline at end of file +geopy +alembic \ No newline at end of file From 512174a841a5787ddbd09c1875f13158e0a3a587 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Mon, 22 Jun 2026 23:27:02 +0530 Subject: [PATCH 2/4] Add migration tests for upgrade, downgrade, and schema validation --- tests/test_migrations.py | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_migrations.py diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..3e92032 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,85 @@ +"""Tests for Alembic migration infrastructure. + +Runs migrations against an in-memory SQLite database to verify: +- upgrade to head works +- downgrade to base works +- round-trip (up → down → up) works +- schema matches expected columns and foreign keys +""" + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect + +ALEMBIC_INI = "alembic.ini" + + +@pytest.fixture() +def alembic_engine(): + return create_engine("sqlite://") + + +@pytest.fixture() +def alembic_cfg(alembic_engine): + cfg = Config(ALEMBIC_INI) + cfg.set_main_option("sqlalchemy.url", "sqlite://") + cfg.attributes["connection"] = alembic_engine.connect() + return cfg + + +def test_upgrade_head(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + tables = inspector.get_table_names() + assert "template" in tables + assert "formsubmission" in tables + assert "alembic_version" in tables + + +def test_downgrade_base(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + command.downgrade(alembic_cfg, "base") + + inspector = inspect(alembic_engine) + tables = inspector.get_table_names() + assert "template" not in tables + assert "formsubmission" not in tables + + +def test_round_trip(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + command.downgrade(alembic_cfg, "-1") + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + tables = inspector.get_table_names() + assert "template" in tables + assert "formsubmission" in tables + + +def test_template_columns(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + columns = {c["name"] for c in inspector.get_columns("template")} + assert columns == {"id", "name", "fields", "pdf_path", "created_at"} + + +def test_formsubmission_columns(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + columns = {c["name"] for c in inspector.get_columns("formsubmission")} + assert columns == {"id", "template_id", "input_text", "output_pdf_path", "created_at"} + + +def test_formsubmission_fk(alembic_cfg, alembic_engine): + command.upgrade(alembic_cfg, "head") + + inspector = inspect(alembic_engine) + fks = inspector.get_foreign_keys("formsubmission") + assert len(fks) == 1 + assert fks[0]["referred_table"] == "template" + assert fks[0]["referred_columns"] == ["id"] From 2ba207d44832a18c24bc74c3a28fce9b67aa0831 Mon Sep 17 00:00:00 2001 From: chetanr25 Date: Mon, 22 Jun 2026 23:27:54 +0530 Subject: [PATCH 3/4] Add alembic check to CI and migration commands to Makefile --- .github/workflows/tests.yml | 8 +++++++- Makefile | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c90b98..075b3e2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,4 +27,10 @@ jobs: env: PYTHONPATH: . run: | - python -m pytest tests/ -v --tb=short \ No newline at end of file + python -m pytest tests/ -v --tb=short + + - name: Check for un-migrated model changes + env: + PYTHONPATH: . + run: | + python -m alembic check \ No newline at end of file diff --git a/Makefile b/Makefile index 7d1d357..c81235e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean +.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean migrate migration COMPOSE = docker compose -f docker/dev/compose.yml --env-file docker/.env.dev ENV_DEV = docker/.env.dev @@ -28,6 +28,8 @@ help: @echo "make shell - Open shell in running app container" @echo "make pull-model - Pull Ollama model from .env.dev ($(OLLAMA_MODEL))" @echo "make test - Run test suite" + @echo "make migrate - Run pending Alembic migrations" + @echo "make migration - Generate new migration (msg='description')" @echo "make clean - Stop containers (preserves volumes)" @echo "make super-clean - [CAUTION] Stop containers, delete volumes, prune Docker" @@ -89,6 +91,12 @@ pull-model: test: @$(COMPOSE) exec -T app python3 -m pytest tests/ -v +migrate: + @$(COMPOSE) exec -T app alembic upgrade head + +migration: + @$(COMPOSE) exec -T app alembic revision --autogenerate -m "$(msg)" + clean: @$(COMPOSE) down From d31ff2dc2e3b6b8ee939f826346b4ff3cfe09425 Mon Sep 17 00:00:00 2001 From: marcvergees Date: Tue, 23 Jun 2026 07:16:27 -0700 Subject: [PATCH 4/4] refactor: :recycle: replaced Union and Sequence to newer accepted versions --- alembic/versions/001_initial_schema.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/alembic/versions/001_initial_schema.py b/alembic/versions/001_initial_schema.py index e6d9fad..59faa78 100644 --- a/alembic/versions/001_initial_schema.py +++ b/alembic/versions/001_initial_schema.py @@ -5,16 +5,16 @@ Create Date: 2026-06-22 """ -from typing import Sequence, Union +from collections.abc import Sequence from alembic import op import sqlalchemy as sa import sqlmodel revision: str = "001" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: