From a03655039c4f43cb435fda923ecf04d7a7f7e655 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 07:32:58 -0400 Subject: [PATCH] feat: add SQLAlchemy declarative base, Alembic environment, and gitignore fix Configures stable constraint naming so autogeneration produces clean diffs. Alembic uses the sync psycopg driver while the runtime app uses asyncpg. Also fixes a .gitignore bug caught during this task: the bare 'models/' rule for the ML weights volume was matching backend/app/models/ (Python package). Anchored all volume rules to repo root (/images/, /import/, /downloads/, /models/, /postgres_data/, /redis_data/). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 15 +++++----- alembic.ini | 40 +++++++++++++++++++++++++ alembic/env.py | 53 ++++++++++++++++++++++++++++++++++ alembic/script.py.mako | 25 ++++++++++++++++ alembic/versions/.gitkeep | 0 backend/app/models/__init__.py | 7 +++++ backend/app/models/base.py | 17 +++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/.gitkeep create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/base.py diff --git a/.gitignore b/.gitignore index 4a5091a..06d2c93 100644 --- a/.gitignore +++ b/.gitignore @@ -42,13 +42,14 @@ yarn-error.log* extension/*.xpi extension/web-ext-artifacts/ -# Runtime volumes (must never be tracked) -images/ -import/ -downloads/ -models/ -postgres_data/ -redis_data/ +# Runtime volumes (must never be tracked) — anchored to repo root so they +# don't accidentally match Python package dirs like backend/app/models/ +/images/ +/import/ +/downloads/ +/models/ +/postgres_data/ +/redis_data/ # IDE / OS .vscode/ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..8b17767 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s +version_path_separator = os +sqlalchemy.url = + +[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/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..9cabe10 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,53 @@ +"""Alembic environment — reads DATABASE_URL from app config.""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from backend.app.config import get_config +from backend.app.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_config().database_url_sync) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${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: 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/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..f420c81 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +"""All ORM models. Import this module to make every model visible to Alembic.""" + +from .base import Base + +# Concrete models land in Task 6 and re-export here. + +__all__ = ["Base"] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..9fd456b --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,17 @@ +"""SQLAlchemy declarative base with standardized constraint naming.""" + +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + +# Stable constraint names so Alembic autogeneration produces clean diffs. +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION)