From 00a5b736f93279237edbaa76eb5c7ec8519b991d Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Tue, 15 Apr 2025 22:47:23 +0100 Subject: [PATCH] feat(backend): add Alembic migrations --- backend/alembic.ini | 41 +++++ backend/alembic/env.py | 69 ++++++++ backend/alembic/script.py.mako | 26 +++ .../alembic/versions/001_initial_schema.py | 162 ++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial_schema.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..54a71d3 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = postgresql+asyncpg://codetutor:codetutor@localhost:5432/codetutor + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[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/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0f72793 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,69 @@ +"""Alembic migration environment configuration.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from src.config import get_settings +from src.models import Base + +config = context.config +settings = get_settings() + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Run migrations with connection.""" + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in async mode.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${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 +${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/backend/alembic/versions/001_initial_schema.py b/backend/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..e93b651 --- /dev/null +++ b/backend/alembic/versions/001_initial_schema.py @@ -0,0 +1,162 @@ +"""Initial schema + +Revision ID: 001 +Revises: +Create Date: 2025-01-30 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Create difficulty enum + difficulty_enum = postgresql.ENUM("easy", "medium", "hard", name="difficulty_enum") + difficulty_enum.create(op.get_bind()) + + # Create questions table + op.create_table( + "questions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("slug", sa.String(255), unique=True, nullable=False), + sa.Column( + "difficulty", + sa.Enum("easy", "medium", "hard", name="difficulty_enum"), + nullable=False, + ), + sa.Column("description", sa.Text, nullable=False), + sa.Column("constraints", sa.Text, nullable=True), + sa.Column("examples", postgresql.JSONB, nullable=True), + sa.Column("leetcode_id", sa.Integer, unique=True, nullable=True), + sa.Column("leetcode_url", sa.String(512), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_questions_slug", "questions", ["slug"]) + op.create_index("ix_questions_difficulty", "questions", ["difficulty"]) + + # Create explanations table + op.create_table( + "explanations", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "question_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("questions.id", ondelete="CASCADE"), + unique=True, + nullable=False, + ), + sa.Column("approach", sa.Text, nullable=False), + sa.Column("intuition", sa.Text, nullable=False), + sa.Column("common_pitfalls", postgresql.JSONB, nullable=True), + sa.Column("key_takeaways", postgresql.JSONB, nullable=True), + sa.Column("time_complexity", sa.String(50), nullable=False), + sa.Column("space_complexity", sa.String(50), nullable=False), + sa.Column("complexity_explanation", sa.Text, nullable=True), + ) + + # Create solutions table + op.create_table( + "solutions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "question_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("questions.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("approach_name", sa.String(100), nullable=False), + sa.Column("code", sa.Text, nullable=False), + sa.Column("language", sa.String(20), server_default="python", nullable=False), + sa.Column("is_optimal", sa.Boolean, server_default="false", nullable=False), + sa.Column("explanation", sa.Text, nullable=True), + ) + op.create_index("ix_solutions_question_id", "solutions", ["question_id"]) + + # Create categories table + op.create_table( + "categories", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(100), unique=True, nullable=False), + sa.Column("slug", sa.String(100), unique=True, nullable=False), + sa.Column("description", sa.Text, nullable=True), + ) + op.create_index("ix_categories_slug", "categories", ["slug"]) + + # Create patterns table + op.create_table( + "patterns", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(100), unique=True, nullable=False), + sa.Column("slug", sa.String(100), unique=True, nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("when_to_use", sa.Text, nullable=True), + ) + op.create_index("ix_patterns_slug", "patterns", ["slug"]) + + # Create question_categories junction table + op.create_table( + "question_categories", + sa.Column( + "question_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("questions.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "category_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("categories.id", ondelete="CASCADE"), + primary_key=True, + ), + ) + + # Create question_patterns junction table + op.create_table( + "question_patterns", + sa.Column( + "question_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("questions.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "pattern_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("patterns.id", ondelete="CASCADE"), + primary_key=True, + ), + ) + + +def downgrade() -> None: + op.drop_table("question_patterns") + op.drop_table("question_categories") + op.drop_table("patterns") + op.drop_table("categories") + op.drop_table("solutions") + op.drop_table("explanations") + op.drop_table("questions") + + difficulty_enum = postgresql.ENUM("easy", "medium", "hard", name="difficulty_enum") + difficulty_enum.drop(op.get_bind())