From 6de1e262a478973ff3cec0ca896682c3ecdde090 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 9 Jul 2024 19:44:24 +0100 Subject: Add alembic boiler plate for migrations --- Dockerfile | 4 +- alembic.ini | 53 +++++++++++++++++++++++++ backend/models/orm/__init__.py | 7 ++++ backend/models/orm/base.py | 25 ++++++++++++ docker-compose.yml | 2 +- migrations/__init__.py | 0 migrations/env.py | 86 +++++++++++++++++++++++++++++++++++++++++ migrations/script.py.mako | 27 +++++++++++++ migrations/versions/__init__.py | 0 pyproject.toml | 4 ++ 10 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 alembic.ini create mode 100644 backend/models/orm/base.py create mode 100644 migrations/__init__.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/__init__.py diff --git a/Dockerfile b/Dockerfile index 1229b56..af798d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,5 +18,5 @@ ARG git_sha="development" ENV GIT_SHA=$git_sha # Start the server with uvicorn -ENTRYPOINT ["poetry", "run"] -CMD ["uvicorn", "backend:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["/bin/bash", "-c"] +CMD ["poetry run alembic upgrade head && poetry run uvicorn backend:app --host 0.0.0.0 --port 8000"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..89c7735 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,53 @@ +# A generic, single database configuration. + +[alembic] +script_location = migrations +file_template = %%(epoch)s-%%(rev)s_%%(slug)s +prepend_sys_path = . +timezone = utc +version_path_separator = os +output_encoding = utf-8 + +[post_write_hooks] +hooks = ruff-lint, ruff-format +ruff-lint.type = exec +ruff-lint.executable = ruff +ruff-lint.options = check --fix-only REVISION_SCRIPT_FILENAME + +ruff-format.type = exec +ruff-format.executable = ruff +ruff-format.options = format REVISION_SCRIPT_FILENAME + +[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/models/orm/__init__.py b/backend/models/orm/__init__.py index e69de29..4c8a6b4 100644 --- a/backend/models/orm/__init__.py +++ b/backend/models/orm/__init__.py @@ -0,0 +1,7 @@ +"""Database models.""" + +from .base import Base + +__all__ = ( + "Base", +) diff --git a/backend/models/orm/base.py b/backend/models/orm/base.py new file mode 100644 index 0000000..adf9270 --- /dev/null +++ b/backend/models/orm/base.py @@ -0,0 +1,25 @@ +"""The base classes for ORM models.""" + +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.schema import MetaData + +NAMING_CONVENTIONS = { + "ix": "%(column_0_label)s_ix", + "uq": "%(table_name)s_%(column_0_name)s_uq", + "ck": "%(table_name)s_%(constraint_name)s_ck", + "fk": "%(table_name)s_%(column_0_name)s_%(referred_table_name)s_fk", + "pk": "%(table_name)s_pk", +} + + +class Base(AsyncAttrs, DeclarativeBase): + """Classes that inherit this class will be automatically mapped using declarative mapping.""" + + metadata = MetaData(naming_convention=NAMING_CONVENTIONS) + + def patch_from_pydantic(self, pydantic_model: BaseModel) -> None: + """Patch this model using the given pydantic model, unspecified attributes remain the same.""" + for key, value in pydantic_model.dict(exclude_unset=True).items(): + setattr(self, key, value) diff --git a/docker-compose.yml b/docker-compose.yml index b8d58da..5be7843 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: backend: build: . - command: ["uvicorn", "--reload", "--host", "0.0.0.0", "backend:app"] + command: ["poetry run alembic upgrade head && poetry run uvicorn backend:app --reload --host 0.0.0.0 --port 8000"] ports: - "127.0.0.1:8000:8000" depends_on: diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..36ddea1 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,86 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.ext.asyncio.engine import AsyncConnection + +# This is a required step by Alembic to properly generate migrations +import backend.models.orm +from backend.constants import PSQL_DATABASE_URL + +target_metadata = backend.models.orm.base.Base.metadata + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +config.set_main_option("sqlalchemy.url", PSQL_DATABASE_URL) + + +def run_migrations_offline() -> None: + """ + Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + 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: AsyncConnection) -> None: + """Run all migrations on the given connection.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """ + Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ), + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1d3e1aa --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +""" +${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +import sqlalchemy as sa +from alembic import op + +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + """Apply this migration.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Revert this migration.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 422d1e9..c5a18b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,10 @@ ignore = [ ] [tool.ruff.lint.isort] +known-first-party = ["migrations", "backend"] order-by-type = false case-sensitive = true combine-as-imports = true + +[tool.ruff.lint.per-file-ignores] +"migrations/*" = ["N999"] -- cgit v1.2.3