Skip to content

Commit 5ff8103

Browse files
committed
feat: add Alembic migration scripts for DB schema changes (closes imDarshanGK#596)
1 parent 59bcab2 commit 5ff8103

8 files changed

Lines changed: 355 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Migration Tests
2+
3+
on:
4+
push:
5+
branches: ["feat/db-migration-scripts"]
6+
pull_request:
7+
branches: ["main"]
8+
9+
jobs:
10+
migration-test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.11"
20+
21+
- name: Install dependencies
22+
run: |
23+
cd backend
24+
pip install alembic sqlalchemy
25+
26+
- name: Run upgrade
27+
run: |
28+
cd backend
29+
alembic upgrade head
30+
env:
31+
DATABASE_URL: sqlite:///./test.db
32+
33+
- name: Run downgrade
34+
run: |
35+
cd backend
36+
alembic downgrade base
37+
env:
38+
DATABASE_URL: sqlite:///./test.db
39+
40+
- name: Run upgrade again
41+
run: |
42+
cd backend
43+
alembic upgrade head
44+
env:
45+
DATABASE_URL: sqlite:///./test.db

backend/alembic.ini

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

backend/migrations/MIGRATIONS.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Database Migrations
2+
3+
This project uses [Alembic](https://alembic.sqlalchemy.org/) with SQLAlchemy for database schema migrations.
4+
5+
## Setup
6+
7+
```bash
8+
cd backend
9+
pip install alembic sqlalchemy
10+
```
11+
12+
## Running Migrations
13+
14+
### Upgrade to latest
15+
```bash
16+
alembic upgrade head
17+
```
18+
19+
### Downgrade one step
20+
```bash
21+
alembic downgrade -1
22+
```
23+
24+
### Check current version
25+
```bash
26+
alembic current
27+
```
28+
29+
### View migration history
30+
```bash
31+
alembic history
32+
```
33+
34+
## Creating a New Migration
35+
36+
```bash
37+
alembic revision -m "describe your change here"
38+
```
39+
40+
Then edit the generated file in `migrations/versions/` and fill in `upgrade()` and `downgrade()`.
41+
42+
## Migration Files
43+
44+
| File | Description |
45+
|------|-------------|
46+
| `0001_initial_schema.py` | Creates all base tables: users, query_history, favorite_results, digest_subscriptions, shares, audit_logs |
47+
| `0002_add_user_profile_fields.py` | Adds full_name, avatar_url, bio, updated_at to users table |
48+
| `0003_add_analysis_token_count.py` | Adds token_count, is_public, view_count to query_history table |
49+
50+
## Environment Variable
51+
52+
Set `DATABASE_URL` to override the default SQLite URL:
53+
54+
```bash
55+
export DATABASE_URL=postgresql://user:password@localhost/dbname
56+
```

backend/migrations/env.py

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

backend/migrations/script.py.mako

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
from alembic import op
10+
import sqlalchemy as sa
11+
${imports if imports else ""}
12+
13+
revision: str = ${repr(up_revision)}
14+
down_revision: Union[str, None] = ${repr(down_revision)}
15+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
16+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
17+
18+
19+
def upgrade() -> None:
20+
${upgrades if upgrades else "pass"}
21+
22+
23+
def downgrade() -> None:
24+
${downgrades if downgrades else "pass"}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Initial schema - all tables
2+
3+
Revision ID: 0001
4+
Revises:
5+
Create Date: 2026-06-27
6+
7+
"""
8+
from typing import Sequence, Union
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
revision: str = "0001"
13+
down_revision: Union[str, None] = None
14+
branch_labels: Union[str, Sequence[str], None] = None
15+
depends_on: Union[str, Sequence[str], None] = None
16+
17+
18+
def upgrade() -> None:
19+
op.create_table(
20+
"users",
21+
sa.Column("id", sa.Integer(), primary_key=True, index=True),
22+
sa.Column("email", sa.String(320), unique=True, index=True, nullable=False),
23+
sa.Column("password_hash", sa.String(256), nullable=False),
24+
sa.Column("is_admin", sa.Boolean(), nullable=False, default=False),
25+
sa.Column("created_at", sa.DateTime(), nullable=False),
26+
)
27+
op.create_table(
28+
"query_history",
29+
sa.Column("id", sa.Integer(), primary_key=True, index=True),
30+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), index=True, nullable=False),
31+
sa.Column("action", sa.String(50), nullable=False),
32+
sa.Column("code", sa.Text(), nullable=False),
33+
sa.Column("result_json", sa.Text(), nullable=False),
34+
sa.Column("created_at", sa.DateTime(), nullable=False),
35+
)
36+
op.create_table(
37+
"favorite_results",
38+
sa.Column("id", sa.Integer(), primary_key=True, index=True),
39+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), index=True, nullable=False),
40+
sa.Column("title", sa.String(200), nullable=False),
41+
sa.Column("action", sa.String(50), nullable=False),
42+
sa.Column("code", sa.Text(), nullable=False),
43+
sa.Column("result_json", sa.Text(), nullable=False),
44+
sa.Column("created_at", sa.DateTime(), nullable=False),
45+
)
46+
op.create_table(
47+
"digest_subscriptions",
48+
sa.Column("id", sa.Integer(), primary_key=True, index=True),
49+
sa.Column("email", sa.String(320), unique=True, index=True, nullable=False),
50+
sa.Column("is_active", sa.Boolean(), nullable=False, default=True),
51+
sa.Column("unsubscribe_token", sa.String(64), unique=True, index=True, nullable=False),
52+
sa.Column("subscribed_at", sa.DateTime(), nullable=False),
53+
sa.Column("last_sent_at", sa.DateTime(), nullable=True),
54+
)
55+
op.create_table(
56+
"shares",
57+
sa.Column("id", sa.Integer(), primary_key=True, index=True),
58+
sa.Column("token", sa.String(64), unique=True, index=True, nullable=False),
59+
sa.Column("code", sa.Text(), nullable=False),
60+
sa.Column("result_json", sa.Text(), nullable=False),
61+
sa.Column("created_at", sa.DateTime(), nullable=False),
62+
)
63+
op.create_table(
64+
"audit_logs",
65+
sa.Column("id", sa.Integer(), primary_key=True, index=True),
66+
sa.Column("actor_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True),
67+
sa.Column("actor_email", sa.String(320), nullable=False),
68+
sa.Column("action", sa.String(100), index=True, nullable=False),
69+
sa.Column("target_type", sa.String(50), nullable=True),
70+
sa.Column("target_id", sa.String(64), nullable=True),
71+
sa.Column("details", sa.Text(), nullable=True),
72+
sa.Column("ip_address", sa.String(64), nullable=True),
73+
sa.Column("created_at", sa.DateTime(), index=True, nullable=False),
74+
)
75+
76+
77+
def downgrade() -> None:
78+
op.drop_table("audit_logs")
79+
op.drop_table("shares")
80+
op.drop_table("digest_subscriptions")
81+
op.drop_table("favorite_results")
82+
op.drop_table("query_history")
83+
op.drop_table("users")
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Add user profile fields
2+
3+
Revision ID: 0002
4+
Revises: 0001
5+
Create Date: 2026-06-27
6+
7+
"""
8+
from typing import Sequence, Union
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
revision: str = "0002"
13+
down_revision: Union[str, None] = "0001"
14+
branch_labels: Union[str, Sequence[str], None] = None
15+
depends_on: Union[str, Sequence[str], None] = None
16+
17+
18+
def upgrade() -> None:
19+
op.add_column("users", sa.Column("full_name", sa.String(200), nullable=True))
20+
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
21+
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
22+
op.add_column("users", sa.Column("updated_at", sa.DateTime(), nullable=True))
23+
24+
25+
def downgrade() -> None:
26+
op.drop_column("users", "updated_at")
27+
op.drop_column("users", "bio")
28+
op.drop_column("users", "avatar_url")
29+
op.drop_column("users", "full_name")
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Add token_count, is_public, view_count to query_history
2+
3+
Revision ID: 0003
4+
Revises: 0002
5+
Create Date: 2026-06-27
6+
7+
"""
8+
from typing import Sequence, Union
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
revision: str = "0003"
13+
down_revision: Union[str, None] = "0002"
14+
branch_labels: Union[str, Sequence[str], None] = None
15+
depends_on: Union[str, Sequence[str], None] = None
16+
17+
18+
def upgrade() -> None:
19+
op.add_column("query_history", sa.Column("token_count", sa.Integer(), nullable=True, default=0))
20+
op.add_column("query_history", sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"))
21+
op.add_column("query_history", sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"))
22+
23+
24+
def downgrade() -> None:
25+
op.drop_column("query_history", "view_count")
26+
op.drop_column("query_history", "is_public")
27+
op.drop_column("query_history", "token_count")

0 commit comments

Comments
 (0)