Skip to content

Commit 9aa1628

Browse files
committed
feat: add Alembic migration scripts for DB schema changes (closes imDarshanGK#596)
1 parent 73b2fc8 commit 9aa1628

11 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Migration Tests
2+
3+
on:
4+
push:
5+
branches: [ main, master ]
6+
pull_request:
7+
branches: [ main, master ]
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+
pip install alembic sqlalchemy
24+
25+
- name: Run upgrade (head)
26+
working-directory: backend
27+
run: alembic upgrade head
28+
29+
- name: Run downgrade (base)
30+
working-directory: backend
31+
run: alembic downgrade base
32+
33+
- name: Run upgrade again (verify roundtrip)
34+
working-directory: backend
35+
run: alembic upgrade head

backend/alembic.ini

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[alembic]
2+
script_location = migrations
3+
prepend_sys_path = .
4+
sqlalchemy.url = sqlite:///./qyverix.db
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+
qualname =
19+
20+
[logger_sqlalchemy]
21+
level = WARN
22+
handlers =
23+
qualname = sqlalchemy.engine
24+
25+
[logger_alembic]
26+
level = INFO
27+
handlers =
28+
qualname = alembic
29+
30+
[handler_console]
31+
class = StreamHandler
32+
args = (sys.stderr,)
33+
level = NOTSET
34+
formatter = generic
35+
36+
[formatter_generic]
37+
format = %(levelname)-5.5s [%(name)s] %(message)s
38+
datefmt = %H:%M:%S

backend/app/db/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

backend/app/db/database.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import os
2+
from sqlalchemy import create_engine
3+
from sqlalchemy.orm import sessionmaker
4+
5+
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./qyverix.db")
6+
7+
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
8+
9+
engine = create_engine(DATABASE_URL, connect_args=connect_args, echo=False)
10+
11+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
12+
13+
14+
def get_db():
15+
db = SessionLocal()
16+
try:
17+
yield db
18+
finally:
19+
db.close()

backend/app/db/models.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from datetime import datetime, timezone
2+
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, JSON
3+
from sqlalchemy.orm import DeclarativeBase, relationship
4+
5+
6+
class Base(DeclarativeBase):
7+
pass
8+
9+
10+
class User(Base):
11+
__tablename__ = "users"
12+
13+
id = Column(Integer, primary_key=True, index=True)
14+
email = Column(String(255), unique=True, nullable=False, index=True)
15+
username = Column(String(100), unique=True, nullable=False, index=True)
16+
hashed_password = Column(String(255), nullable=False)
17+
is_active = Column(Boolean, default=True, nullable=False)
18+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
19+
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
20+
21+
analyses = relationship("Analysis", back_populates="user", cascade="all, delete-orphan")
22+
share_links = relationship("ShareLink", back_populates="user", cascade="all, delete-orphan")
23+
24+
25+
class ShareLink(Base):
26+
__tablename__ = "share_links"
27+
28+
id = Column(Integer, primary_key=True, index=True)
29+
short_id = Column(String(16), unique=True, nullable=False, index=True)
30+
code = Column(Text, nullable=False)
31+
result = Column(JSON, nullable=False)
32+
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
33+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
34+
expires_at = Column(DateTime(timezone=True), nullable=False)
35+
36+
user = relationship("User", back_populates="share_links")
37+
38+
39+
class Analysis(Base):
40+
__tablename__ = "analyses"
41+
42+
id = Column(Integer, primary_key=True, index=True)
43+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
44+
language = Column(String(50), nullable=True)
45+
code_snippet = Column(Text, nullable=False)
46+
result = Column(JSON, nullable=False)
47+
provider = Column(String(50), nullable=False, default="rule-based")
48+
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
49+
50+
user = relationship("User", back_populates="analyses")

backend/migrations/MIGRATIONS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Database Migrations
2+
3+
This project uses **Alembic** for database schema migrations.
4+
5+
## Setup
6+
7+
```bash
8+
cd backend
9+
pip install alembic sqlalchemy
10+
```
11+
12+
## Commands
13+
14+
| Task | Command |
15+
|------|---------|
16+
| Apply all migrations | `alembic upgrade head` |
17+
| Rollback one step | `alembic downgrade -1` |
18+
| Rollback everything | `alembic downgrade base` |
19+
| Create new migration | `alembic revision -m "your message"` |
20+
| Check current version | `alembic current` |
21+
| View history | `alembic history` |
22+
23+
## Environment Variable
24+
25+
```bash
26+
# SQLite (default, local dev)
27+
DATABASE_URL=sqlite:///./qyverix.db
28+
29+
# PostgreSQL (production)
30+
DATABASE_URL=postgresql+psycopg2://user:pass@host/dbname
31+
```
32+
33+
## Migration Files
34+
35+
| File | Description |
36+
|------|-------------|
37+
| `0001_initial_schema.py` | Creates users, share_links, analyses tables |
38+
| `0002_add_user_profile_fields.py` | Adds full_name, bio, avatar_url, last_login_at to users |
39+
| `0003_add_analysis_token_count.py` | Adds token_count, is_public to analyses; view_count to share_links |

backend/migrations/env.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
import sys
3+
from logging.config import fileConfig
4+
from alembic import context
5+
from sqlalchemy import engine_from_config, pool
6+
7+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
8+
9+
from app.db.models import Base # noqa: F401
10+
11+
config = context.config
12+
13+
database_url = os.getenv("DATABASE_URL", "sqlite:///./qyverix.db")
14+
config.set_main_option("sqlalchemy.url", database_url)
15+
16+
if config.config_file_name is not None:
17+
fileConfig(config.config_file_name)
18+
19+
target_metadata = Base.metadata
20+
21+
22+
def run_migrations_offline() -> None:
23+
url = config.get_main_option("sqlalchemy.url")
24+
context.configure(
25+
url=url,
26+
target_metadata=target_metadata,
27+
literal_binds=True,
28+
dialect_opts={"paramstyle": "named"},
29+
compare_type=True,
30+
)
31+
with context.begin_transaction():
32+
context.run_migrations()
33+
34+
35+
def run_migrations_online() -> None:
36+
connectable = engine_from_config(
37+
config.get_section(config.config_ini_section, {}),
38+
prefix="sqlalchemy.",
39+
poolclass=pool.NullPool,
40+
)
41+
with connectable.connect() as connection:
42+
context.configure(
43+
connection=connection,
44+
target_metadata=target_metadata,
45+
compare_type=True,
46+
)
47+
with context.begin_transaction():
48+
context.run_migrations()
49+
50+
51+
if context.is_offline_mode():
52+
run_migrations_offline()
53+
else:
54+
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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Initial schema: users, share_links, analyses
2+
3+
Revision ID: 0001
4+
Revises:
5+
Create Date: 2025-06-01 00:00:00.000000
6+
"""
7+
from typing import Sequence, Union
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
revision: str = "0001"
12+
down_revision: Union[str, None] = None
13+
branch_labels: Union[str, Sequence[str], None] = None
14+
depends_on: Union[str, Sequence[str], None] = None
15+
16+
17+
def upgrade() -> None:
18+
op.create_table(
19+
"users",
20+
sa.Column("id", sa.Integer(), primary_key=True),
21+
sa.Column("email", sa.String(255), nullable=False),
22+
sa.Column("username", sa.String(100), nullable=False),
23+
sa.Column("hashed_password", sa.String(255), nullable=False),
24+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
25+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
26+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
27+
)
28+
op.create_index("ix_users_email", "users", ["email"], unique=True)
29+
op.create_index("ix_users_username", "users", ["username"], unique=True)
30+
op.create_index("ix_users_id", "users", ["id"])
31+
32+
op.create_table(
33+
"share_links",
34+
sa.Column("id", sa.Integer(), primary_key=True),
35+
sa.Column("short_id", sa.String(16), nullable=False),
36+
sa.Column("code", sa.Text(), nullable=False),
37+
sa.Column("result", sa.JSON(), nullable=False),
38+
sa.Column("user_id", sa.Integer(), nullable=True),
39+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
40+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
41+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
42+
)
43+
op.create_index("ix_share_links_short_id", "share_links", ["short_id"], unique=True)
44+
op.create_index("ix_share_links_user_id", "share_links", ["user_id"])
45+
op.create_index("ix_share_links_id", "share_links", ["id"])
46+
47+
op.create_table(
48+
"analyses",
49+
sa.Column("id", sa.Integer(), primary_key=True),
50+
sa.Column("user_id", sa.Integer(), nullable=False),
51+
sa.Column("language", sa.String(50), nullable=True),
52+
sa.Column("code_snippet", sa.Text(), nullable=False),
53+
sa.Column("result", sa.JSON(), nullable=False),
54+
sa.Column("provider", sa.String(50), nullable=False, server_default="rule-based"),
55+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
56+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
57+
)
58+
op.create_index("ix_analyses_user_id", "analyses", ["user_id"])
59+
op.create_index("ix_analyses_id", "analyses", ["id"])
60+
61+
62+
def downgrade() -> None:
63+
op.drop_table("analyses")
64+
op.drop_table("share_links")
65+
op.drop_table("users")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Add profile fields to users table
2+
3+
Revision ID: 0002
4+
Revises: 0001
5+
Create Date: 2025-06-02 00:00:00.000000
6+
"""
7+
from typing import Sequence, Union
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
revision: str = "0002"
12+
down_revision: Union[str, None] = "0001"
13+
branch_labels: Union[str, Sequence[str], None] = None
14+
depends_on: Union[str, Sequence[str], None] = None
15+
16+
17+
def upgrade() -> None:
18+
op.add_column("users", sa.Column("full_name", sa.String(200), nullable=True))
19+
op.add_column("users", sa.Column("bio", sa.Text(), nullable=True))
20+
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
21+
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
22+
23+
24+
def downgrade() -> None:
25+
op.drop_column("users", "last_login_at")
26+
op.drop_column("users", "avatar_url")
27+
op.drop_column("users", "bio")
28+
op.drop_column("users", "full_name")

0 commit comments

Comments
 (0)