Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ repos:
language: system
types: [python]
args: [--ini, .bandit]
- id: mypy
name: "mypy"
entry: mypy
args: ["--config-file", "pyproject.toml"]
types: [python]
language: system
exclude: tests
# - id: mypy
# name: "mypy"
# entry: mypy
# args: ["--config-file", "pyproject.toml"]
# types: [python]
# language: system
# exclude: tests

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
Expand Down
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Вспомогательная библиотека для разработки приложений на FastAPI.

Реализует функционал для наиболее распространных ситуаций (создание экзмепляра приложения FastAPI по заданным настройкам,
Реализует функционал для наиболее распространных ситуаций (создание экзмепляра приложения FastAPI по заданным настройкам,
работа с БД, фильтрация, пагинация, авторизация, аутентификация).

- [Названия библиотеки](#названия-библиотеки)
Expand Down Expand Up @@ -33,7 +33,7 @@ fastapi-django - рабочее название (не финальное).
## TODO

1. множественные БД (в одну БД (мастер) пишется, в другую синхронизируется и из нее читается.)
2. генерация шаблона проекта как в django (также генерируется файл manage.py, в котором дополняются переменные окружения
2. генерация шаблона проекта как в django (также генерируется файл manage.py, в котором дополняются переменные окружения
и который является входной точкой в приложение)
2. прикинуть, какие еще консольные команды могут пригодиться (напр., миграции)
3. репозитории
Expand All @@ -52,7 +52,7 @@ fastapi-django - рабочее название (не финальное).

## Создание приложения

Библиотека предоставляет функцию `fastapi_django.app.get_default_app()`, которая создает экземпляр приложения FastAPI
Библиотека предоставляет функцию `fastapi_django.app.get_default_app()`, которая создает экземпляр приложения FastAPI
с параметрами, указанными в настройках в settings.py:

```python
Expand Down Expand Up @@ -92,13 +92,13 @@ UVICORN_APP = "web.app:app"
python manage.py runserver
```

Это запустит экземпляр указанного в UVICORN_APP приложения при помощи uvicorn.
Это запустит экземпляр указанного в UVICORN_APP приложения при помощи uvicorn.

## Работа с БД

### Сессии SQLAlchemy

Работа с базами данных происходит через слой репозиториев. Для этого разработан [базовый класс репозитория BaseRepository](fastapi_django/db/repositories/base.py#L13),
Работа с базами данных происходит через слой репозиториев. Для этого разработан [базовый класс репозитория BaseRepository](fastapi_django/db/repositories/base.py#L13),
который предоставляет возможность работать с данными в стиле Django ORM:

```python
Expand All @@ -115,9 +115,9 @@ async with contextified_autocommit_session():
users = await repository.objects.filter(name="Иван").all()
```

Обратите внимание, что сессия SQLAlchemy не передается при инициализации репозитория. Вместо этого она инициализируется
контекстным менеджером contextified_autocommit_session() и помещается в ContextVars. Репозитории (все в пределах действия
контекстного менеджера) затем берут инициализированную сессию оттуда. contextified_autocommit_session() также управляет
Обратите внимание, что сессия SQLAlchemy не передается при инициализации репозитория. Вместо этого она инициализируется
контекстным менеджером contextified_autocommit_session() и помещается в ContextVars. Репозитории (все в пределах действия
контекстного менеджера) затем берут инициализированную сессию оттуда. contextified_autocommit_session() также управляет
жизненным циклом сессии.

Настройки базы данных задаются в settings(.py) в DATABASE. Пример:
Expand Down Expand Up @@ -148,10 +148,10 @@ DATABASE = {

### fastapi-sqla

Движки, сессии конфигурируются библиотекой. Параметры через энвы, для наименования которых необходимо придерживаться
некоторых правил.
Движки, сессии конфигурируются библиотекой. Параметры через энвы, для наименования которых необходимо придерживаться
некоторых правил.

Сессия создается в миддлварях и [записывается в fastapi.Request.state](https://github.com/dialoguemd/fastapi-sqla/blob/master/fastapi_sqla/async_sqla.py#L137).
Сессия создается в миддлварях и [записывается в fastapi.Request.state](https://github.com/dialoguemd/fastapi-sqla/blob/master/fastapi_sqla/async_sqla.py#L137).
Затем эту сессию депенденси AsyncSessionDependency возвращает.

Сессия не хранится в contextvars.
Expand Down Expand Up @@ -324,4 +324,3 @@ def get_users():
Паттерн active record.

Не поддерживается.

5 changes: 3 additions & 2 deletions fastapi_django/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from fastapi_django.conf import settings
from fastapi_django.exceptions import ImproperlyConfigured


# поддерживаемые диалекты SQLAlchemy https://docs.sqlalchemy.org/en/20/dialects/index.html
# SQLAlchemy как безоговорочный вариант для взаимодействия с БД

Expand All @@ -27,7 +26,9 @@ def __init__(self):
database=settings.DATABASE["DATABASE"],
)
kw = settings.DATABASE.get("OPTIONS", {})
self.__dict__["_engine"] = create_async_engine(url, **kw) # иначе будет RecursionError: maximum recursion depth exceeded
self.__dict__["_engine"] = create_async_engine(
url, **kw
) # иначе будет RecursionError: maximum recursion depth exceeded

def __getattr__(self, item):
return getattr(self._engine, item)
Expand Down
7 changes: 5 additions & 2 deletions fastapi_django/db/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
from typing import Any, AsyncIterator, Callable, Annotated
from collections.abc import AsyncIterator, Callable
from typing import Annotated, Any

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession

from fastapi_django.db.sessions import contextified_transactional_session, contextified_autocommit_session
from fastapi_django.db.sessions import contextified_autocommit_session, contextified_transactional_session


def contextify_transactional_session(**kw: Any) -> Callable:
async def wrapper() -> AsyncIterator[AsyncSession]:
async with contextified_transactional_session(**kw) as session:
yield session

return wrapper


def contextify_autocommit_session(**kw: Any) -> Callable:
async def wrapper() -> AsyncIterator[AsyncSession]:
async with contextified_autocommit_session(**kw) as session:
yield session

return wrapper


Expand Down
4 changes: 1 addition & 3 deletions fastapi_django/db/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
class ColumnNotFoundError(Exception):
def __init__(self, model, column_name: str):
error = (
f"Столбец `{column_name}` не найден в модели {model.__name__}"
)
error = f"Столбец `{column_name}` не найден в модели {model.__name__}"
super().__init__(error)
4 changes: 2 additions & 2 deletions fastapi_django/db/repositories/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from itertools import islice
from typing import Generic, Any, Type, Self
from typing import Any, Generic, Self

from fastapi_django.db.repositories.queryset import QuerySet
from fastapi_django.db.sessions import session_context_var
Expand All @@ -11,7 +11,7 @@


class BaseRepository(Generic[Model]):
model_cls: Type[Model]
model_cls: type[Model]

def __init__(self):
if not self.model_cls:
Expand Down
60 changes: 23 additions & 37 deletions fastapi_django/db/repositories/builder.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,46 @@
import logging
from typing import Any, Type, Self
from typing import Any, Self

from sqlalchemy import Select, select, func, delete, Delete, update, Update
from sqlalchemy.orm import contains_eager, aliased
from sqlalchemy import Delete, Select, Update, delete, func, select, update
from sqlalchemy.orm import aliased, contains_eager
from sqlalchemy.sql.operators import eq

from fastapi_django.db.repositories.constants import LOOKUP_SEP
from fastapi_django.db.repositories.lookups import lookups
from fastapi_django.db.types import Model
from fastapi_django.db.utils import get_column, get_pk, get_relationships, get_columns, get_annotations
from fastapi_django.db.utils import get_annotations, get_column, get_columns, get_pk, get_relationships

logger = logging.getLogger(__name__)


class InvalidFilterFieldError(Exception):

def __init__(self, filter_field: str):
error = f"Некорректное поле для фильтрации - {filter_field}"
super().__init__(error)


class InvalidOrderByFieldError(Exception):

def __init__(self, ordering_field: str):
error = f"Некорректное поле для сортировки - {ordering_field}"
super().__init__(error)


class InvalidOptionFieldError(Exception):

def __init__(self, option_field: str):
error = f"Некорректное поле для options - {option_field}"
super().__init__(error)


class InvalidJoinFieldError(Exception):

def __init__(self, join_field: str):
error = f"Некорректное поле для join - {join_field}"
super().__init__(error)


class QueryBuilder:
"""
Обертка над запросом SQLAlchemy. Хранит параметры запроса. Предоставляет методы для
"""Обертка над запросом SQLAlchemy.

Хранит параметры запроса. Предоставляет методы для
создания конечных методов

Собирает параметры запроса и в конце генерирует запрос
Expand Down Expand Up @@ -134,7 +131,7 @@ class QueryBuilder:

"""

def __init__(self, model_cls: Type[Model]):
def __init__(self, model_cls: type[Model]):
self._model_cls = model_cls
self._where: dict = {}
self._order_by: dict = {}
Expand Down Expand Up @@ -226,9 +223,7 @@ def order_by(self, *args: str) -> None:
raise InvalidOrderByFieldError(ordering_field)
if column_name is None:
raise InvalidOrderByFieldError(ordering_field)
order_by[column_name] = {
"direction": "desc" if ordering_field.startswith("-") else "asc"
}
order_by[column_name] = {"direction": "desc" if ordering_field.startswith("-") else "asc"}

def options(self, *args: str) -> None:
for option_field in args:
Expand All @@ -239,7 +234,7 @@ def options(self, *args: str) -> None:
if attr in relationships:
model_cls = getattr(model_cls, attr).property.mapper.class_
joins = joins.setdefault("children", {}).setdefault(attr, {})
joins['model_cls'] = model_cls
joins["model_cls"] = model_cls
relationships = get_relationships(model_cls)
else:
raise InvalidOptionFieldError(option_field)
Expand Down Expand Up @@ -277,7 +272,7 @@ def join(self, *args: str, isouter: bool) -> None:
if attr in relationships:
model_cls = getattr(model_cls, attr).property.mapper.class_
joins = joins.setdefault("children", {}).setdefault(attr, {})
joins['model_cls'] = model_cls
joins["model_cls"] = model_cls
relationships = get_relationships(model_cls)
else:
raise InvalidJoinFieldError(join_field)
Expand All @@ -300,10 +295,7 @@ def build_count_stmt(self) -> Select:
if self._options:
raise ValueError("Удалите options")
pk = get_pk(self._model_cls)
stmt = (
select(func.count(func.distinct(pk)))
.select_from(self._model_cls)
)
stmt = select(func.count(func.distinct(pk))).select_from(self._model_cls)
stmt = self._apply_joins(stmt)
stmt = self._apply_where(stmt)
return stmt
Expand Down Expand Up @@ -343,8 +335,7 @@ def _apply_distinct(self, stmt: Select) -> Select:
return stmt

def build_select_stmt(self) -> Select:
"""
Возвращает запрос на выборку
"""Возвращает запрос на выборку.

Лимитированные запросы с options приходится составлять при помощи подзапроса, чтобы
гарантировать правильность применений OFFSET и LIMIT, так как связные модели join-ятся
Expand Down Expand Up @@ -424,17 +415,17 @@ def _apply_limit(self, stmt: Select) -> Select:
def _apply_where(self, stmt, model_cls=None) -> Select:
model_cls = model_cls or self._model_cls
for attr, value in self._where.items():
op = value['op']
op = value["op"]
column = getattr(model_cls, attr)
stmt = stmt.where(op(column, value['value']))
stmt = stmt.where(op(column, value["value"]))
return stmt

def _apply_order_by(self, stmt: Select, model_cls=None):
model_cls = model_cls or self._model_cls
for attr, value in self._order_by.items():
direction = value['direction']
direction = value["direction"]
column = getattr(model_cls, attr) # напр., aliased(Section).name или Section.name
column = column.asc() if direction == 'asc' else column.desc()
column = column.asc() if direction == "asc" else column.desc()
stmt = stmt.order_by(column)
return stmt

Expand All @@ -444,9 +435,10 @@ def _apply_joins(
apply_where: bool = True,
apply_order_by: bool = True,
apply_options: bool = True,
parent_model_cls=None
parent_model_cls=None,
) -> Select:
"""
"""Метод для работы с join-ами.

как сейчас:

{
Expand Down Expand Up @@ -516,7 +508,7 @@ def _apply_joins_recursively(self, stmt, joins, where, order_by, parent_model_cl
for attr, value in joins.get("children", {}).items():
target = aliased(value["model_cls"])
onclause = getattr(parent_model_cls, attr)
attr_root = f"{root}__{attr}".strip("__")
attr_root = f"{root}{LOOKUP_SEP}{attr}".removesuffix(LOOKUP_SEP).removeprefix(LOOKUP_SEP)
tree[attr_root] = {"attr": onclause, "alias": target}
isouter = value.get("isouter", False)
stmt = stmt.join(target, onclause, isouter=isouter)
Expand All @@ -527,15 +519,9 @@ def _apply_joins_recursively(self, stmt, joins, where, order_by, parent_model_cl
for name, item in value.get("order_by", {}).items():
direction = item["direction"]
column = getattr(target, name)
order_by.append(column.asc() if direction == 'asc' else column.desc())
order_by.append(column.asc() if direction == "asc" else column.desc())
stmt = self._apply_joins_recursively(
stmt,
joins=value,
order_by=order_by,
where=where,
parent_model_cls=target,
tree=tree,
root=attr_root
stmt, joins=value, order_by=order_by, where=where, parent_model_cls=target, tree=tree, root=attr_root
)
return stmt

Expand Down
Loading
Loading