Skip to content

Commit edb771d

Browse files
committed
Add Alembic migration scaffold
1 parent 33c36a3 commit edb771d

13 files changed

Lines changed: 641 additions & 0 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Database Migration Guidelines
2+
3+
## Overview
4+
5+
This project uses Alembic for database migrations. API v1 still uses raw SQL
6+
initializers rather than ORM models, so Alembic target metadata is reflected
7+
from `policyengine_api/data/initialise_local.sql` by default.
8+
9+
## Rules
10+
11+
- Do not manually author Alembic operations for normal schema changes.
12+
- Generate migrations with `uv run alembic revision --autogenerate`.
13+
- Review generated migrations before applying them.
14+
- Keep SQL initializers and generated migrations aligned.
15+
- For pre-existing production databases, stamp the base revision before applying
16+
new upgrade revisions.
17+
18+
## Commands
19+
20+
```bash
21+
uv run alembic revision --autogenerate -m "Description"
22+
uv run alembic upgrade head
23+
uv run alembic current
24+
uv run alembic history
25+
uv run alembic stamp <revision>
26+
```
27+
28+
## API v1 Notes
29+
30+
- Set `POLICYENGINE_ALEMBIC_DATABASE_URL` to the database SQLAlchemy URL Alembic
31+
should connect to.
32+
- Set `POLICYENGINE_ALEMBIC_SCHEMA_SQL` when generating against a temporary
33+
schema SQL file instead of the current initializer.
34+
- The base migration should be stamped in production because the tables already
35+
exist there.

alembic.ini

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Alembic configuration for PolicyEngine API v1.
2+
3+
[alembic]
4+
script_location = %(here)s/alembic
5+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
6+
prepend_sys_path = .
7+
path_separator = os
8+
output_encoding = utf-8
9+
10+
# Overridden by alembic/env.py. For local generation, set
11+
# POLICYENGINE_ALEMBIC_DATABASE_URL explicitly.
12+
sqlalchemy.url = sqlite:///policyengine_api/data/policyengine.db
13+
14+
[post_write_hooks]
15+
16+
[loggers]
17+
keys = root,sqlalchemy,alembic
18+
19+
[handlers]
20+
keys = console
21+
22+
[formatters]
23+
keys = generic
24+
25+
[logger_root]
26+
level = WARN
27+
handlers = console
28+
qualname =
29+
30+
[logger_sqlalchemy]
31+
level = WARN
32+
handlers =
33+
qualname = sqlalchemy.engine
34+
35+
[logger_alembic]
36+
level = INFO
37+
handlers =
38+
qualname = alembic
39+
40+
[handler_console]
41+
class = StreamHandler
42+
args = (sys.stderr,)
43+
level = NOTSET
44+
formatter = generic
45+
46+
[formatter_generic]
47+
format = %(levelname)-5.5s [%(name)s] %(message)s
48+
datefmt = %H:%M:%S

alembic/README

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
PolicyEngine API v1 Alembic migrations.
2+
3+
This project does not currently use SQLAlchemy ORM models. Alembic
4+
autogenerate reflects target metadata from `policyengine_api/data/initialise_local.sql`
5+
or from the path in `POLICYENGINE_ALEMBIC_SCHEMA_SQL`.
6+
7+
Use `alembic stamp` for pre-existing production databases before applying
8+
incremental migrations.

alembic/env.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Alembic environment for PolicyEngine API v1 raw-SQL schema migrations."""
2+
3+
from logging.config import fileConfig
4+
import importlib.util
5+
import os
6+
from pathlib import Path
7+
import sys
8+
9+
from sqlalchemy import engine_from_config, pool
10+
11+
from alembic import context
12+
13+
sys.path.insert(0, str(Path(__file__).parent.parent))
14+
15+
metadata_path = (
16+
Path(__file__).parent.parent / "policyengine_api" / "data" / "alembic_metadata.py"
17+
)
18+
metadata_spec = importlib.util.spec_from_file_location(
19+
"policyengine_api_alembic_metadata",
20+
metadata_path,
21+
)
22+
if metadata_spec is None or metadata_spec.loader is None:
23+
raise RuntimeError(f"Could not load Alembic metadata helper from {metadata_path}")
24+
metadata_module = importlib.util.module_from_spec(metadata_spec)
25+
metadata_spec.loader.exec_module(metadata_module)
26+
build_metadata_from_sql = metadata_module.build_metadata_from_sql
27+
28+
29+
config = context.config
30+
31+
database_url = os.environ.get("POLICYENGINE_ALEMBIC_DATABASE_URL") or os.environ.get(
32+
"DATABASE_URL"
33+
)
34+
if database_url:
35+
config.set_main_option("sqlalchemy.url", database_url)
36+
37+
if config.config_file_name is not None:
38+
fileConfig(config.config_file_name)
39+
40+
schema_sql_path = os.environ.get("POLICYENGINE_ALEMBIC_SCHEMA_SQL")
41+
target_metadata = build_metadata_from_sql(schema_sql_path)
42+
43+
44+
def _configure_context(connection=None, url: str | None = None) -> None:
45+
options = {
46+
"target_metadata": target_metadata,
47+
"compare_type": False,
48+
"compare_server_default": False,
49+
}
50+
if connection is not None:
51+
context.configure(connection=connection, **options)
52+
else:
53+
context.configure(
54+
url=url,
55+
literal_binds=True,
56+
dialect_opts={"paramstyle": "named"},
57+
**options,
58+
)
59+
60+
61+
def run_migrations_offline() -> None:
62+
_configure_context(url=config.get_main_option("sqlalchemy.url"))
63+
with context.begin_transaction():
64+
context.run_migrations()
65+
66+
67+
def run_migrations_online() -> None:
68+
connectable = engine_from_config(
69+
config.get_section(config.config_ini_section, {}),
70+
prefix="sqlalchemy.",
71+
poolclass=pool.NullPool,
72+
)
73+
74+
with connectable.connect() as connection:
75+
_configure_context(connection=connection)
76+
with context.begin_transaction():
77+
context.run_migrations()
78+
79+
80+
if context.is_offline_mode():
81+
run_migrations_offline()
82+
else:
83+
run_migrations_online()

alembic/script.py.mako

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
${imports if imports else ""}
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = ${repr(up_revision)}
16+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
${upgrades if upgrades else "pass"}
24+
25+
26+
def downgrade() -> None:
27+
"""Downgrade schema."""
28+
${downgrades if downgrades else "pass"}

0 commit comments

Comments
 (0)