Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HF-39: Setup Database controller #8

Merged
merged 10 commits into from
Feb 24, 2024
13 changes: 13 additions & 0 deletions .github/workflows/quality_check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ jobs:
test:
name: Testing
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: ${{ secrets.POSTGRES_USERNAME }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v3
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

## Business
### Problem
1. Удобное сохранение твоих трат в твое хранилище.
1. Удобное сохранение твоих трат в твое хранилище.
1. Например все твои траты содержаться в excel в Google Drive. Чтобы добавить одну трату, тебе нужно сделать следующее:
1. Открыть Google Drive.
2. Открыть папку в которой находится нужный тебе файл.
Expand All @@ -15,11 +15,17 @@
1. Открыть Telegram.
2. Открыть чат с ботом Home Financier.
3. Нажать кнопку в меню: `внести траты`.
4. Указать необходимые данныe.
4. Указать необходимые данныe.

### Features
1. Подключение твоего хранилища.
1. Все данные хранятся у тебя, сервис не хранит их у себя.
2. Внесение трат с информацией о них.
1. Траты сохраняются в твое хранилище.
1. Дает тебе возможность иметь полный контроль над твоими тратами.


## Documentation
### Migrations
[dbmate](https://github.com/amacneil/dbmate) is used for migrations. It is CLI program that gives you an ability to manage migration using raw SQL. And, heh, written in GO :)
Use `dbmate` from `./migrations/` directory(this directory contains envs for `dbmate`)
4 changes: 4 additions & 0 deletions migrations/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Config for dbmate
DATABASE_URL=postgres://postgres:postgres@localhost:5432/homefinancier_dev?sslmode=disable
DBMATE_MIGRATIONS_DIR=./versions/
DBMATE_NO_DUMP_SCHEMA=true
86 changes: 85 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
asyncpg = "0.29.0"

[tool.poetry.group.dev.dependencies]
ruff = "==0.0.287"
Expand All @@ -15,6 +16,7 @@ pytest = "==7.4.3"
pytest-mock = "==3.11.1"
mypy = "==1.6.1"
pytest-asyncio = "==0.21.1"
asyncpg-stubs = "0.29.0"

[build-system]
requires = ["poetry-core"]
Expand All @@ -29,6 +31,8 @@ ignore = [
"FBT001", # https://docs.astral.sh/ruff/rules/boolean-type-hint-positional-argument/
"ANN101", # https://docs.astral.sh/ruff/rules/missing-type-self/
"A003", # https://docs.astral.sh/ruff/rules/builtin-attribute-shadowing/
"TCH002", # https://docs.astral.sh/ruff/rules/typing-only-third-party-import/
"FA102", # https://docs.astral.sh/ruff/rules/future-required-type-annotation/
]
fix = true
exclude = [
Expand All @@ -40,6 +44,7 @@ exclude = [
".venv",
"__pypackages__",
"venv",
"migrations",
]

[tool.ruff.per-file-ignores]
Expand Down Expand Up @@ -70,4 +75,5 @@ exclude = [
".venv",
"__pypackages__",
"venv",
"migrations",
]
Empty file.
21 changes: 21 additions & 0 deletions src/infrastructure/databases/base/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .core import (
Database,
DatabaseEngineInterface,
Session,
)
from .exceptions import (
DatabaseIsAlreadyConnectedError,
DatabaseIsNotConnectedError,
DatabaseSessionIsAlreadyInitializedError,
DatabaseSessionIsNotInitializedError,
)

__all__ = (
"Database",
"DatabaseEngineInterface",
"DatabaseIsAlreadyConnectedError",
"DatabaseIsNotConnectedError",
"DatabaseSessionIsNotInitializedError",
"DatabaseSessionIsAlreadyInitializedError",
"Session",
)
81 changes: 81 additions & 0 deletions src/infrastructure/databases/base/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import abc
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import AsyncGenerator, Generic, TypeVar

from .exceptions import (
DatabaseIsAlreadyConnectedError,
DatabaseIsNotConnectedError,
DatabaseSessionIsAlreadyInitializedError,
DatabaseSessionIsNotInitializedError,
)

Session = TypeVar("Session")


class DatabaseEngineInterface(abc.ABC, Generic[Session]):
@abc.abstractmethod
async def connect(self) -> None:
...

@abc.abstractmethod
async def disconnect(self) -> None:
...

@abc.abstractmethod
async def acquire(self) -> Session:
...

@abc.abstractmethod
async def release(self, conn: Session) -> None:
...


class Database(Generic[Session]):
def __init__(self, engine: DatabaseEngineInterface[Session]) -> None:
self._engine = engine
self._is_connected = False
self._ctx_var_session: ContextVar[Session] = ContextVar("session")

@asynccontextmanager
async def connect(self) -> AsyncGenerator[None, None]:
if self._is_connected:
raise DatabaseIsAlreadyConnectedError
await self._engine.connect()
self._is_connected = True
try:
yield
finally:
await self._engine.disconnect()
self._is_connected = False

@property
def session(self) -> Session:
"""Rerturns Session that assigned to current context (asyncio.Task or Thread)

Session is saved in context variable, so it can be accessed from any place in code.
Check https://peps.python.org/pep-0567/ for more details about context variables.
"""
if not self._is_connected:
raise DatabaseIsNotConnectedError
session = self._ctx_var_session.get(None)
if session is None:
raise DatabaseSessionIsNotInitializedError
return session

@asynccontextmanager
async def session_context(self) -> AsyncGenerator[Session, None]:
if not self._is_connected:
raise DatabaseIsNotConnectedError
session = self._ctx_var_session.get(None)
if session is not None:
raise DatabaseSessionIsAlreadyInitializedError

session = await self._engine.acquire()

ctx_var_token = self._ctx_var_session.set(session)
try:
yield session
finally:
self._ctx_var_session.reset(ctx_var_token)
await self._engine.release(session)
20 changes: 20 additions & 0 deletions src/infrastructure/databases/base/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class DatabaseIsAlreadyConnectedError(Exception):
def __init__(self) -> None:
super().__init__("Database is already connected")


class DatabaseIsNotConnectedError(Exception):
def __init__(self) -> None:
super().__init__("Database is not connected, use `connect` context manager to connect to database")


class DatabaseSessionIsAlreadyInitializedError(Exception):
def __init__(self) -> None:
super().__init__("Database session is already initialized")


class DatabaseSessionIsNotInitializedError(Exception):
def __init__(self) -> None:
super().__init__(
"Database session is not initialized, use `session_context` context manager to initialize session",
)
8 changes: 8 additions & 0 deletions src/infrastructure/databases/postgresql/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .core import Connection, PostgreSQL
from .exceptions import PostgreSQLIsNotConnectedError

__all__ = (
"PostgreSQL",
"PostgreSQLIsNotConnectedError",
"Connection",
)
47 changes: 47 additions & 0 deletions src/infrastructure/databases/postgresql/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import TYPE_CHECKING, TypeAlias

import asyncpg
from asyncpg import Pool, Record
from asyncpg.pool import PoolConnectionProxy

from src.infrastructure.databases.base import DatabaseEngineInterface

from .exceptions import PostgreSQLIsNotConnectedError

# Prevent runtime error: https://mypy.readthedocs.io/en/stable/runtime_troubles.html?highlight=subscriptable#using-classes-that-are-generic-in-stubs-but-not-at-runtime
if TYPE_CHECKING:
Connection: TypeAlias = PoolConnectionProxy[Record]
else:
Connection: TypeAlias = PoolConnectionProxy


class PostgreSQL(DatabaseEngineInterface[Connection]):
"""PostgreSQL database engine"

Uses asyncpg library to connect to PostgreSQL databases.
Uses connection pool to prevent creating new connection for each query.
"""

def __init__(self, dsn: str) -> None:
self._dsn = dsn
self._pool: Pool[Record] | None = None

async def connect(self) -> None:
"""Creating connection pool to PostgreSQL database"""
# Do not check any validation, asyncpg does it by itself
self._pool = await asyncpg.create_pool(dsn=self._dsn)

async def disconnect(self) -> None:
if not self._pool:
raise PostgreSQLIsNotConnectedError
await self._pool.close()

async def acquire(self) -> Connection:
if not self._pool:
raise PostgreSQLIsNotConnectedError
return await self._pool.acquire()

async def release(self, conn: Connection) -> None:
if not self._pool:
raise PostgreSQLIsNotConnectedError
await self._pool.release(connection=conn)
3 changes: 3 additions & 0 deletions src/infrastructure/databases/postgresql/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class PostgreSQLIsNotConnectedError(Exception):
def __init__(self) -> None:
super().__init__("PostgreSQL is not connected, use `connect` context manager to connect to database")
Empty file.
Empty file.
17 changes: 17 additions & 0 deletions tests/test_infrastructure/test_base/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Any, AsyncGenerator

import pytest
from pytest_mock import MockerFixture

from src.infrastructure.databases.base import Database, DatabaseEngineInterface


@pytest.fixture()
def database(mocker: MockerFixture) -> Database[Any]:
return Database(engine=mocker.Mock(spec=DatabaseEngineInterface))


@pytest.fixture()
async def connected_database(database: Database[Any]) -> AsyncGenerator[Database[Any], None]:
async with database.connect():
yield database
Loading