Skip to content

Commit

Permalink
feat: migration by boundary
Browse files Browse the repository at this point in the history
  • Loading branch information
abusquets committed Aug 9, 2023
1 parent c9e93af commit 627a660
Show file tree
Hide file tree
Showing 54 changed files with 438 additions and 38 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ python manage.py {user_email} {name}

# Migrations

:warning: **WIP, not tested with multiple folders**


```bash
alembic init -t async migrations
```
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ known-local-folder = [
"auth",
"config",
"core",
"filmin",
"infra",
"shared",
"utils",
Expand Down
2 changes: 1 addition & 1 deletion src/alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[alembic]
# path to migration scripts
script_location = infra/database/alembic
script_location = infra/database/alembic core/infra/database/alembic/migrations/ filmin/infra/database/alembic/migrations/

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
Expand Down
3 changes: 1 addition & 2 deletions src/app/app_container.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from filmin.di.mixins import FilminContainerMixin

from auth.di.mixins.token import TokenContainerMixin
from config import settings
from core.di.mixins import CoreContainerMixin
from filmin.di.mixins import FilminContainerMixin
from infra.cache.ports import AbstractCacheRepository
from infra.database.sqlalchemy.session import AbstractDatabase
from utils.di import DIContainer, di_singleton
Expand Down
3 changes: 1 addition & 2 deletions src/app/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from filmin.api.router import router as filmin_router

from .app_container import AppContainer
from app.setup_logging import setup_logging
from auth.adapters.api.http.router import router as auth_router
from config import settings
from core.adapters.api.http.router import router as core_router
from filmin.api.router import router as filmin_router
from shared.exceptions import APPExceptionError


Expand Down
2 changes: 1 addition & 1 deletion src/core/adapters/spi/repositories/country.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from core.domain.dtos.country.update_country import UpdatePartialCountryInDTO
from core.domain.entities.country import Country
from core.domain.ports.repositories.country import AbstractCountryRepository
from infra.database.sqlalchemy.models.core.country import countries
from core.infra.database.sqlalchemy.models.country import countries
from shared.repository.sqlalchemy import SqlAlchemyRepository


Expand Down
2 changes: 1 addition & 1 deletion src/core/adapters/spi/repositories/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from core.domain.entities.user import User
from core.domain.ports.repositories.user import AbstractUserRepository, CreateUserInDTO, UpdatePartialUserInDTO
from infra.database.sqlalchemy.models.core.user import users
from core.infra.database.sqlalchemy.models.user import users
from shared.exceptions import AlreadyExistsError
from shared.repository.sqlalchemy import SqlAlchemyRepository

Expand Down
File renamed without changes.
Empty file.
101 changes: 101 additions & 0 deletions src/core/infra/database/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import asyncio

from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine

from config import settings
import infra.database.sqlalchemy.models.core
import infra.database.sqlalchemy.models.filmin # noqa

from infra.database.sqlalchemy.sqlalchemy import metadata


# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

config.set_main_option('sqlalchemy.url', settings.DATABASE_URL)

# Interpret the config file for Python logging.
# This line sets up loggers basically.
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 = None


target_metadata = metadata


# 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.


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: Connection) -> None:
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 = 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)

await connectable.dispose()


if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
24 changes: 24 additions & 0 deletions src/core/infra/database/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
Empty file.
80 changes: 80 additions & 0 deletions src/core/infra/database/sqlalchemy/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import abc

from asyncio import current_task
from contextlib import asynccontextmanager
import contextvars
import logging
from typing import TYPE_CHECKING, Any, AsyncContextManager, AsyncIterator, Dict, Optional, cast

from sqlalchemy.ext.asyncio import AsyncSession, async_scoped_session, create_async_engine
from sqlalchemy.orm import sessionmaker

from config import settings


if TYPE_CHECKING:
from sqlalchemy.ext.asyncio.engine import AsyncEngine


logger = logging.getLogger(__name__)


db_session_context: contextvars.ContextVar = contextvars.ContextVar('db_ctx', default={'session': None, 'level': 0})


class AbstractDatabase(abc.ABC):
@abc.abstractmethod
def session(self) -> AsyncContextManager[AsyncSession]:
...


def _get_current_task_id() -> int:
return id(current_task())


# @singleton
class Database(AbstractDatabase):
def __init__(self) -> None:
self.engine: AsyncEngine = create_async_engine(settings.DATABASE_URL, echo=True, future=True)
session_local: sessionmaker = sessionmaker(
self.engine, class_=AsyncSession, autocommit=False, autoflush=False, expire_on_commit=False
)
# SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, class_=AsyncSession)
self._session_factory = async_scoped_session(session_local, scopefunc=_get_current_task_id)

@asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
db_session: Optional[Dict[str, Any]] = None
db_session = db_session_context.get() or {'session': None, 'level': 0}
if db_session['level'] == 0:
session: AsyncSession = cast(AsyncSession, self._session_factory())
db_session['session'] = session
# await session.begin()
logger.debug('session begin', extra={'level': db_session['level']})

else:
session = db_session['session']
db_session['level'] = (db_session['level'] or 0) + 1
db_session_context.set(db_session)

try:
yield session
except Exception:
logger.exception('Session rollback because of exception')
await session.rollback()
logger.debug('session rollback')
raise
else:
# db_session = db_session_context.get() or {'session': None, 'level': 0}
if db_session['level'] == 0:
await session.commit()
logger.debug('session commit', extra={'level': db_session['level']})
finally:
# db_session = db_session_context.get() or {'session': None, 'level': 0}
if db_session['level'] == 0:
await session.close()
logger.debug('session close', extra={'level': db_session['level']})
db_session_context.set(None)
else:
db_session['level'] = (db_session['level'] or 0) - 1
db_session_context.set(db_session)
12 changes: 12 additions & 0 deletions src/core/infra/database/sqlalchemy/sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from sqlalchemy.schema import MetaData


metadata = MetaData(
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',
}
)
7 changes: 3 additions & 4 deletions src/filmin/api/genre.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
from fastapi.responses import JSONResponse
from fastapi.routing import APIRouter

from app.exceptions import EmptyPayloadExceptionError
from app.schemas import Session
from app.session_deps import check_access_token, is_admin_session
from filmin.api.schemas.genre import (
CreateGenreRequestDTO,
CreateGenreResponseDTO,
Expand All @@ -13,10 +16,6 @@
from filmin.data.repositories.ports.genre import AbstractGenreRepository
from filmin.domain.schemas.genre import Genre
from filmin.schemas.genre import CreateGenreInDTO, UpdatePartialGenreInDTO

from app.exceptions import EmptyPayloadExceptionError
from app.schemas import Session
from app.session_deps import check_access_token, is_admin_session
from shared.api.schemas.page import PagedResponseSchema, PageParams


Expand Down
3 changes: 1 addition & 2 deletions src/filmin/api/movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from fastapi.responses import JSONResponse
from fastapi.routing import APIRouter

from app.exceptions import EmptyPayloadExceptionError
from filmin.api.schemas.movie import (
CreateMovieRequestDTO,
CreateMovieResponseDTO,
Expand All @@ -12,8 +13,6 @@
from filmin.data.repositories.ports.movie import AbstractMovieRepository
from filmin.domain.schemas.movie import Movie
from filmin.schemas.movie import CreateMovieInDTO, UpdatePartialMovieInDTO

from app.exceptions import EmptyPayloadExceptionError
from shared.api.schemas.page import PagedResponseSchema, PageParams


Expand Down
3 changes: 1 addition & 2 deletions src/filmin/api/production_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from fastapi.responses import JSONResponse
from fastapi.routing import APIRouter

from app.exceptions import EmptyPayloadExceptionError
from filmin.api.schemas.production_company import (
CreateProductionCompanyRequestDTO,
CreateProductionCompanyResponseDTO,
Expand All @@ -13,8 +14,6 @@
from filmin.data.repositories.ports.production_company import AbstractProductionCompanyRepository
from filmin.domain.schemas.production_company import ProductionCompany
from filmin.schemas.production_company import CreateProductionCompanyInDTO, UpdatePartialProductionCompanyInDTO

from app.exceptions import EmptyPayloadExceptionError
from shared.api.schemas.page import PagedResponseSchema, PageParams


Expand Down
5 changes: 2 additions & 3 deletions src/filmin/data/repositories/genre.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from sqlalchemy.orm import registry

from .ports.genre import AbstractGenreRepository
from filmin.domain.schemas.genre import Genre
from filmin.infra.database.sqlalchemy.models.genre import genres
from filmin.schemas.genre import CreateGenreInDTO, UpdatePartialGenreInDTO

from .ports.genre import AbstractGenreRepository
from infra.database.sqlalchemy.models.filmin.genre import genres
from shared.repository.sqlalchemy import SqlAlchemyRepository


Expand Down
7 changes: 3 additions & 4 deletions src/filmin/data/repositories/movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import registry, relationship

from .ports.movie import AbstractMovieRepository
from filmin.data.repositories.ports.genre import AbstractGenreRepository
from filmin.domain.schemas.genre import Genre
from filmin.domain.schemas.movie import Movie
from filmin.domain.schemas.movie_collection import MovieCollection
from filmin.infra.database.sqlalchemy.models.collection import movie_collection
from filmin.infra.database.sqlalchemy.models.movie import movie, movie_genre
from filmin.schemas.movie import CreateMovieInDTO, UpdatePartialMovieInDTO

from .ports.movie import AbstractMovieRepository
from infra.database.sqlalchemy.models.filmin.collection import movie_collection
from infra.database.sqlalchemy.models.filmin.movie import movie, movie_genre
from shared.repository.sqlalchemy import SqlAlchemyRepository


Expand Down
1 change: 0 additions & 1 deletion src/filmin/data/repositories/ports/genre.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from filmin.domain.schemas.genre import Genre
from filmin.schemas.genre import CreateGenreInDTO, UpdatePartialGenreInDTO

from shared.repository.ports.generic import AbstractRepository


Expand Down
1 change: 0 additions & 1 deletion src/filmin/data/repositories/ports/movie.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from filmin.domain.schemas.movie import Movie
from filmin.schemas.movie import CreateMovieInDTO, UpdatePartialMovieInDTO

from shared.repository.ports.generic import AbstractRepository


Expand Down
1 change: 0 additions & 1 deletion src/filmin/data/repositories/ports/production_company.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from filmin.domain.schemas.production_company import ProductionCompany
from filmin.schemas.production_company import CreateProductionCompanyInDTO, UpdatePartialProductionCompanyInDTO

from shared.repository.ports.generic import AbstractRepository


Expand Down
Loading

0 comments on commit 627a660

Please sign in to comment.