diff --git a/fastapp/README.md b/fastapp/README.md index 0fbd905..9eff77b 100644 --- a/fastapp/README.md +++ b/fastapp/README.md @@ -105,6 +105,39 @@ By default it runs: You can read more about pre-commit here: https://pre-commit.com/ +## Migrations + +If you want to migrate your database, you should run following commands: +```bash +# To run all migrations until the migration with revision_id. +alembic upgrade "" + +# To perform all pending migrations. +alembic upgrade "head" +``` + +### Reverting migrations + +If you want to revert migrations, you should run: +```bash +# revert all migrations up to: revision_id. +alembic downgrade + +# Revert everything. + alembic downgrade base +``` + +### Migration generation + +To generate migrations you should run: +```bash +# For automatic change detection. +alembic revision --autogenerate + +# For empty file generation. +alembic revision +``` + ## Running tests diff --git a/fastapp/alembic.ini b/fastapp/alembic.ini new file mode 100644 index 0000000..a12d87e --- /dev/null +++ b/fastapp/alembic.ini @@ -0,0 +1,54 @@ +[alembic] +script_location = fastapp/db/migrations +file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d-%%(minute).2d_%%(rev)s +prepend_sys_path = . +output_encoding = utf-8 +# truncate_slug_length = 40 + + +[post_write_hooks] +hooks = black,autoflake,isort + +black.type = console_scripts +black.entrypoint = black + +autoflake.type = console_scripts +autoflake.entrypoint = autoflake + +isort.type = console_scripts +isort.entrypoint = isort + +# Logging configuration +[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/fastapp/docker-compose.yml b/fastapp/docker-compose.yml index db84d7c..5068c5e 100644 --- a/fastapp/docker-compose.yml +++ b/fastapp/docker-compose.yml @@ -13,6 +13,15 @@ services: volumes: - fastapp-db-data:/db_data/ + migrator: + image: fastapp:${FASTAPP_VERSION:-latest} + restart: "no" + command: alembic upgrade head + environment: + FASTAPP_DB_FILE: /db_data/db.sqlite3 + volumes: + - fastapp-db-data:/db_data/ + volumes: diff --git a/fastapp/fastapp/db/dao/__init__.py b/fastapp/fastapp/db/dao/__init__.py new file mode 100644 index 0000000..db62a0a --- /dev/null +++ b/fastapp/fastapp/db/dao/__init__.py @@ -0,0 +1 @@ +"""DAO classes.""" diff --git a/fastapp/fastapp/db/dao/dummy_dao.py b/fastapp/fastapp/db/dao/dummy_dao.py new file mode 100644 index 0000000..3ebd842 --- /dev/null +++ b/fastapp/fastapp/db/dao/dummy_dao.py @@ -0,0 +1,50 @@ +from typing import List, Optional + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fastapp.db.dependencies import get_db_session +from fastapp.db.models.dummy_model import DummyModel + + +class DummyDAO: + """Class for accessing dummy table.""" + + def __init__(self, session: AsyncSession = Depends(get_db_session)) -> None: + self.session = session + + async def create_dummy_model(self, name: str) -> None: + """ + Add single dummy to session. + + :param name: name of a dummy. + """ + self.session.add(DummyModel(name=name)) + + async def get_all_dummies(self, limit: int, offset: int) -> List[DummyModel]: + """ + Get all dummy models with limit/offset pagination. + + :param limit: limit of dummies. + :param offset: offset of dummies. + :return: stream of dummies. + """ + raw_dummies = await self.session.execute( + select(DummyModel).limit(limit).offset(offset), + ) + + return list(raw_dummies.scalars().fetchall()) + + async def filter(self, name: Optional[str] = None) -> List[DummyModel]: + """ + Get specific dummy model. + + :param name: name of dummy instance. + :return: dummy models. + """ + query = select(DummyModel) + if name: + query = query.where(DummyModel.name == name) + rows = await self.session.execute(query) + return list(rows.scalars().fetchall()) diff --git a/fastapp/fastapp/db/migrations/__init__.py b/fastapp/fastapp/db/migrations/__init__.py new file mode 100644 index 0000000..a3c9318 --- /dev/null +++ b/fastapp/fastapp/db/migrations/__init__.py @@ -0,0 +1 @@ +"""Alembic migrations.""" diff --git a/fastapp/fastapp/db/migrations/env.py b/fastapp/fastapp/db/migrations/env.py new file mode 100644 index 0000000..28e4fe9 --- /dev/null +++ b/fastapp/fastapp/db/migrations/env.py @@ -0,0 +1,88 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio.engine import create_async_engine +from sqlalchemy.future import Connection +from fastapp.db.meta import meta +from fastapp.db.models import load_all_models +from fastapp.settings import settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + + +load_all_models() +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = meta + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +async 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. + + """ + context.configure( + url=str(settings.db_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 actual sync migrations. + + :param connection: connection to the database. + """ + context.configure(connection=connection, target_metadata=target_metadata) + + 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 = create_async_engine(str(settings.db_url)) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + +loop = asyncio.get_event_loop() +if context.is_offline_mode(): + task = run_migrations_offline() +else: + task = run_migrations_online() + +loop.run_until_complete(task) diff --git a/fastapp/fastapp/db/migrations/script.py.mako b/fastapp/fastapp/db/migrations/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/fastapp/fastapp/db/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${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: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/fastapp/fastapp/db/migrations/versions/2021-08-16-16-53_819cbf6e030b.py b/fastapp/fastapp/db/migrations/versions/2021-08-16-16-53_819cbf6e030b.py new file mode 100644 index 0000000..86abe60 --- /dev/null +++ b/fastapp/fastapp/db/migrations/versions/2021-08-16-16-53_819cbf6e030b.py @@ -0,0 +1,21 @@ +"""Initial migration. + +Revision ID: 819cbf6e030b +Revises: +Create Date: 2021-08-16 16:53:05.484024 + +""" + +# revision identifiers, used by Alembic. +revision = "819cbf6e030b" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/fastapp/fastapp/db/migrations/versions/2021-08-16-16-55_2b7380507a71.py b/fastapp/fastapp/db/migrations/versions/2021-08-16-16-55_2b7380507a71.py new file mode 100644 index 0000000..5527468 --- /dev/null +++ b/fastapp/fastapp/db/migrations/versions/2021-08-16-16-55_2b7380507a71.py @@ -0,0 +1,33 @@ +"""Created Dummy Model. + +Revision ID: 2b7380507a71 +Revises: 819cbf6e030b +Create Date: 2021-08-16 16:55:25.157309 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "2b7380507a71" +down_revision = "819cbf6e030b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "dummy_model", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=200), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("dummy_model") + # ### end Alembic commands ### diff --git a/fastapp/fastapp/db/migrations/versions/__init__.py b/fastapp/fastapp/db/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapp/fastapp/db/models/dummy_model.py b/fastapp/fastapp/db/models/dummy_model.py new file mode 100644 index 0000000..5869eb1 --- /dev/null +++ b/fastapp/fastapp/db/models/dummy_model.py @@ -0,0 +1,13 @@ +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql.sqltypes import String + +from fastapp.db.base import Base + + +class DummyModel(Base): + """Model for demo purpose.""" + + __tablename__ = "dummy_model" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(length=200)) diff --git a/fastapp/fastapp/web/api/dummy/__init__.py b/fastapp/fastapp/web/api/dummy/__init__.py new file mode 100644 index 0000000..925db5b --- /dev/null +++ b/fastapp/fastapp/web/api/dummy/__init__.py @@ -0,0 +1,5 @@ +"""Dummy model API.""" + +from fastapp.web.api.dummy.views import router + +__all__ = ["router"] diff --git a/fastapp/fastapp/web/api/dummy/schema.py b/fastapp/fastapp/web/api/dummy/schema.py new file mode 100644 index 0000000..247c5d1 --- /dev/null +++ b/fastapp/fastapp/web/api/dummy/schema.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, ConfigDict + + +class DummyModelDTO(BaseModel): + """ + DTO for dummy models. + + It returned when accessing dummy models from the API. + """ + + id: int + name: str + + model_config = ConfigDict(from_attributes=True) + + +class DummyModelInputDTO(BaseModel): + """DTO for creating new dummy model.""" + + name: str diff --git a/fastapp/fastapp/web/api/dummy/views.py b/fastapp/fastapp/web/api/dummy/views.py new file mode 100644 index 0000000..0b8bbfa --- /dev/null +++ b/fastapp/fastapp/web/api/dummy/views.py @@ -0,0 +1,41 @@ +from typing import List + +from fastapi import APIRouter +from fastapi.param_functions import Depends + +from fastapp.db.dao.dummy_dao import DummyDAO +from fastapp.db.models.dummy_model import DummyModel +from fastapp.web.api.dummy.schema import DummyModelDTO, DummyModelInputDTO + +router = APIRouter() + + +@router.get("/", response_model=List[DummyModelDTO]) +async def get_dummy_models( + limit: int = 10, + offset: int = 0, + dummy_dao: DummyDAO = Depends(), +) -> List[DummyModel]: + """ + Retrieve all dummy objects from the database. + + :param limit: limit of dummy objects, defaults to 10. + :param offset: offset of dummy objects, defaults to 0. + :param dummy_dao: DAO for dummy models. + :return: list of dummy objects from database. + """ + return await dummy_dao.get_all_dummies(limit=limit, offset=offset) + + +@router.put("/") +async def create_dummy_model( + new_dummy_object: DummyModelInputDTO, + dummy_dao: DummyDAO = Depends(), +) -> None: + """ + Creates dummy model in the database. + + :param new_dummy_object: new dummy model item. + :param dummy_dao: DAO for dummy models. + """ + await dummy_dao.create_dummy_model(name=new_dummy_object.name) diff --git a/fastapp/fastapp/web/api/router.py b/fastapp/fastapp/web/api/router.py index 883b5b9..49af071 100644 --- a/fastapp/fastapp/web/api/router.py +++ b/fastapp/fastapp/web/api/router.py @@ -1,7 +1,8 @@ from fastapi.routing import APIRouter -from fastapp.web.api import echo, monitoring +from fastapp.web.api import dummy, echo, monitoring api_router = APIRouter() api_router.include_router(monitoring.router) api_router.include_router(echo.router, prefix="/echo", tags=["echo"]) +api_router.include_router(dummy.router, prefix="/dummy", tags=["dummy"]) diff --git a/fastapp/fastapp/web/lifespan.py b/fastapp/fastapp/web/lifespan.py index 8963cb3..db38232 100644 --- a/fastapp/fastapp/web/lifespan.py +++ b/fastapp/fastapp/web/lifespan.py @@ -4,8 +4,6 @@ from fastapi import FastAPI from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from fastapp.db.meta import meta -from fastapp.db.models import load_all_models from fastapp.settings import settings @@ -28,15 +26,6 @@ def _setup_db(app: FastAPI) -> None: # pragma: no cover app.state.db_session_factory = session_factory -async def _create_tables() -> None: # pragma: no cover - """Populates tables in the database.""" - load_all_models() - engine = create_async_engine(str(settings.db_url)) - async with engine.begin() as connection: - await connection.run_sync(meta.create_all) - await engine.dispose() - - @asynccontextmanager async def lifespan_setup( app: FastAPI, @@ -53,7 +42,6 @@ async def lifespan_setup( app.middleware_stack = None _setup_db(app) - await _create_tables() app.middleware_stack = app.build_middleware_stack() yield diff --git a/fastapp/poetry.lock b/fastapp/poetry.lock index e5a9bfd..645f81e 100644 --- a/fastapp/poetry.lock +++ b/fastapp/poetry.lock @@ -18,6 +18,25 @@ typing_extensions = ">=4.0" dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] +[[package]] +name = "alembic" +version = "1.14.0" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.8" +files = [ + {file = "alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25"}, + {file = "alembic-1.14.0.tar.gz", hash = "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["backports.zoneinfo"] + [[package]] name = "annotated-types" version = "0.7.0" @@ -615,6 +634,25 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "mako" +version = "1.3.6" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.6-py3-none-any.whl", hash = "sha256:a91198468092a2f1a0de86ca92690fb0cfc43ca90ee17e15d93662b4c04b241a"}, + {file = "mako-1.3.6.tar.gz", hash = "sha256:9ec3a1583713479fae654f83ed9fa8c9a4c16b7bb0daba0e6bbebff50c0d983d"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -2137,4 +2175,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "39fb595b0f3024b4b1fbd595bca5cf66bbf31310d47aa91652e3e95fac8bcc91" +content-hash = "cfbdd0c3d7a228cbc3d1ffcde906e053f7f4465de7b3f15e362141d704da6fee" diff --git a/fastapp/pyproject.toml b/fastapp/pyproject.toml index 5bd2882..b746ebb 100644 --- a/fastapp/pyproject.toml +++ b/fastapp/pyproject.toml @@ -20,6 +20,7 @@ pydantic-settings = "^2" yarl = "^1" ujson = "^5.10.0" SQLAlchemy = {version = "^2.0.31", extras = ["asyncio"]} +alembic = "^1.13.2" aiosqlite = "^0.20.0" httptools = "^0.6.1" pymongo = "^4.8.0" @@ -137,7 +138,7 @@ api_type = "rest" enable_redis = "None" enable_rmq = "None" ci_type = "none" -enable_migrations = "None" +enable_migrations = "True" enable_taskiq = "None" enable_kube = "None" kube_name = "fastapp" @@ -145,7 +146,7 @@ enable_routers = "True" enable_kafka = "None" enable_loguru = "None" traefik_labels = "None" -add_dummy = "None" +add_dummy = "True" orm = "sqlalchemy" self_hosted_swagger = "None" prometheus_enabled = "None" diff --git a/fastapp/tests/test_dummy.py b/fastapp/tests/test_dummy.py new file mode 100644 index 0000000..c7644ab --- /dev/null +++ b/fastapp/tests/test_dummy.py @@ -0,0 +1,45 @@ +import uuid + +import pytest +from fastapi import FastAPI +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from starlette import status + +from fastapp.db.dao.dummy_dao import DummyDAO + + +@pytest.mark.anyio +async def test_creation( + fastapi_app: FastAPI, + client: AsyncClient, + dbsession: AsyncSession, +) -> None: + """Tests dummy instance creation.""" + url = fastapi_app.url_path_for("create_dummy_model") + test_name = uuid.uuid4().hex + response = await client.put(url, json={"name": test_name}) + assert response.status_code == status.HTTP_200_OK + dao = DummyDAO(dbsession) + + instances = await dao.filter(name=test_name) + assert instances[0].name == test_name + + +@pytest.mark.anyio +async def test_getting( + fastapi_app: FastAPI, + client: AsyncClient, + dbsession: AsyncSession, +) -> None: + """Tests dummy instance retrieval.""" + dao = DummyDAO(dbsession) + test_name = uuid.uuid4().hex + await dao.create_dummy_model(name=test_name) + url = fastapi_app.url_path_for("get_dummy_models") + response = await client.get(url) + dummies = response.json() + + assert response.status_code == status.HTTP_200_OK + assert len(dummies) == 1 + assert dummies[0]["name"] == test_name