From 3f357764bd29741829c2a48e0d27937bba40f2b8 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Fri, 11 Apr 2025 00:02:04 +0100 Subject: [PATCH] feat(backend): add database models --- backend/src/models/__init__.py | 23 +++++ backend/src/models/base.py | 38 ++++++++ backend/src/models/category.py | 25 ++++++ backend/src/models/pattern.py | 53 ++++++++++++ backend/src/models/question.py | 153 +++++++++++++++++++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 backend/src/models/__init__.py create mode 100644 backend/src/models/base.py create mode 100644 backend/src/models/category.py create mode 100644 backend/src/models/pattern.py create mode 100644 backend/src/models/question.py diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..43581f1 --- /dev/null +++ b/backend/src/models/__init__.py @@ -0,0 +1,23 @@ +from src.models.base import Base +from src.models.category import Category +from src.models.pattern import Pattern +from src.models.question import ( + Difficulty, + Explanation, + Question, + QuestionCategory, + QuestionPattern, + Solution, +) + +__all__ = [ + "Base", + "Category", + "Difficulty", + "Explanation", + "Pattern", + "Question", + "QuestionCategory", + "QuestionPattern", + "Solution", +] diff --git a/backend/src/models/base.py b/backend/src/models/base.py new file mode 100644 index 0000000..d4f1f78 --- /dev/null +++ b/backend/src/models/base.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + """Base class for all models.""" + + pass + + +class TimestampMixin: + """Mixin that adds created_at and updated_at timestamps.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class UUIDMixin: + """Mixin that adds a UUID primary key.""" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) diff --git a/backend/src/models/category.py b/backend/src/models/category.py new file mode 100644 index 0000000..65e7370 --- /dev/null +++ b/backend/src/models/category.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, UUIDMixin + +if TYPE_CHECKING: + from src.models.question import Question + + +class Category(Base, UUIDMixin): + """Question category (e.g., Arrays, Strings, Trees).""" + + __tablename__ = "categories" + + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationships + questions: Mapped[list["Question"]] = relationship( + secondary="question_categories", + back_populates="categories", + ) diff --git a/backend/src/models/pattern.py b/backend/src/models/pattern.py new file mode 100644 index 0000000..b36a1ab --- /dev/null +++ b/backend/src/models/pattern.py @@ -0,0 +1,53 @@ +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, UUIDMixin + +if TYPE_CHECKING: + from src.models.question import Question + + +class Pattern(Base, UUIDMixin): + """Algorithmic pattern (e.g., Two Pointers, Sliding Window).""" + + __tablename__ = "patterns" + + # Core fields + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + when_to_use: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Tutorial content fields + metaphor: Mapped[str | None] = mapped_column(Text, nullable=True) + core_concept: Mapped[str | None] = mapped_column(Text, nullable=True) + visualization: Mapped[str | None] = mapped_column(Text, nullable=True) + code_template: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Structured data fields (JSONB) + recognition_signals: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + common_mistakes: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + variations: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + related_patterns: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + prerequisite_patterns: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + + # Difficulty level (1-5 learning curve) + difficulty_level: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Pattern classification + pattern_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + display_order: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Interactive visualization examples + visualization_examples: Mapped[list[dict[str, Any]] | None] = mapped_column( + JSONB, nullable=True + ) + + # Relationships + questions: Mapped[list["Question"]] = relationship( + secondary="question_patterns", + back_populates="patterns", + ) diff --git a/backend/src/models/question.py b/backend/src/models/question.py new file mode 100644 index 0000000..f497b39 --- /dev/null +++ b/backend/src/models/question.py @@ -0,0 +1,153 @@ +import enum +import uuid +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from src.models.category import Category + from src.models.pattern import Pattern + + +class Difficulty(str, enum.Enum): + """Question difficulty levels.""" + + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + + +# Association tables +class QuestionCategory(Base): + """Junction table for questions and categories.""" + + __tablename__ = "question_categories" + + question_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("questions.id", ondelete="CASCADE"), + primary_key=True, + ) + category_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("categories.id", ondelete="CASCADE"), + primary_key=True, + ) + + +class QuestionPattern(Base): + """Junction table for questions and patterns.""" + + __tablename__ = "question_patterns" + + question_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("questions.id", ondelete="CASCADE"), + primary_key=True, + ) + pattern_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("patterns.id", ondelete="CASCADE"), + primary_key=True, + ) + is_optimal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Relationships + question: Mapped["Question"] = relationship(back_populates="question_patterns") + pattern: Mapped["Pattern"] = relationship() + + +class Question(Base, UUIDMixin, TimestampMixin): + """Coding interview question.""" + + __tablename__ = "questions" + + title: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + difficulty: Mapped[Difficulty] = mapped_column( + Enum( + Difficulty, + name="difficulty_enum", + create_type=False, + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + ) + description: Mapped[str] = mapped_column(Text, nullable=False) + constraints: Mapped[str | None] = mapped_column(Text, nullable=True) + examples: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + leetcode_id: Mapped[int | None] = mapped_column(Integer, unique=True, nullable=True) + leetcode_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + function_signature: Mapped[str | None] = mapped_column(Text, nullable=True) + test_cases: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + + # Relationships + explanation: Mapped["Explanation | None"] = relationship( + back_populates="question", + uselist=False, + cascade="all, delete-orphan", + ) + solutions: Mapped[list["Solution"]] = relationship( + back_populates="question", + cascade="all, delete-orphan", + ) + categories: Mapped[list["Category"]] = relationship( + secondary="question_categories", + back_populates="questions", + ) + patterns: Mapped[list["Pattern"]] = relationship( + secondary="question_patterns", + back_populates="questions", + ) + question_patterns: Mapped[list["QuestionPattern"]] = relationship( + back_populates="question", + cascade="all, delete-orphan", + ) + + +class Explanation(Base, UUIDMixin): + """Educational explanation for a question.""" + + __tablename__ = "explanations" + + question_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("questions.id", ondelete="CASCADE"), + unique=True, + nullable=False, + ) + approach: Mapped[str] = mapped_column(Text, nullable=False) + intuition: Mapped[str] = mapped_column(Text, nullable=False) + common_pitfalls: Mapped[list[dict[str, Any]] | None] = mapped_column(JSONB, nullable=True) + key_takeaways: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + time_complexity: Mapped[str] = mapped_column(Text, nullable=False) + space_complexity: Mapped[str] = mapped_column(Text, nullable=False) + complexity_explanation: Mapped[str | None] = mapped_column(Text, nullable=True) + pattern_comparison: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationships + question: Mapped["Question"] = relationship(back_populates="explanation") + + +class Solution(Base, UUIDMixin): + """Code solution for a question.""" + + __tablename__ = "solutions" + + question_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("questions.id", ondelete="CASCADE"), + nullable=False, + ) + approach_name: Mapped[str] = mapped_column(String(100), nullable=False) + code: Mapped[str] = mapped_column(Text, nullable=False) + language: Mapped[str] = mapped_column(String(20), default="python", nullable=False) + is_optimal: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + explanation: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationships + question: Mapped["Question"] = relationship(back_populates="solutions")