Skip to content

Commit 9bdfe96

Browse files
m4dm4rtig4nClément VALENTINclaude
authored
Implement Alembic for database migrations (#20)
- Set up Alembic configuration with async support for PostgreSQL and SQLite - Create initial baseline migration representing current schema - Add migration for missing pricing_option column in PDLs table - Implement automatic migrations on container startup via entrypoint script - Add Makefile targets for migration management (upgrade, downgrade, history, etc.) - Update documentation with Alembic usage instructions - Fixes missing pricing_option column error in PostgreSQL queries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Clément VALENTIN <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent dd52e35 commit 9bdfe96

File tree

11 files changed

+697
-20
lines changed

11 files changed

+697
-20
lines changed

CLAUDE.md

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,14 @@ make watch # Start backend file watcher
2626
make stop-watch # Stop backend file watcher
2727

2828
# Database
29-
make db-shell # Access PostgreSQL shell
30-
make db-backup # Backup database
31-
make migrate # Apply database migrations
29+
make db-shell # Access PostgreSQL shell
30+
make db-backup # Backup database
31+
make migrate # Apply all pending migrations
32+
make migrate-downgrade # Rollback last migration
33+
make migrate-history # Show migration history
34+
make migrate-current # Show current migration revision
35+
make migrate-revision # Generate a new migration (autogenerate)
36+
make migrate-stamp # Stamp database with current revision
3237

3338
# Maintenance
3439
make logs # Show all logs
@@ -99,15 +104,32 @@ uv run black src tests
99104
uv run ruff check --fix src tests
100105
```
101106

102-
**Database Migration:**
107+
**Database Migration (Alembic):**
103108

104109
```bash
105110
# Auto-detect: SQLite (default) or PostgreSQL based on DATABASE_URL
106111
# SQLite: sqlite+aiosqlite:///./data/myelectricaldata.db
107112
# PostgreSQL: postgresql+asyncpg://user:pass@postgres:5432/db
108113

109-
# Migration scripts in apps/api/migrations/
110-
docker compose exec backend python /app/migrations/migration_name.py
114+
# Apply all pending migrations
115+
docker compose exec backend alembic upgrade head
116+
117+
# Rollback last migration
118+
docker compose exec backend alembic downgrade -1
119+
120+
# Show migration history
121+
docker compose exec backend alembic history
122+
123+
# Generate a new migration (after modifying models)
124+
docker compose exec backend alembic revision --autogenerate -m "Description"
125+
126+
# For existing databases, stamp with current revision
127+
docker compose exec backend alembic stamp head
128+
129+
# Local development (from apps/api/)
130+
cd apps/api
131+
uv run alembic upgrade head
132+
uv run alembic revision --autogenerate -m "Description"
111133
```
112134

113135
### Frontend (apps/web/)

Makefile

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,14 @@ help:
5151
@echo " make backend-restart - Restart backend container"
5252
@echo ""
5353
@echo "$(YELLOW)Database:$(NC)"
54-
@echo " make db-shell - Access PostgreSQL shell"
55-
@echo " make db-backup - Backup database"
56-
@echo " make migrate - Apply database migrations"
54+
@echo " make db-shell - Access PostgreSQL shell"
55+
@echo " make db-backup - Backup database"
56+
@echo " make migrate - Apply all pending migrations"
57+
@echo " make migrate-downgrade - Rollback last migration"
58+
@echo " make migrate-history - Show migration history"
59+
@echo " make migrate-current - Show current migration revision"
60+
@echo " make migrate-revision - Generate a new migration"
61+
@echo " make migrate-stamp - Stamp database with current revision"
5762
@echo ""
5863
@echo "$(YELLOW)Documentation:$(NC)"
5964
@echo " make docs - Start documentation server via Docker (http://localhost:8002)"
@@ -179,15 +184,40 @@ db-backup:
179184
$(COMPOSE) exec -T postgres pg_dump -U myelectricaldata myelectricaldata > $(LOG_DIR)/backups/backup_$$(date +%Y%m%d_%H%M%S).sql
180185
@echo "$(GREEN)Backup saved to $(LOG_DIR)/backups/$(NC)"
181186

182-
## Apply database migrations
187+
## Apply database migrations (Alembic)
183188
migrate:
184189
@echo "$(GREEN)Applying database migrations...$(NC)"
185-
@if [ -f ./apps/api/scripts/create_refresh_tracker_table.sql ]; then \
186-
docker exec -i myelectricaldata-postgres psql -U myelectricaldata -d myelectricaldata < ./apps/api/scripts/create_refresh_tracker_table.sql; \
187-
echo "$(GREEN)Migrations applied$(NC)"; \
188-
else \
189-
echo "$(YELLOW)No migrations found$(NC)"; \
190-
fi
190+
$(COMPOSE) exec backend alembic upgrade head
191+
@echo "$(GREEN)Migrations applied$(NC)"
192+
193+
## Rollback last migration
194+
migrate-downgrade:
195+
@echo "$(YELLOW)Rolling back last migration...$(NC)"
196+
$(COMPOSE) exec backend alembic downgrade -1
197+
@echo "$(GREEN)Rollback complete$(NC)"
198+
199+
## Show migration history
200+
migrate-history:
201+
@echo "$(GREEN)Migration history:$(NC)"
202+
$(COMPOSE) exec backend alembic history
203+
204+
## Show current migration revision
205+
migrate-current:
206+
@echo "$(GREEN)Current migration revision:$(NC)"
207+
$(COMPOSE) exec backend alembic current
208+
209+
## Generate a new migration (autogenerate)
210+
migrate-revision:
211+
@echo "$(GREEN)Creating new migration...$(NC)"
212+
@read -p "Enter migration message: " msg; \
213+
$(COMPOSE) exec backend alembic revision --autogenerate -m "$$msg"
214+
@echo "$(GREEN)Migration created$(NC)"
215+
216+
## Stamp the database with current revision (for existing databases)
217+
migrate-stamp:
218+
@echo "$(YELLOW)Stamping database with head revision...$(NC)"
219+
$(COMPOSE) exec backend alembic stamp head
220+
@echo "$(GREEN)Database stamped$(NC)"
191221

192222
## Show all logs
193223
logs:
@@ -444,4 +474,4 @@ docker-info:
444474
@echo " Backend: $(IMAGE_BACKEND):$(VERSION)"
445475
@echo " Frontend: $(IMAGE_FRONTEND):$(VERSION)"
446476

447-
.PHONY: help dev up up-fg down restart watch stop-watch stop-docs backend-logs backend-restart db-shell db-backup migrate logs ps clean rebuild check-deps install-fswatch docs docs-build docs-dev docs-down docker-login docker-build docker-build-backend docker-build-frontend docker-push docker-push-backend docker-push-frontend docker-release docker-release-native docker-release-multiarch docker-release-amd64 docker-release-arm64 docker-release-ci docker-buildx-setup docker-info
477+
.PHONY: help dev up up-fg down restart watch stop-watch stop-docs backend-logs backend-restart db-shell db-backup migrate migrate-downgrade migrate-history migrate-current migrate-revision migrate-stamp logs ps clean rebuild check-deps install-fswatch docs docs-build docs-dev docs-down docker-login docker-build docker-build-backend docker-build-frontend docker-push docker-push-backend docker-push-frontend docker-release docker-release-native docker-release-multiarch docker-release-amd64 docker-release-arm64 docker-release-ci docker-buildx-setup docker-info

apps/api/Dockerfile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ COPY --from=deps /usr/local/bin /usr/local/bin
4040
# Copy application code (changes frequently - last layer)
4141
COPY . .
4242

43+
# Make entrypoint executable
44+
RUN chmod +x /app/entrypoint.sh
45+
4346
# Expose port
4447
EXPOSE 8000
4548

46-
# Start the application directly with uvicorn
47-
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
49+
# Start with entrypoint (runs migrations then uvicorn)
50+
ENTRYPOINT ["/app/entrypoint.sh"]

apps/api/Makefile

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: install run test lint format clean sync
1+
.PHONY: install run test lint format clean sync migrate migrate-upgrade migrate-downgrade migrate-revision migrate-history migrate-current
22

33
install:
44
uv sync
@@ -30,3 +30,25 @@ clean:
3030
find . -type d -name __pycache__ -exec rm -rf {} +
3131
find . -type f -name "*.pyc" -delete
3232
rm -rf .pytest_cache .coverage htmlcov .mypy_cache .venv
33+
34+
# Alembic migrations
35+
migrate: migrate-upgrade
36+
37+
migrate-upgrade:
38+
uv run alembic upgrade head
39+
40+
migrate-downgrade:
41+
uv run alembic downgrade -1
42+
43+
migrate-revision:
44+
@read -p "Enter migration message: " msg; \
45+
uv run alembic revision --autogenerate -m "$$msg"
46+
47+
migrate-history:
48+
uv run alembic history
49+
50+
migrate-current:
51+
uv run alembic current
52+
53+
migrate-stamp-head:
54+
uv run alembic stamp head

apps/api/alembic.ini

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
script_location = alembic
6+
7+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
9+
10+
# set to 'true' to run the environment during
11+
# the 'revision' command, regardless of autogenerate
12+
# revision_environment = false
13+
14+
# set to 'true' to allow .pyc and .pyo files without
15+
# a source .py file to be detected as revisions in the
16+
# versions/ directory
17+
# sourceless = false
18+
19+
# version location specification; This defaults
20+
# to alembic/versions. When using multiple version
21+
# directories, initial revisions must be specified with --version-path.
22+
# The path separator used here should be the separator specified by "version_path_separator" below.
23+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
24+
25+
# version path separator; As mentioned above, this is the character used to split
26+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
27+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
28+
# Valid values for version_path_separator are:
29+
#
30+
# version_path_separator = :
31+
# version_path_separator = ;
32+
# version_path_separator = space
33+
# version_path_separator = newline
34+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
35+
36+
# set to 'true' to search source files recursively
37+
# in each "version_locations" directory
38+
# recursive_version_locations = false
39+
40+
# the output encoding used when revision files
41+
# are written from script.py.mako
42+
# output_encoding = utf-8
43+
44+
# SQLAlchemy URL - read from environment variable DATABASE_URL
45+
# This is overridden in env.py to use the settings
46+
sqlalchemy.url =
47+
48+
49+
[post_write_hooks]
50+
# post_write_hooks defines scripts or Python functions that are run
51+
# on newly generated revision scripts. See the documentation for further
52+
# detail and examples
53+
54+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
55+
hooks = black
56+
black.type = console_scripts
57+
black.entrypoint = black
58+
black.options = -q
59+
60+
61+
# Logging configuration
62+
[loggers]
63+
keys = root,sqlalchemy,alembic
64+
65+
[handlers]
66+
keys = console
67+
68+
[formatters]
69+
keys = generic
70+
71+
[logger_root]
72+
level = WARN
73+
handlers = console
74+
qualname =
75+
76+
[logger_sqlalchemy]
77+
level = WARN
78+
handlers =
79+
qualname = sqlalchemy.engine
80+
81+
[logger_alembic]
82+
level = INFO
83+
handlers =
84+
qualname = alembic
85+
86+
[handler_console]
87+
class = StreamHandler
88+
args = (sys.stderr,)
89+
level = NOTSET
90+
formatter = generic
91+
92+
[formatter_generic]
93+
format = %(levelname)-5.5s [%(name)s] %(message)s
94+
datefmt = %H:%M:%S

apps/api/alembic/README

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Generic single-database configuration with async support.
2+
3+
This Alembic environment is configured for async SQLAlchemy with support for both
4+
PostgreSQL (asyncpg) and SQLite (aiosqlite).
5+
6+
Usage:
7+
# Generate a new migration
8+
cd apps/api
9+
alembic revision --autogenerate -m "Description of changes"
10+
11+
# Apply all migrations
12+
alembic upgrade head
13+
14+
# Rollback one migration
15+
alembic downgrade -1
16+
17+
# Show current revision
18+
alembic current
19+
20+
# Show migration history
21+
alembic history
22+
23+
Docker Usage:
24+
docker compose exec backend alembic upgrade head
25+
docker compose exec backend alembic revision --autogenerate -m "Description"

apps/api/alembic/env.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import asyncio
2+
from logging.config import fileConfig
3+
4+
from sqlalchemy import pool
5+
from sqlalchemy.engine import Connection
6+
from sqlalchemy.ext.asyncio import async_engine_from_config
7+
8+
from alembic import context
9+
10+
# Add src to path for imports
11+
import sys
12+
from pathlib import Path
13+
14+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
15+
16+
# Import models and config
17+
from config import settings
18+
from models import Base
19+
20+
# this is the Alembic Config object, which provides
21+
# access to the values within the .ini file in use.
22+
config = context.config
23+
24+
# Override sqlalchemy.url with our settings
25+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
26+
27+
# Interpret the config file for Python logging.
28+
# This line sets up loggers basically.
29+
if config.config_file_name is not None:
30+
fileConfig(config.config_file_name)
31+
32+
# add your model's MetaData object here
33+
# for 'autogenerate' support
34+
target_metadata = Base.metadata
35+
36+
# other values from the config, defined by the needs of env.py,
37+
# can be acquired:
38+
# my_important_option = config.get_main_option("my_important_option")
39+
# ... etc.
40+
41+
42+
def run_migrations_offline() -> None:
43+
"""Run migrations in 'offline' mode.
44+
45+
This configures the context with just a URL
46+
and not an Engine, though an Engine is acceptable
47+
here as well. By skipping the Engine creation
48+
we don't even need a DBAPI to be available.
49+
50+
Calls to context.execute() here emit the given string to the
51+
script output.
52+
53+
"""
54+
url = config.get_main_option("sqlalchemy.url")
55+
context.configure(
56+
url=url,
57+
target_metadata=target_metadata,
58+
literal_binds=True,
59+
dialect_opts={"paramstyle": "named"},
60+
)
61+
62+
with context.begin_transaction():
63+
context.run_migrations()
64+
65+
66+
def do_run_migrations(connection: Connection) -> None:
67+
context.configure(connection=connection, target_metadata=target_metadata)
68+
69+
with context.begin_transaction():
70+
context.run_migrations()
71+
72+
73+
async def run_async_migrations() -> None:
74+
"""In this scenario we need to create an Engine
75+
and associate a connection with the context.
76+
77+
"""
78+
connectable = async_engine_from_config(
79+
config.get_section(config.config_ini_section, {}),
80+
prefix="sqlalchemy.",
81+
poolclass=pool.NullPool,
82+
)
83+
84+
async with connectable.connect() as connection:
85+
await connection.run_sync(do_run_migrations)
86+
87+
await connectable.dispose()
88+
89+
90+
def run_migrations_online() -> None:
91+
"""Run migrations in 'online' mode."""
92+
asyncio.run(run_async_migrations())
93+
94+
95+
if context.is_offline_mode():
96+
run_migrations_offline()
97+
else:
98+
run_migrations_online()

0 commit comments

Comments
 (0)