├── .flake8 ├── .github └── workflows │ ├── docker-test.yml │ └── python-publish.yml ├── .gitignore ├── .pylintrc ├── AUTHORS ├── LICENSE ├── Makefile ├── README.md ├── docs ├── benefits.md ├── db_validation.md ├── extension.md ├── filtering.md ├── index.md ├── ru │ ├── benefits.md │ ├── db_validation.md │ ├── extension.md │ ├── filtering.md │ ├── index.md │ ├── sorting.md │ ├── transactions.md │ ├── usage.md │ └── utils.md ├── sorting.md ├── transactions.md ├── usage.md └── utils.md ├── examples └── app │ ├── .env │ ├── __init__.py │ ├── api │ ├── __init__.py │ ├── api.py │ ├── deps.py │ └── endpoints │ │ ├── __init__.py │ │ └── child.py │ ├── config.py │ ├── db.py │ ├── main.py │ ├── managers.py │ ├── models.py │ └── schemas.py ├── fastapi_sqlalchemy_toolkit ├── __init__.py ├── filters.py ├── model_manager.py ├── ordering.py └── utils.py ├── mkdocs.yml ├── pyproject.toml ├── requirements ├── base.txt ├── docs.txt ├── lint.txt └── test.txt └── tests ├── Dockerfile ├── __init__.py ├── conftest.py ├── db.py ├── docker-compose.yml ├── models.py └── test_public_methods.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = .git,__pycache__,__init__.py,.mypy_cache,.pytest_cache -------------------------------------------------------------------------------- /.github/workflows/docker-test.yml: -------------------------------------------------------------------------------- 1 | name: fastapi-sqlalchemy-toolkit tests CI 2 | 3 | on: 4 | push: 5 | branches: ["**"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Start Compose 16 | run: docker compose -f tests/docker-compose.yml up -d 17 | - name: Build Image 18 | run: docker build -t test_toolkit:latest --file tests/Dockerfile . 19 | - name: Run Tests in Container 20 | run: docker run --name toolkit-test --network toolkit-test test_toolkit:latest 21 | - name: Remove Container 22 | run: docker rm toolkit-test 23 | - name: Stop Compose 24 | run: docker compose -f tests/docker-compose.yml down 25 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | deploy: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python 21 | uses: actions/setup-python@v3 22 | with: 23 | python-version: '3.x' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install build 28 | - name: Build package 29 | run: python -m build 30 | - name: Publish package 31 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 32 | with: 33 | user: __token__ 34 | password: ${{ secrets.PYPI_API_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | .venv/ 3 | .vscode/ 4 | dist/ 5 | fastapi_sqlalchemy_toolkit.egg-info/ 6 | .pypirc 7 | site/ -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=88 3 | 4 | [MESSAGES CONTROL] 5 | disable=missing-docstring,too-few-public-methods,no-name-in-module,no-member,too-many-ancestors 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Fastapi SQLAlchemy Toolkit was originally created in 2022 by Egor Kondrashov while working at ITMO University, Saint Petersburg, Russia 2 | 3 | A list of project contributors: 4 | 5 | Egor Kondrashov 6 | Natalia Kamysheva 7 | Buned Kholmatov 8 | Arina Negodina 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Kondrashov Egor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | docker compose -f tests/docker-compose.yml up -d 3 | docker build -t test_toolkit:latest --file tests/Dockerfile . 4 | - docker run --name toolkit-test --network toolkit-test test_toolkit:latest 5 | docker rm toolkit-test 6 | docker compose -f tests/docker-compose.yml down 7 | 8 | docs_publish: 9 | mkdocs build 10 | mkdocs gh-deploy -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI SQLAlchemy Toolkit 2 | 3 | --- 4 | 5 | **Документация**: [https://e-kondr01.github.io/fastapi-sqlalchemy-toolkit/ru/](https://e-kondr01.github.io/fastapi-sqlalchemy-toolkit/ru/) 6 | 7 | --- 8 | 9 | **FastAPI SQLAlchemy Toolkit** — это библиотека для стека `FastAPI` + Async `SQLAlchemy`, 10 | которая помогает решать следующие задачи: 11 | 12 | - cнижение количества шаблонного, копипастного кода, который возникает при разработке 13 | REST API и взаимодействии с СУБД через `SQLAlchemy`; 14 | 15 | - автоматическая валидация значений на уровне БД при создании и изменении объектов через API. 16 | 17 | ## Функционал 18 | 19 | - Методы для CRUD-операций с объектами в БД 20 | 21 | - Фильтрация с обработкой необязательных параметров запроса (см. раздел **Фильтрация**) 22 | 23 | - Декларативная сортировка с помощью `ordering_depends` (см. раздел **Сортировка**) 24 | 25 | - Валидация существования внешних ключей 26 | 27 | - Валидация уникальных ограничений 28 | 29 | - Упрощение CRUD-действий с M2M связями 30 | 31 | ## Установка 32 | 33 | ```bash 34 | pip install fastapi-sqlalchemy-toolkit 35 | ``` 36 | 37 | ## Демонстрация 38 | 39 | Пример использования `fastapi-sqlalchemy-toolkit` в FastAPI приложении: 40 | 41 | [https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit/tree/master/examples/app](https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit/tree/master/examples/app) 42 | -------------------------------------------------------------------------------- /docs/benefits.md: -------------------------------------------------------------------------------- 1 | An optional section demonstrating the reduction of boilerplate code when using `fastapi_sqlalchemy_toolkit`. 2 | 3 | If you need to add filters based on field values in a `FastAPI` endpoint, the code would look something like this: 4 | 5 | ```python 6 | from typing import Annotated 7 | from uuid import UUID 8 | 9 | from fastapi import APIRouter, Depends, Response, status 10 | from sqlalchemy import select 11 | from sqlalchemy.ext.asyncio import AsyncSession 12 | 13 | from app.deps import get_async_session 14 | from app.models import MyModel, MyParentModel 15 | from app.schemas import MyObjectListSchema 16 | 17 | router = APIRouter() 18 | Session = Annotated[AsyncSession, Depends(get_async_session)] 19 | 20 | 21 | @router.get("/my-objects") 22 | async def get_my_objects( 23 | session: Session, 24 | user_id: UUID | None = None, 25 | name: str | None = None, 26 | parent_name: str | None = None, 27 | ) -> list[MyObjectListSchema]: 28 | stmt = select(MyModel) 29 | if user_id is not None: 30 | stmt = stmt.filter_by(user_id=user_id) 31 | if name is not None: 32 | stmt = stmt.filter(MyModel.name.ilike == f"%{name}%") 33 | if parent_name is not None: 34 | stmt = stmt.join(MyModel.parent) 35 | stmt = stmt.filter(ParentModel.name.ilike == f"%{parent_name}%") 36 | result = await session.execute(stmt) 37 | return result.scalars().all() 38 | ``` 39 | As you can see, implementing filtering requires duplicating template code. 40 | 41 | With `fastapi-sqlalchemy-toolkit`, this endpoint looks like this: 42 | 43 | ```python 44 | from fastapi_sqlalchemy_toolkit import FieldFilter 45 | 46 | from app.managers import my_object_manager 47 | 48 | @router.get("/my-objects") 49 | async def get_my_objects( 50 | session: Session, 51 | user_id: UUID | None = None, 52 | name: str | None = None, 53 | parent_name: str | None = None, 54 | ) -> list[MyObjectListSchema]: 55 | return await my_object_manager.list( 56 | session, 57 | user_id=user_id, 58 | filter_expressions={ 59 | MyObject.name: name, 60 | MyObjectParent.name: parent_name 61 | } 62 | ) 63 | ``` -------------------------------------------------------------------------------- /docs/db_validation.md: -------------------------------------------------------------------------------- 1 | # Database-Level Validation 2 | 3 | The `create` and `update` methods perform validation on the passed values at the database level. Specifically: 4 | 5 | ## Foreign Keys Existence Validation 6 | 7 | If `Child.parent_id` is a `ForeignKey` referencing `Parent.id`, and the value of `parent_id` is set via `child_manager.create` or `child_manager.update`, an SQL query will be executed to check the existence of a `Parent` with the provided ID. 8 | 9 | If no such object exists, an `fastapi.HTTPException` will be raised. 10 | 11 | ## Many-to-Many Relationships Existence Validation 12 | 13 | If `Post.tags` represents a `ManyToMany` relationship with `Tag.id`, and the `tags` value is set via `post_manager.create` or `post_manager.update` as a list of IDs, an SQL query will be executed to verify the existence of all `Tag` objects with the provided IDs. 14 | 15 | If any of the objects do not exist, an `fastapi.HTTPException` will be raised. 16 | 17 | ## Unique Fields Validation 18 | 19 | If `Post.slug` is a field defined with `unique=True`, and the value of `slug` is set via `post_manager.create` or `post_manager.update`, an SQL query will be executed to check that no other `Post` object exists with the same `slug` value. 20 | 21 | If the uniqueness constraint is violated, an `fastapi.HTTPException` will be raised. 22 | 23 | ## Unique Constraints Validation 24 | 25 | If the model defines unique constraints using `sqlalchemy.UniqueConstraint`, then when using the `create` or `update` methods, an SQL query will be executed to verify that no other objects with the same combination of field values in the unique constraint exist. 26 | 27 | If the unique constraint is violated, an `fastapi.HTTPException` will be raised. -------------------------------------------------------------------------------- /docs/extension.md: -------------------------------------------------------------------------------- 1 | The `ModelManager` methods can be easily extended with additional logic. 2 | 3 | 4 | Firstly, you need to define your own `ModelManager` class: 5 | 6 | ```python 7 | from fastapi_sqlalchemy_toolkit import ModelManager 8 | 9 | 10 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager): 11 | ... 12 | ``` 13 | 14 | ### Additional Validation 15 | You can add additional validation by overriding the `validate` method: 16 | 17 | ```python 18 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager): 19 | async def validate_parent_type(self, session: AsyncSession, validated_data: ModelDict) -> None: 20 | """ 21 | Checks the type of the selected Parent object 22 | """ 23 | # The Parent object with this ID definitely exists since this is checked earlier in super().validate 24 | parent = await parent_manager.get(session, id=in_obj["parent_id"]) 25 | if parent.type != ParentTypes.CanHaveChildren: 26 | raise HTTPException( 27 | status_code=status.HTTP_400_BAD_REQUEST, 28 | detail="This parent has incompatible type", 29 | ) 30 | 31 | async def run_db_validation( 32 | self, 33 | session: AsyncSession, 34 | db_obj: MyModel | None = None, 35 | in_obj: ModelDict | None = None, 36 | ) -> ModelDict: 37 | validated_data = await super().validate(session, db_obj, in_obj) 38 | await self.validate_parent_type(session, validated_data) 39 | return validated_data 40 | ``` 41 | 42 | ### Additional business logic for CRUD operations 43 | If additional business logic needs to be executed during CRUD operations with the model, 44 | this can be done by overriding the corresponding `ModelManager` methods: 45 | 46 | ```python 47 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager): 48 | async def create( 49 | self, *args, background_tasks: BackgroundTasks | None = None, **kwargs 50 | ) -> MyModel: 51 | created = await super().create(*args, **kwargs) 52 | background_tasks.add_task(send_email, created.id) 53 | return created 54 | ``` 55 | 56 | ### Using declarative filters in non-standard list queries 57 | If you need to retrieve not just a list of objects but also other fields (e.g., the number of child objects) 58 | or aggregations, and you also need declarative filtering, you can create a new manager method, 59 | calling the `super().get_filter_expression` method within it: 60 | ```python 61 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel): 62 | async def get_parents_with_children_count( 63 | self, session: AsyncSession, **kwargs 64 | ) -> list[RetrieveParentWithChildrenCountSchema]: 65 | children_count_query = ( 66 | select(func.count(Child.id)) 67 | .filter(Child.parent_id == Parent.id) 68 | .scalar_subquery() 69 | ) 70 | query = ( 71 | select(Parent, children_count_query.label("children_count")) 72 | ) 73 | 74 | # Calling the method to get SQLAlchemy filters from method arguments 75 | # list и paginated_list 76 | query = query.filter(self.get_filter_expression(**kwargs)) 77 | 78 | result = await session.execute(query) 79 | result = result.unique().all() 80 | for i, row in enumerate(result): 81 | row.Parent.children_count = row.children_count 82 | result[i] = row.Parent 83 | return result 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/filtering.md: -------------------------------------------------------------------------------- 1 | To retrieve a list of objects with filtering, `fastapi_sqlalchemy_toolkit` provides two methods: 2 | `list`, which preprocesses values, and `filter`, which does not perform additional processing. 3 | Similarly, `paginated_list` and `paginated_filter` behave the same, except they paginate the result using `fastapi_pagination`. 4 | 5 | Let's assume the following models: 6 | 7 | ```python 8 | class Base(DeclarativeBase): 9 | id: Mapped[_py_uuid] = mapped_column( 10 | UUID(as_uuid=True), primary_key=True, default=uuid4 11 | ) 12 | created_at: Mapped[datetime] = mapped_column( 13 | DateTime(timezone=True), server_default=func.now() 14 | ) 15 | 16 | 17 | class Parent(Base): 18 | title: Mapped[str] 19 | slug: Mapped[str] = mapped_column(unique=True) 20 | children: Mapped[list["Child"]] = relationship(back_populates="parent") 21 | 22 | 23 | class Child(Base): 24 | title: Mapped[str] 25 | slug: Mapped[str] = mapped_column(unique=True) 26 | parent_id: Mapped[UUID] = mapped_column(ForeignKey("parent.id", ondelete="CASCADE")) 27 | parent: Mapped[Parent] = relationship(back_populates="children") 28 | ``` 29 | 30 | And manager: 31 | 32 | ```python 33 | from fastapi_sqlalchemy_toolkit import ModelManager 34 | 35 | child_manager = ModelManager[Child, CreateChildSchema, PatchChildSchema]( 36 | Child, default_ordering=Child.title 37 | ) 38 | ``` 39 | 40 | ### Simple exact matching filter 41 | 42 | ```python 43 | @router.get("/children") 44 | async def get_list( 45 | session: Session, 46 | slug: str | None = None, 47 | ) -> list[ChildListSchema]: 48 | return await child_manager.list( 49 | session, 50 | slug=slug, 51 | ) 52 | ``` 53 | 54 | `GET /children` request will generate the following SQL: 55 | 56 | ```SQL 57 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 58 | FROM child 59 | ``` 60 | 61 | `GET /children?slug=child-1` request will generate the following SQL: 62 | 63 | ```SQL 64 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 65 | FROM child 66 | WHERE child.slug = :slug_1 67 | ``` 68 | 69 | Following the `FastAPI` convention, optional query parameters are typed as `slug: str | None = None`. In this case, API clients typically expect that a request to `GET /children` will return all `Child` objects, not just those with a `null` `slug`. Therefore, the `list` (`paginated_list`) method discards filtering on this parameter if its value is not provided. 70 | 71 | ### More complex filtering 72 | 73 | To use filtering not only for exact attribute matching but also for more complex scenarios, you can pass the `filter_expressions` parameter to the `list` and `paginated_list` methods. 74 | 75 | The `filter_expressions` parameter takes a dictionary where keys can be: 76 | 77 | 1. Attributes of the main model (`Child.title`) 78 | 79 | 2. Model attribute operators (`Child.title.ilike`) 80 | 81 | 3. `sqlalchemy` functions on model attributes (`func.date(Child.created_at)`) 82 | 83 | 4. Attributes of the related model (`Parent.title`). It works if the model is directly related to the main model and if the models are linked by only one foreign key. 84 | 85 | The value associated with a key in the `filter_expressions` dictionary is the value for which the filtering should occur. 86 | 87 | An example of filtering using an **operator** on a model attribute: 88 | 89 | ```python 90 | @router.get("/children") 91 | async def get_list( 92 | session: Session, 93 | title: str | None = None, 94 | ) -> list[ChildListSchema]: 95 | return await child_manager.list( 96 | session, 97 | filter_expressions={ 98 | Child.title.ilike: title 99 | }, 100 | ) 101 | ``` 102 | 103 | `GET /children` request will generate the following SQL: 104 | 105 | ```SQL 106 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 107 | FROM child 108 | ``` 109 | 110 | `GET /children?title=ch` request will generate the following SQL: 111 | 112 | ```SQL 113 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 114 | FROM child 115 | WHERE lower(child.title) LIKE lower(:title_1) 116 | ``` 117 | 118 | Filtering example using **`sqlalchemy` function** on model attribute: 119 | 120 | ```python 121 | @router.get("/children") 122 | async def get_list( 123 | session: Session, 124 | created_at_date: date | None = None, 125 | ) -> list[ChildListSchema]: 126 | return await child_manager.list( 127 | session, 128 | filter_expressions={ 129 | func.date(Child.created_at): created_at_date 130 | }, 131 | ) 132 | ``` 133 | 134 | `GET /children?created_at_date=2023-11-19` request will generate the following SQL: 135 | 136 | ```SQL 137 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 138 | FROM child 139 | WHERE date(child.created_at) = :date_1 140 | ``` 141 | 142 | Filtering example on related model attribute: 143 | 144 | ```python 145 | @router.get("/children") 146 | async def get_list( 147 | session: Session, 148 | parent_title: str | None = None, 149 | ) -> list[ChildListSchema]: 150 | return await child_manager.list( 151 | session, 152 | filter_expressions={ 153 | Parent.title.ilike: title 154 | }, 155 | ) 156 | ``` 157 | 158 | `GET /children?parent_title=ch` request will generate the following SQL: 159 | 160 | ```SQL 161 | SELECT parent.title, parent.slug, parent.id, parent.created_at, 162 | child.title AS title_1, child.slug AS slug_1, child.parent_id, child.id AS id_1, 163 | child.created_at AS created_at_1 164 | FROM child LEFT OUTER JOIN parent ON parent.id = child.parent_id 165 | WHERE lower(parent.title) LIKE lower(:title_1) 166 | ``` 167 | 168 | When filtering by fields of related models using the `filter_expression` parameter, 169 | the necessary `join` for filtering will be automatically performed. 170 | **Important**: It only works for models directly related to the main model and only when 171 | these models are linked by a single foreign key. 172 | 173 | ### Filtering without additional processing 174 | 175 | For filtering without additional processing in the list and `paginated_list` methods, 176 | you can use the `where` parameter. The value of this parameter will be directly 177 | passed to the `.where()` method of the `Select` instance in the SQLAlchemy query expression. 178 | 179 | ```python 180 | non_archived_items = await item_manager.list(session, where=(Item.archived_at == None)) 181 | ``` 182 | 183 | Using the `where` parameter in the `list` and `paginated_list` methods makes sense when 184 | these methods are used in a list API endpoint and preprocessing of some query parameters 185 | is useful, but you also need to add a filter without preprocessing from `fastapi_sqlalchemy_toolkit`. 186 | 187 | In cases where `fastapi_sqlalchemy_toolkit` preprocessing is not needed at all, 188 | you should use the `filter` and `paginated_filter` methods: 189 | 190 | ```python 191 | created_at = None 192 | 193 | items = await item_manager.filter(session, created_at=created_at) 194 | ``` 195 | 196 | ```SQL 197 | SELECT item.id, item.name, item.created_at 198 | FROM item 199 | WHERE itme.created is null 200 | ``` 201 | 202 | Unlike the `list` method, the `filter` method: 203 | 204 | 1. Does not ignore simple filters (`kwargs`) with a `None` value 205 | 206 | 2. Does not have the `filter_expressions` parameter, i.e., it will not perform `join`, 207 | necessary for filtering by fields of related models. 208 | 209 | ### Filtering by `null` via API 210 | 211 | If in a list API endpoint, you need to be able to filter the field value 212 | by the passed value and also filter it by `null`, it is recommended to use the 213 | `nullable_filter_expressions` parameter of the `list` (`paginated_list`) methods: 214 | 215 | ```python 216 | from datetime import datetime 217 | 218 | from fastapi_sqlalchemy_toolkit import NullableQuery 219 | 220 | from app.managers import my_object_manager 221 | from app.models import MyObject 222 | 223 | @router.get("/my-objects") 224 | async def get_my_objects( 225 | session: Session, 226 | deleted_at: datetime | NullableQuery | None = None 227 | ) -> list[MyObjectListSchema]: 228 | return await my_object_manager.list( 229 | session, 230 | nullable_filter_expressions={ 231 | MyObject.deleted_at: deleted_at 232 | } 233 | ) 234 | ``` 235 | 236 | For the parameter with `null` filtering support, you need to specify the possible type 237 | `fastapi_sqlalchemy_toolkit.NullableQuery`. 238 | 239 | Now, when requesting `GET /my-objects?deleted_at=` or `GET /my-objects?deleted_at=null`, 240 | objects of `MyObject` with `deleted_at IS NULL` will be returned. 241 | 242 | ### Filtering by reverse relationships 243 | Also, there is support for filtering by reverse relationships 244 | (`relationship()` in the one-to-many direction) using the `.any()` method. 245 | 246 | ```python 247 | # If ParentModel.children is a one-to-many relationship 248 | await parent_manager.list(session, children=[1, 2]) 249 | # Returns Parent objects that have a relationship with ChildModel with ids 1 or 2 250 | ``` 251 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # FastAPI SQLAlchemy Toolkit 2 | 3 | **FastAPI SQLAlchemy Toolkit** — a library for the `FastAPI` + Async `SQLAlchemy` stack that helps solve the following tasks: 4 | 5 | - reducing the amount of templated, copy-pasted code that arises when developing REST APIs and interacting with databases through `SQLAlchemy`; 6 | 7 | - automatic validation of values at the database level when creating and modifying objects through the API. 8 | 9 | To achieve this, `FastAPI SQLAlachemy Toolkit` provides the `fastapi_sqlalchemy_toolkit.ModelManager` manager class for interacting with the `SQLAlchemy`. 10 | 11 | ## Features 12 | 13 | - Methods for CRUD operations with objects in the database 14 | 15 | - Filtering with optional query parameters handling (see the [Filtering](./filtering.md) section) 16 | 17 | Declarative sorting using `ordering_dep` (see the [Sorting](./sorting.md) section) 18 | 19 | - Validation of foreign key existence 20 | 21 | - Validation of unique constraints 22 | 23 | - Simplification of CRUD actions with M2M relationships 24 | 25 | ## Installation 26 | 27 | ```bash 28 | pip install fastapi-sqlalchemy-toolkit 29 | ``` 30 | 31 | ## Demonstration 32 | Example of `fastapi-sqlalchemy-toolkit` usage in FastAPI app: 33 | 34 | [https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit/tree/master/examples/app](https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit/tree/master/examples/app) 35 | 36 | ## Read More 37 | - [Usage](./usage.md) 38 | - [Filtering](./filtering.md) 39 | - [Sorting](./sorting.md) 40 | - [Transactions](./transactions.md) 41 | - [Database-Level Validation](./db_validation.md) 42 | - [Extension](./extension.md) 43 | - [Other utilities](./utils.md) 44 | -------------------------------------------------------------------------------- /docs/ru/benefits.md: -------------------------------------------------------------------------------- 1 | # Предпосылки 2 | Необязательный раздел с примером сокращения количества шаблонного кода при использовании `fastapi_sqlalchemy_toolkit`. 3 | 4 | Если в эндпоинт `FastAPI` с использованием `SQLAlchemy` 5 | нужно добавить фильтры по значениям полей при получении списка, 6 | то код будет выглядеть примерно так: 7 | 8 | ```python 9 | from uuid import UUID 10 | 11 | from fastapi_pagination import Page 12 | from fastapi_pagination.ext.sqlalchemy import paginate 13 | from sqlalchemy import select 14 | 15 | from .deps import Session 16 | from .models import MyModel, ParentModel 17 | from .schemas import MyObjectListSchema 18 | 19 | 20 | @router.get("/my-objects") 21 | async def get_my_objects( 22 | session: Session, 23 | user_id: UUID | None = None, 24 | name: str | None = None, 25 | parent_name: str | None = None, 26 | ) -> Page[MyObjectListSchema]: 27 | stmt = select(MyModel) 28 | if user_id is not None: 29 | stmt = stmt.filter_by(user_id=user_id) 30 | if name is not None: 31 | stmt = stmt.filter(MyModel.name.ilike == f"%{name}%") 32 | if parent_name is not None: 33 | stmt = stmt.join(MyModel.parent) 34 | stmt = stmt.filter(ParentModel.name.ilike == f"%{parent_name}%") 35 | return await paginate(session, stmt) 36 | ``` 37 | 38 | В `fastapi-sqlalchemy-toolkit` этот эндпоинт выглядит так: 39 | 40 | ```python 41 | from app.managers import my_object_manager 42 | 43 | @router.get("/my-objects") 44 | async def get_my_objects( 45 | session: Session, 46 | user_id: UUID | None = None, 47 | name: str | None = None, 48 | parent_name: str | None = None, 49 | ) -> Page[MyObjectListSchema]: 50 | return await my_object_manager.paginated_list( 51 | session, 52 | user_id=user_id, 53 | filter_expressions={ 54 | MyObject.name: name, 55 | MyObjectParent.name: parent_name 56 | } 57 | ) 58 | ``` 59 | 60 | Теперь рассмотрим создание объекта, который имеет FK и уникальное поле. Без `fastapi-sqlalchemy-toolkit`: 61 | 62 | ```python 63 | @router.post("/my-objects") 64 | async def create_my_object( 65 | session: Session, in_obj: MyObjectCreateSchema 66 | ) -> MyObjectListSchema: 67 | if in_obj.parent_id: 68 | parent_exists = ( 69 | await session.execute(select(ParentModel.id).filter_by(id=in_obj.parent_id)) 70 | ).first() is not None 71 | if not parent_exists: 72 | raise HTTPException( 73 | status.HTTP_400_BAD_REQUEST, 74 | detail=f"Parent with id {in_obj.parent_id} does not exist", 75 | ) 76 | 77 | slug_exists = ( 78 | await session.execute(select(MyModel.id).filter_by(slug=in_obj.slug)) 79 | ).first() is not None 80 | if slug_exists: 81 | raise HTTPException( 82 | status.HTTP_400_BAD_REQUEST, 83 | detail=f"MyModel with slug {in_obj.slug} already exists", 84 | ) 85 | 86 | db_obj = MyModel(**in_obj.model_dump()) 87 | session.add(db_obj) 88 | await session.commit() 89 | await session.refresh(db_obj) 90 | return db_obj 91 | ``` 92 | 93 | С использованием `fastapi-sqlalchemy-toolkit`: 94 | 95 | ```python 96 | @router.post("/my-objects") 97 | async def create_my_object( 98 | session: Session, in_obj: MyObjectCreateSchema 99 | ) -> MyObjectListSchema: 100 | return await my_object_manager.create(session, in_obj=in_obj) 101 | ``` 102 | 103 | В обоих случаях, использование `fastapi-sqlalchemy-toolkit` значительно сокращает 104 | код приложения за счёт внутренней реализации в методах `ModelManager` стандартной логики, 105 | необходимой при создании REST API. 106 | -------------------------------------------------------------------------------- /docs/ru/db_validation.md: -------------------------------------------------------------------------------- 1 | # Валидация на уровне базы данных 2 | 3 | Методы `create` и `update` выполняют валидацию переданных значений на уровне БД, а именно: 4 | 5 | ## Валидация существования внешних ключей 6 | 7 | Ecли `Child.parent_id` -- это `ForeignKey` на `Parent.id`, и значение `parent_id` устанавливается 8 | через `child_manager.create` или `child_manager.update`, то будет выполнен SQL запрос для проверки 9 | существования `Parent` с переданным id. 10 | 11 | Если такого объекта не существует, будет вызван `fastapi.HTTPException`. 12 | 13 | ## Валидация существования M2M связей 14 | 15 | Ecли `Post.tags` -- это `ManyToMany` связь с `Tag.id`, и значение `tags` устанавливается 16 | через `post_manager.create` или `post_manager.update` в виде переданного списка id, 17 | то будет выполнен SQL запрос для проверки существования всех `Tag` с переданными id. 18 | 19 | Если какого-либо из объектов не существует, будет вызван `fastapi.HTTPException`. 20 | 21 | ## Валидация уникальных полей 22 | 23 | Ecли `Post.slug` -- поле, определённое с `unique=True`, и значение `slug` устанавливается 24 | через `post_manager.create` или `post_manager.update`, то будет выполнен SQL запрос для 25 | проверки того, что не существует другого объекта `Post` с передаваемым значением `slug`. 26 | 27 | Если уникальность значения поля будет нарушена, вызывается `fastapi.HTTPException`. 28 | 29 | ## Валидация уникальных ограничений 30 | 31 | Если в модели определены уникальные ограничения через `sqlalchemy.UniqueConstraint`, то 32 | при использовании методов `create` или `update` будет выполнен SQL запрос для проверки 33 | существования других объектов с таким же набором значений полей, входящих в уникальное огранчение. 34 | 35 | Если уникальное ограничение будет нарушено, вызывается `fastapi.HTTPException`. -------------------------------------------------------------------------------- /docs/ru/extension.md: -------------------------------------------------------------------------------- 1 | # Расширение 2 | Методы `ModelManager` легко расширить дополнительной логикой. 3 | 4 | 5 | В первую очередь необходимо определить свой класс ModelManager: 6 | 7 | ```python 8 | from fastapi_sqlalchemy_toolkit import ModelManager 9 | 10 | 11 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager): 12 | ... 13 | ``` 14 | ## Дополнительная валидация 15 | Дополнительную валидацию можно добавить, переопределив метод `run_db_valiation`: 16 | 17 | ```python 18 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager): 19 | async def validate_parent_type(self, session: AsyncSession, validated_data: ModelDict) -> None: 20 | """ 21 | Проверяет тип выбранного объекта Parent 22 | """ 23 | # объект Parent с таким ID точно есть, так как это проверяется ранее в super().validate 24 | parent = await parent_manager.get(session, id=in_obj["parent_id"]) 25 | if parent.type != ParentTypes.CanHaveChildren: 26 | raise HTTPException( 27 | status_code=status.HTTP_400_BAD_REQUEST, 28 | detail="This parent has incompatible type", 29 | ) 30 | 31 | async def run_db_validation( 32 | self, 33 | session: AsyncSession, 34 | db_obj: MyModel | None = None, 35 | in_obj: ModelDict | None = None, 36 | ) -> ModelDict: 37 | validated_data = await super().validate(session, db_obj, in_obj) 38 | await self.validate_parent_type(session, validated_data) 39 | return validated_data 40 | ``` 41 | 42 | ## Дополнительная бизнес логика при CRUD операциях 43 | Если при CRUD операциях с моделью необходимо выполнить какую-то дополнительную бизнес логику, 44 | это можно сделать, переопределив соответствующие методы ModelManager: 45 | 46 | ```python 47 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager): 48 | async def create( 49 | self, *args, background_tasks: BackgroundTasks | None = None, **kwargs 50 | ) -> MyModel: 51 | created = await super().create(*args, **kwargs) 52 | background_tasks.add_task(send_email, created.id) 53 | return created 54 | ``` 55 | 56 | ## Использование декларативных фильтров в нестандартных списочных запросах 57 | Если необходимо получить не просто список объектов, но и какие-то другие поля (допустим, кол-во дочерних объектов) 58 | или агрегации, но также необходима декларативная фильтрация, то можно определить метод менеджера, 59 | вызвав в нём метод `assemble_stmt`: 60 | ```python 61 | class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel): 62 | async def get_parents_with_children_count( 63 | self, session: AsyncSession, **kwargs 64 | ) -> list[RetrieveParentWithChildrenCountSchema]: 65 | children_count_stmt = ( 66 | select(func.count(Child.id)) 67 | .filter(Child.parent_id == Parent.id) 68 | .scalar_subquery() 69 | ) 70 | stmt = ( 71 | select(Parent, children_count_query.label("children_count")) 72 | ) 73 | 74 | # Вызываем метод для получения фильтров SQLAlchemy из аргументов методов 75 | # list и paginated_list 76 | stmt = self.assemble_stmt(stmt, **kwargs) 77 | 78 | result = await session.execute(query) 79 | result = result.unique().all() 80 | for i, row in enumerate(result): 81 | row.Parent.children_count = row.children_count 82 | result[i] = row.Parent 83 | return result 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/ru/filtering.md: -------------------------------------------------------------------------------- 1 | # Фильтрация 2 | 3 | Для получения списка объектов с фильтрацией `fastapi_sqlalchemy_toolkit` предоставляет два метода: 4 | `list`, который осуществляет предобработку значений, и `filter`, который не производит дополнительных обработок. 5 | Аналогично ведут себя методы `paginated_list` и `paginated_filter`, за исключением того, что они пагинирует результат 6 | с помощью `fastapi_pagination`. 7 | 8 | Пусть имеются следующие модели: 9 | 10 | ```python 11 | class Base(DeclarativeBase): 12 | id: Mapped[UUID] = mapped_column( 13 | primary_key=True, 14 | default=uuid4, 15 | server_default=func.gen_random_uuid(), 16 | ) 17 | 18 | created_at: Mapped[datetime] = mapped_column( 19 | DateTime(timezone=True), server_default=func.now() 20 | ) 21 | 22 | 23 | class Parent(Base): 24 | title: Mapped[str] 25 | slug: Mapped[str] = mapped_column(unique=True) 26 | children: Mapped[list["Child"]] = relationship(back_populates="parent") 27 | 28 | 29 | class Child(Base): 30 | title: Mapped[str] 31 | slug: Mapped[str] = mapped_column(unique=True) 32 | parent_id: Mapped[UUID] = mapped_column(ForeignKey("parent.id")) 33 | parent: Mapped[Parent] = relationship(back_populates="children") 34 | ``` 35 | 36 | И менеджер: 37 | 38 | ```python 39 | from fastapi_sqlalchemy_toolkit import ModelManager 40 | 41 | child_manager = ModelManager[Child, CreateChildSchema, PatchChildSchema]( 42 | Child, default_ordering=Child.title 43 | ) 44 | ``` 45 | 46 | ## Простая фильтрация по точному соответствию 47 | 48 | ```python 49 | @router.get("/children") 50 | async def get_list( 51 | session: Session, 52 | slug: str | None = None, 53 | ) -> list[ChildListSchema]: 54 | return await child_manager.list( 55 | session, 56 | slug=slug, 57 | ) 58 | ``` 59 | 60 | Запрос `GET /children` сгенерирует следующий SQL: 61 | 62 | ```SQL 63 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 64 | FROM child 65 | ``` 66 | 67 | Запрос `GET /children?slug=child-1` сгенерирует следующий SQL: 68 | 69 | ```SQL 70 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 71 | FROM child 72 | WHERE child.slug = :slug_1 73 | ``` 74 | 75 | По конвенции `FastAPI`, необязательные параметры запроса типизируются как `slug: str | None = None`. 76 | При этом клиенты API обычно ожидают, что при запросе `GET /children` будут возвращены все объекты `Child`, 77 | а не только те, у которых `slug is null`. Поэтому метод `list` (`paginated_list`) отбрасывает фильтрацию 78 | по этому параметру, если его значение не передано. 79 | 80 | ## Более сложная фильтрация 81 | 82 | Чтобы использовать фильтрацию не только по точному соответствию атрибуту модели, 83 | в методах `list` и `paginated_list` можно передать параметр `filter_expressions`. 84 | 85 | Параметр `filter_expressions` принимает словарь, в котором ключи могут быть: 86 | 87 | 1. Атрибутами основной модели (`Child.title`) 88 | 89 | 2. Операторами атрибутов модели (`Child.title.ilike`) 90 | 91 | 3. Функциями `sqlalchemy` над атрибутами модели (`func.date(Child.created_at)`) 92 | 93 | 4. Атрибутами связанной модели (`Parent.title`). Работает в том случае, если 94 | это модель, напрямую связанная с основной, а также если модели связывает только один внешний ключ. 95 | 96 | Значение по ключу в словаре `filter_expressions` -- это значение, 97 | по которому должна осуществляться фильтрация. 98 | 99 | Пример фильтрации по **оператору** атрибута модели: 100 | 101 | ```python 102 | @router.get("/children") 103 | async def get_list( 104 | session: Session, 105 | title: str | None = None, 106 | ) -> list[ChildListSchema]: 107 | return await child_manager.list( 108 | session, 109 | filter_expressions={ 110 | Child.title.ilike: title 111 | }, 112 | ) 113 | ``` 114 | 115 | Запрос `GET /children` сгенерирует следующий SQL: 116 | 117 | ```SQL 118 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 119 | FROM child 120 | ``` 121 | 122 | Запрос `GET /children?title=ch` сгенерирует следующий SQL: 123 | 124 | ```SQL 125 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 126 | FROM child 127 | WHERE lower(child.title) LIKE lower(:title_1) 128 | ``` 129 | 130 | Пример фильтрации по **функции `sqlalchemy`** над атрибутом модели: 131 | 132 | ```python 133 | @router.get("/children") 134 | async def get_list( 135 | session: Session, 136 | created_at_date: date | None = None, 137 | ) -> list[ChildListSchema]: 138 | return await child_manager.list( 139 | session, 140 | filter_expressions={ 141 | func.date(Child.created_at): created_at_date 142 | }, 143 | ) 144 | ``` 145 | 146 | Запрос `GET /children?created_at_date=2023-11-19` сгенерирует следующий SQL: 147 | 148 | ```SQL 149 | SELECT child.title, child.slug, child.parent_id, child.id, child.created_at 150 | FROM child 151 | WHERE date(child.created_at) = :date_1 152 | ``` 153 | 154 | Пример фильтрации по атрибуту связанной модели: 155 | 156 | ```python 157 | @router.get("/children") 158 | async def get_list( 159 | session: Session, 160 | parent_title: str | None = None, 161 | ) -> list[ChildListSchema]: 162 | return await child_manager.list( 163 | session, 164 | filter_expressions={ 165 | Parent.title.ilike: title 166 | }, 167 | ) 168 | ``` 169 | 170 | Запрос `GET /children?parent_title=ch` сгенерирует следующий SQL: 171 | 172 | ```SQL 173 | SELECT parent.title, parent.slug, parent.id, parent.created_at, 174 | child.title AS title_1, child.slug AS slug_1, child.parent_id, child.id AS id_1, 175 | child.created_at AS created_at_1 176 | FROM child LEFT OUTER JOIN parent ON parent.id = child.parent_id 177 | WHERE lower(parent.title) LIKE lower(:title_1) 178 | ``` 179 | 180 | При фильтрации по полям связанных моделей через параметр `filter_expression`, 181 | необходимые для фильтрации `join` будут сделаны автоматически. 182 | **Важно**: работает только для моделей, напрямую связанных с основной, и только тогда, когда 183 | эти модели связывает единственный внешний ключ. 184 | 185 | ## Фильтрация без дополнительной обработки 186 | 187 | Для фильтрации без дополнительной обработки в методах `list` и `paginated_list` можно 188 | использовать параметр `where`. Значение этого параметра будет напрямую 189 | передано в метод `.where()` экземпляра `Select` в выражении запроса `SQLAlchemy`. 190 | 191 | ```python 192 | non_archived_items = await item_manager.list(session, where=(Item.archived_at == None)) 193 | ``` 194 | 195 | Использовать параметр `where` методов `list` и `paginated_list` имеет смысл тогда, 196 | когда эти методы используются в списочном API эндпоинте и предобработка части параметров 197 | запроса полезна, однако нужно также добавить фильтр без предобработок от `fastapi_sqlalchemy_toolkit`. 198 | 199 | В том случае, когда предобработки `fastapi_sqlalchemy_toolkit` не нужны вообще, стоит использовать методы 200 | `filter` и `paginated_filter`: 201 | 202 | ```python 203 | created_at = None 204 | 205 | items = await item_manager.filter(session, created_at=created_at) 206 | ``` 207 | 208 | ```SQL 209 | SELECT item.id, item.name, item.created_at 210 | FROM item 211 | WHERE itme.created is null 212 | ``` 213 | 214 | В отличие от метода `list`, метод `filter`: 215 | 216 | 1. Не игнорирует простые фильтры (`kwargs`) со значением `None` 217 | 218 | 2. Не имеет параметра `filter_expressions`, т. е. не будет выполнять `join`, 219 | необходимые для фильтрации по полям связанных моделей. 220 | 221 | ## Фильтрация по `null` через API 222 | 223 | Если в списочном эндпоинте API требуется, чтобы можно было как отфильтровать значение поля 224 | по переданному значению, так и отфильтровать его по `null`, предлагается использовать параметр 225 | `nullable_filter_expressions` методов `list` (`paginated_list`): 226 | 227 | ```python 228 | from datetime import datetime 229 | 230 | from fastapi_sqlalchemy_toolkit import NullableQuery 231 | 232 | from app.managers import my_object_manager 233 | from app.models import MyObject 234 | 235 | @router.get("/my-objects") 236 | async def get_my_objects( 237 | session: Session, 238 | deleted_at: datetime | NullableQuery | None = None 239 | ) -> list[MyObjectListSchema]: 240 | return await my_object_manager.list( 241 | session, 242 | nullable_filter_expressions={ 243 | MyObject.deleted_at: deleted_at 244 | } 245 | ) 246 | ``` 247 | 248 | Параметру с поддержкой фильтрации по `null` нужно указать возможный тип 249 | `fastapi_sqlalchemy_toolkit.NullableQuery`. 250 | 251 | Теперь при запросе `GET /my-objects?deleted_at=` или `GET /my-objects?deleted_at=null` 252 | вернутся объекты `MyObject`, у которых `deleted_at IS NULL`. 253 | 254 | ## Фильтрация по обратным связям 255 | Также в методах получения списков есть поддержка фильтрации 256 | по обратным связям (`relationship()` в направлении один ко многим) с использованием метода `.any()`. 257 | 258 | ```python 259 | # Если ParentModel.children -- это связь один ко многим 260 | await parent_manager.list(session, children=[1, 2]) 261 | # Вернёт объекты Parent, у которых есть связь с ChildModel с id 1 или 2 262 | ``` 263 | -------------------------------------------------------------------------------- /docs/ru/index.md: -------------------------------------------------------------------------------- 1 | # FastAPI SQLAlchemy Toolkit 2 | 3 | **FastAPI SQLAlchemy Toolkit** — это библиотека для стека `FastAPI` + Async `SQLAlchemy`, 4 | которая помогает решать следующие задачи: 5 | 6 | - cнижение количества шаблонного, копипастного кода, который возникает при разработке 7 | REST API и взаимодействии с СУБД через `SQLAlchemy`; 8 | 9 | - автоматическая валидация значений на уровне БД при создании и изменении объектов через API. 10 | 11 | Для этого `FastAPI SQLAlachemy Toolkit` предоставляет класс менеджера `fastapi_sqlalchemy_toolkit.ModelManager` 12 | для взаимодействия с моделью `SQLAlchemy`. 13 | 14 | ## Функционал 15 | 16 | - Методы для CRUD-операций с объектами в БД 17 | 18 | - [Фильтрация](./filtering.md) с обработкой необязательных параметров запроса 19 | 20 | - Декларативная [сортировка](./sorting.md) с помощью `ordering_depends` 21 | 22 | - Валидация существования внешних ключей 23 | 24 | - Валидация уникальных ограничений 25 | 26 | - Упрощение CRUD-действий с M2M связями 27 | 28 | ## Установка 29 | 30 | ```bash 31 | pip install fastapi-sqlalchemy-toolkit 32 | ``` 33 | 34 | ## Демонстрация 35 | 36 | Пример использования `fastapi-sqlalchemy-toolkit` в FastAPI приложении: 37 | 38 | [https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit/tree/master/examples/app](https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit/tree/master/examples/app) 39 | 40 | ## Далее 41 | - [Использование](./usage.md) 42 | - [Фильтрация](./filtering.md) 43 | - [Сортировка](./sorting.md) 44 | - [Транзакции](./transactions.md) 45 | - [Валидация на уровне БД](./db_validation.md) 46 | - [Расширения](./extension.md) 47 | - [Утилиты](./utils.md) 48 | -------------------------------------------------------------------------------- /docs/ru/sorting.md: -------------------------------------------------------------------------------- 1 | # Сортировка 2 | 3 | `fastapi-sqlalchemy-toolkit` поддеживает декларативную сортировку по полям модели, 4 | а также по полям связанных моделей (если это модель, напрямую связанная с основной, 5 | а также эти модели связывает единственный внешний ключ). При этом необходимые для сортировки по полям 6 | связанных моделей join'ы будут сделаны автоматически. 7 | 8 | Для применения декларативной сортировки нужно: 9 | 10 | ## Определить поля, по которым доступна фильтрация 11 | 12 | Это может быть: 13 | 14 | - Cписок или кортеж полей основной модели: 15 | 16 | ```python 17 | from app.models import Child 18 | 19 | child_ordering_fields = ( 20 | Child.title, 21 | Child.created_at 22 | ) 23 | ``` 24 | 25 | В таком случае, будут доступны следующий параметря для сортировки: 26 | `title`, `-title`, `created_at`, `-created_at`. 27 | 28 | Дефис первым символом означает направление сортировки по убыванию. 29 | 30 | - Маппинг строковых полей для сортировки на соответствующие поля моделей: 31 | 32 | ```python 33 | from app.models import Child, Parent 34 | 35 | child_ordering_fields = { 36 | "title": MyModel.title, 37 | "parent_title": ParentModel.title 38 | } 39 | ``` 40 | 41 | В таком случае, будут доступны следующий параметря для сортировки: 42 | `title`, `-title`, `parent_title`, `-parent_title`. 43 | 44 | ## В параметрах энпдоинта передать определённый выше список 45 | в `ordering_depends` 46 | 47 | ```python 48 | from fastapi_sqlalchemy_toolkit import ordering_depends 49 | 50 | @router.get("/children") 51 | async def get_child_objects( 52 | session: Session, 53 | order_by: ordering_depends(child_ordering_fields) 54 | ) -> list[ChildListSchema] 55 | ... 56 | ``` 57 | 58 | ## Передать параметр сортировки как параметр `order_by` в методы `ModelManager` 59 | 60 | ```python 61 | return await child_manager.list(session=session, order_by=order_by) 62 | ``` 63 | 64 | Если `order_by` передаётся в методы `list` или `paginated_list`, 65 | и поле для сортировки относится к модели, напрямую связанную с основной, 66 | то будет выполнен необходимый `join` для применения сортировки. 67 | 68 | -------------------------------------------------------------------------------- /docs/ru/transactions.md: -------------------------------------------------------------------------------- 1 | # Транзакции 2 | 3 | `fastapi-sqlalchemy-toolkit` поддерживает оба подхода к работе с транзакциями `SQAlchemy`. 4 | 5 | ## Commit as you go 6 | 7 | [Документация Commit as you go SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#commit-as-you-go) 8 | 9 | ```python 10 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 11 | from app.managers import my_model_manager 12 | 13 | ... 14 | 15 | engine = create_async_engine( 16 | "...", 17 | ) 18 | async with async_sessionmaker(engine) as session: 19 | # Вызов метода сделает SQL COMMIT 20 | created_obj = await my_model_manager.create(session, input_data) 21 | # Вызов метода не сделает SQL COMMIT 22 | await my_model_manager.update(session, created_obj, name="updated_name", commit=False) 23 | # В БД сохранится только первый вызов 24 | ``` 25 | 26 | ## Begin once 27 | 28 | [Документация Begin once SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#begin-once) 29 | 30 | ```python 31 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 32 | from app.managers import my_model_manager 33 | 34 | ... 35 | 36 | engine = create_async_engine( 37 | "...", 38 | ) 39 | # Транзакция начинается в контекстном менеджере 40 | async with async_sessionmaker(engine) as session, session.begin(): 41 | # Этот вызов выполняет только flush, без SQL COMMIT 42 | created_obj = await my_model_manager.create(session, input_data) 43 | # Этот вызов выполняет только flush, без SQL COMMIT 44 | await my_model_manager.update(session, created_obj, name="updated_name") 45 | # Если во вложенном блоке не было исключений, то вызывается COMMIT, сохраняющий 46 | # изменения от обоих вызовов 47 | ``` -------------------------------------------------------------------------------- /docs/ru/usage.md: -------------------------------------------------------------------------------- 1 | ### Инициализация ModelManager 2 | 3 | Для взаимодействия с моделью `SQLAlchemy`, `fastapi-sqlalchemy-toolkit` предоставляет 4 | класс `ModelManager`. Его методы используются для взаимодействия с БД. 5 | 6 | Создать экземпляр `ModelManager` для конкретной модели можно следующим образом: 7 | 8 | ```python 9 | from fastapi_sqlalchemy_toolkit import ModelManager 10 | 11 | from .models import MyModel 12 | from .schemas import MyModelCreateSchema, MyModelUpdateSchema 13 | 14 | my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema]( 15 | MyModel 16 | ) 17 | ``` 18 | 19 | В качестве аргумента передаётся модель `SQLAlchemy`. Кроме того, используется параметризация типов 20 | класса `ModelManager`. В параметры типа передаётся модель `SQLAlchemy`, `Pydantic` модель для 21 | создания объекта и `Pydantic` модель для обновления объекта. 22 | 23 | Атрибут `default_ordering` определяет сортировку по умолчанию при получении списка объектов. 24 | В него можно передать поле модели: 25 | 26 | ```python 27 | from fastapi_sqlalchemy_toolkit import ModelManager 28 | 29 | from .models import MyModel 30 | from .schemas import MyModelCreateSchema, MyModelUpdateSchema 31 | 32 | my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema]( 33 | MyModel, default_ordering=MyModel.title 34 | ) 35 | ``` 36 | 37 | ### Методы ModelManager 38 | 39 | Ниже перечислены CRUD методы, предоставляемые `ModelManager`. 40 | Документация параметров, принимаемых методами, находится в докстрингах методов. 41 | 42 | - `create` - создание объекта; выполняет валидацию значений полей на уровне БД 43 | - `get` - получение объекта 44 | - `get_or_404` - получение объекта или ошибки HTTP 404 45 | - `exists` - проверка существования объекта 46 | - `paginated_list` / `paginated_filter` - получение списка объектов с фильтрами и пагинацией через `fastapi_pagination` 47 | - `list` / `filter` - получение списка объектов с фильтрами 48 | - `count` - получение количества объектов 49 | - `update` - обновление объекта; выполняет валидацию значений полей на уровне БД 50 | - `delete` - удаление объекта 51 | 52 | Использование методов `paginated_list` и `paginated_filter`, согласно документации 53 | `fastapi_pagination`, требует применения `fastapi_pagination.add_pagination` 54 | к приложению `FastAPI`, и использование `fastapi_pagination.Page` в типизации ответа 55 | эндпоинта FastAPI. 56 | 57 | Также доступны следующие методы для выполнения действий "пачкой": 58 | 59 | - `bulk_create` - создание объектов (частично выполняет валидацию на уровне БД) 60 | - `bulk_update` - обновление объектов (не выполняет валидацию на уровне БД) 61 | - `bulk_delete` - удаление объектов 62 | -------------------------------------------------------------------------------- /docs/ru/utils.md: -------------------------------------------------------------------------------- 1 | # Другие утилиты 2 | ## Сохранение пользователя запроса 3 | 4 | Пользователя запроса можно задать в создаваемом/обновляемом объекте, 5 | передав дополнительный параметр в метод `create` (`update`): 6 | ```python 7 | @router.post("") 8 | async def create_child( 9 | child_in: CreateUpdateChildSchema, session: Session, user: User 10 | ) -> CreateUpdateChildSchema: 11 | return await child_manager.create(session=session, in_obj=child_in, author_id=user.id) 12 | ``` 13 | 14 | ## Создание и обновление объектов с M2M связями 15 | Если на модели определена M2M связь, то использование `ModelManager` позволяет передать в это поле список ID объектов. 16 | 17 | `fastapi-sqlalchemy-toolkit` провалидирует существование этих объектов и установит им M2M связь, 18 | без необходимости создавать отдельные эндпоинты для работы с M2M связями. 19 | 20 | ```python 21 | # Пусть модели Person и House имеют M2M связь 22 | from pydantic import BaseModel 23 | 24 | 25 | class PersonCreateSchema(BaseModel): 26 | house_ids: list[int] 27 | 28 | ... 29 | 30 | in_obj = PersonCreateSchema(house_ids=[1, 2, 3]) 31 | await person_manager.create(session, in_obj) 32 | # Создаст объект Person и установит ему M2M связь с House с id 1, 2 и 3 33 | ``` 34 | 35 | ## Фильтрация по списку значений 36 | Один из способов фильтрации по списку значений -- передать этот список в качестве 37 | квери параметра в строку через запятую. 38 | `fastapi-sqlalchemy-toolkit` предоставляет утилиту для фильтрации по списку значений, переданного в строку через запятую: 39 | ```python 40 | from uuid import UUID 41 | from fastapi_sqlalchemy_toolkit.utils import comma_list_query, get_comma_list_values 42 | 43 | @router.get("/children") 44 | async def get_child_objects( 45 | session: Session, 46 | ids: comma_list_query = None, 47 | ) -> list[ChildListSchema] 48 | ids = get_comma_list_values(ids, UUID) 49 | return await child_manager.list( 50 | session, 51 | filter_expressions={ 52 | Child.id.in_: ids 53 | } 54 | ) 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/sorting.md: -------------------------------------------------------------------------------- 1 | `fastapi-sqlalchemy-toolkit` supports declarative sorting by model fields, as well as by fields of related models (if it is a model directly related to the main one and if these models are linked by a single foreign key). The necessary joins for sorting by fields of related models will be automatically performed. 2 | 3 | To apply declarative sorting: 4 | 1. Define a list of fields available for filtering. The field can be a string if it is a field of the main model, or a model attribute if it is on a related model. 5 | 6 | ```python 7 | from app.models import Parent 8 | 9 | child_ordering_fields = ( 10 | "title", 11 | "created_at", 12 | Parent.title, 13 | Parent.created_at 14 | ) 15 | ``` 16 | 17 | For each of the specified fields, sorting in ascending and descending order will be available. To sort by a field in descending order, pass its name in the query parameter starting with a hyphen (Django style). Thus, `?order_by=title` sorts by `title` in ascending order, and `?order_by=-title` sorts by `title` in descending order. 18 | 19 | 2. Pass the above-defined list to the `ordering_dep` parameter in the endpoint parameters 20 | 21 | ```python 22 | from fastapi_sqlalchemy_toolkit import ordering_dep 23 | 24 | @router.get("/children") 25 | async def get_child_objects( 26 | session: Session, 27 | order_by: ordering_dep(child_ordering_fields) 28 | ) -> list[ChildListSchema] 29 | ... 30 | ``` 31 | 32 | 3. Pass the sorting parameter as the `order_by` parameter in the `ModelManager` methods 33 | 34 | ```python 35 | return await child_manager.list(session=session, order_by=order_by) 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/transactions.md: -------------------------------------------------------------------------------- 1 | # Working with transactions 2 | 3 | `fastapi-sqlalchemy-toolkit` supports both approaches for working with transactions in `SQLAlchemy`. 4 | 5 | ## Commit as you go 6 | 7 | [Commit as you go SQLAlchemy Documentation](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#commit-as-you-go) 8 | 9 | ```python 10 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 11 | from app.managers import my_model_manager 12 | 13 | ... 14 | 15 | engine = create_async_engine( 16 | "...", 17 | ) 18 | async with async_sessionmaker(engine) as session: 19 | # Calling this method will perform an SQL COMMIT 20 | created_obj = await my_model_manager.create(session, input_data) 21 | # Calling this method will perform an SQL COMMIT 22 | await my_model_manager.update(session, created_obj, name="updated_name", commit=False) 23 | # Only the first call will be saved in the database 24 | ``` 25 | 26 | ## Begin once 27 | 28 | [Begin once SQLAlchemy Documentation](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#begin-once) 29 | 30 | ```python 31 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 32 | from app.managers import my_model_manager 33 | 34 | ... 35 | 36 | engine = create_async_engine( 37 | "...", 38 | ) 39 | # Transaction begins within the context manager 40 | async with async_sessionmaker(engine) as session, session.begin(): 41 | # This call only performs a flush, without an SQL COMMIT 42 | created_obj = await my_model_manager.create(session, input_data) 43 | # This call only performs a flush, without an SQL COMMIT 44 | await my_model_manager.update(session, created_obj, name="updated_name") 45 | # If there were no exceptions in the nested block, a COMMIT is invoked, saving 46 | # changes from both calls 47 | ``` -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ### ModelManager initialization 2 | 3 | To use `fastapi-sqlaclhemy-toolkit`, you need to create an instance of `ModelManager` for your model: 4 | 5 | ```python 6 | from fastapi_sqlalchemy_toolkit import ModelManager 7 | 8 | from .models import MyModel 9 | from .schemas import MyModelCreateSchema, MyModelUpdateSchema 10 | 11 | my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel) 12 | ``` 13 | 14 | The `default_ordering` attribute defines the default sorting when retrieving a list of objects. You should pass the primary model field to it. 15 | 16 | ```python 17 | from fastapi_sqlalchemy_toolkit import ModelManager 18 | 19 | from .models import MyModel 20 | from .schemas import MyModelCreateSchema, MyModelUpdateSchema 21 | 22 | my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema]( 23 | MyModel, default_ordering=MyModel.title 24 | ) 25 | ``` 26 | 27 | ### ModelManager methods 28 | 29 | Below are the CRUD methods provided by `ModelManager`. Documentation for the parameters accepted by these methods can be found in the method docstrings. 30 | 31 | - `create` - creates an object; performs validation of field values at the database level 32 | - `get` - retrieves an object 33 | - `get_or_404` - retrieves an object or returns HTTP 404 error 34 | - `exists` - checks the existence of an object 35 | - `paginated_list` / `paginated_filter` - retrieves a list of objects with filters and pagination through `fastapi_pagination` 36 | - `list` / `filter` - retrieves a list of objects with filters 37 | - `count` - retrieves the count of objects 38 | - `update` - updates an object; performs validation of field values at the database level 39 | - `delete` - deletes an object 40 | -------------------------------------------------------------------------------- /docs/utils.md: -------------------------------------------------------------------------------- 1 | ### Saving user of the request 2 | 3 | You can associate the user of the request with the object being created/updated 4 | by passing an additional parameter to the `create` (`update`) method: 5 | ```python 6 | @router.post("") 7 | async def create_child( 8 | child_in: CreateUpdateChildSchema, session: Session, user: CurrentUser 9 | ) -> CreateUpdateChildSchema: 10 | return await child_manager.create(session=session, in_obj=child_in, author_id=user.id) 11 | ``` 12 | 13 | ### Creating and updating objects with M2M relationships 14 | If the model has an M2M relationship defined, using `ModelManager` allows you to pass a list of object IDs to this field. 15 | 16 | `fastapi-sqlalchemy-toolkit` validates the existence of these objects and establishes the M2M relationship for them, 17 | without the need to create separate endpoints for working with M2M relationships. 18 | 19 | ```python 20 | # Let the Person and House models have an M2M relationship 21 | from pydantic import BaseModel 22 | 23 | 24 | class PersonCreateSchema(BaseModel): 25 | house_ids: list[int] 26 | 27 | ... 28 | 29 | in_obj = PersonCreateSchema(house_ids=[1, 2, 3]) 30 | await person_manager.create(session, in_obj) 31 | # Creates a Person object and establishes an M2M relationship with Houses with ids 1, 2, and 3 32 | ``` 33 | 34 | ### Filtering by list of values 35 | One way to filter by a list of values is to pass this list as a 36 | query parameter in the URL as a comma-separated string. 37 | `fastapi-sqlalchemy-toolkit` provides a utility for filtering by a list of values passed as a comma-separated string: 38 | ```python 39 | from uuid import UUID 40 | from fastapi_sqlalchemy_toolkit.utils import comma_list_query, get_comma_list_values 41 | 42 | @router.get("/children") 43 | async def get_child_objects( 44 | session: Session, 45 | ids: comma_list_query = None, 46 | ) -> list[ChildListSchema] 47 | ids = get_comma_list_values(ids, UUID) 48 | return await child_manager.list(session, id=FieldFilter(ids, operator="in_")) 49 | ``` 50 | -------------------------------------------------------------------------------- /examples/app/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=postgres 2 | POSTGRES_USER=admin 3 | POSTGRES_PASSWORD=password123 4 | POSTGRES_HOST=localhost 5 | POSTGRES_PORT=5432 6 | -------------------------------------------------------------------------------- /examples/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e-kondr01/fastapi-sqlalchemy-toolkit/906086c451e9f7d3161fc6255ee8285898ff1021/examples/app/__init__.py -------------------------------------------------------------------------------- /examples/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e-kondr01/fastapi-sqlalchemy-toolkit/906086c451e9f7d3161fc6255ee8285898ff1021/examples/app/api/__init__.py -------------------------------------------------------------------------------- /examples/app/api/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .endpoints import child 4 | 5 | api_router = APIRouter() 6 | api_router.include_router( 7 | child.router, 8 | prefix="/children", 9 | tags=["Demo"], 10 | ) 11 | -------------------------------------------------------------------------------- /examples/app/api/deps.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, AsyncGenerator 2 | 3 | from fastapi import Depends 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from app.db import async_session_factory 7 | 8 | 9 | async def get_async_session() -> AsyncGenerator[AsyncSession, None]: 10 | async_session = async_session_factory() # type: ignore 11 | async with async_session: 12 | yield async_session 13 | 14 | 15 | Session = Annotated[AsyncSession, Depends(get_async_session)] 16 | -------------------------------------------------------------------------------- /examples/app/api/endpoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e-kondr01/fastapi-sqlalchemy-toolkit/906086c451e9f7d3161fc6255ee8285898ff1021/examples/app/api/endpoints/__init__.py -------------------------------------------------------------------------------- /examples/app/api/endpoints/child.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from uuid import UUID 3 | 4 | from fastapi import APIRouter, Response, status 5 | from fastapi_pagination import Page 6 | from fastapi_sqlalchemy_toolkit import ordering_depends 7 | from sqlalchemy import func 8 | from sqlalchemy.orm import joinedload 9 | 10 | from app.api.deps import Session 11 | from app.managers import child_manager 12 | from app.models import Child, Parent 13 | from app.schemas import ( 14 | ChildDetailSchema, 15 | ChildListSchema, 16 | CreateChildSchema, 17 | HTTPErrorSchema, 18 | PatchChildSchema, 19 | ) 20 | 21 | router = APIRouter() 22 | 23 | 24 | children_ordering_fields = { 25 | "title": Child.title, 26 | "created_at": Child.created_at, 27 | "parent_title": Parent.title, 28 | "parent_created_at": Parent.created_at, 29 | } 30 | 31 | 32 | @router.get("") 33 | async def get_list( 34 | session: Session, 35 | order_by: ordering_depends(children_ordering_fields), 36 | title: str | None = None, 37 | slug: str | None = None, 38 | parent_title: str | None = None, 39 | parent_slug: str | None = None, 40 | created_at_date: date | None = None, 41 | ) -> Page[ChildListSchema]: 42 | return await child_manager.paginated_list( 43 | # Обязательные параметры 44 | session, 45 | # Фильтры 46 | slug=slug, 47 | filter_expressions={ 48 | Child.title.ilike: title, 49 | Parent.slug: parent_slug, 50 | Parent.title.ilike: parent_title, 51 | func.date(Child.created_at): created_at_date, 52 | }, 53 | # Сортировка 54 | order_by=order_by, 55 | ) 56 | 57 | 58 | @router.get( 59 | "/{object_id}", 60 | responses={ 61 | status.HTTP_404_NOT_FOUND: {"model": HTTPErrorSchema}, 62 | }, 63 | ) 64 | async def retrieve( 65 | object_id: UUID, 66 | session: Session, 67 | ) -> ChildDetailSchema: 68 | return await child_manager.get_or_404( 69 | session, 70 | id=object_id, 71 | options=joinedload(Child.parent), 72 | ) 73 | 74 | 75 | @router.post("") 76 | async def create(in_obj: CreateChildSchema, session: Session) -> ChildListSchema: 77 | return await child_manager.create(session, in_obj=in_obj) 78 | 79 | 80 | @router.patch( 81 | "/{object_id}", 82 | responses={ 83 | status.HTTP_404_NOT_FOUND: {"model": HTTPErrorSchema}, 84 | }, 85 | ) 86 | async def update_child( 87 | object_id: UUID, in_obj: PatchChildSchema, session: Session 88 | ) -> ChildListSchema: 89 | db_obj = await child_manager.get_or_404(session, id=object_id) 90 | return await child_manager.update(session, db_obj=db_obj, in_obj=in_obj) 91 | 92 | 93 | @router.delete( 94 | "/{object_id}", 95 | responses={ 96 | status.HTTP_404_NOT_FOUND: {"model": HTTPErrorSchema}, 97 | }, 98 | ) 99 | async def delete(object_id: UUID, session: Session) -> Response: 100 | db_obj = await child_manager.get_or_404(session=session, id=object_id) 101 | await child_manager.delete(session, db_obj=db_obj) 102 | return Response(status_code=status.HTTP_204_NO_CONTENT) 103 | -------------------------------------------------------------------------------- /examples/app/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pydantic import FieldValidationInfo, PostgresDsn, field_validator 4 | from pydantic_settings import BaseSettings, SettingsConfigDict 5 | 6 | ROOT_DIR = Path(__file__).resolve(strict=True).parent 7 | 8 | 9 | class Settings(BaseSettings): 10 | model_config = SettingsConfigDict( 11 | env_file=ROOT_DIR / ".env", case_sensitive=True, extra="allow" 12 | ) 13 | # PostgreSQL Database Connection 14 | POSTGRES_USER: str 15 | POSTGRES_PASSWORD: str 16 | POSTGRES_HOST: str 17 | POSTGRES_PORT: int 18 | POSTGRES_DB: str 19 | SQLALCHEMY_DATABASE_URL: str | None = None 20 | 21 | @field_validator("SQLALCHEMY_DATABASE_URL", mode="before") 22 | def assemble_db_connection_string( 23 | cls, value: PostgresDsn | None, info: FieldValidationInfo 24 | ) -> str | PostgresDsn: 25 | if isinstance(value, str): 26 | return value 27 | return str( 28 | PostgresDsn.build( 29 | scheme="postgresql+asyncpg", 30 | username=info.data["POSTGRES_USER"], 31 | password=info.data["POSTGRES_PASSWORD"], 32 | host=info.data["POSTGRES_HOST"], 33 | port=info.data["POSTGRES_PORT"], 34 | path=info.data["POSTGRES_DB"], 35 | ) 36 | ) 37 | 38 | 39 | settings = Settings() 40 | -------------------------------------------------------------------------------- /examples/app/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 2 | 3 | from .config import settings 4 | 5 | engine = create_async_engine(settings.SQLALCHEMY_DATABASE_URL, future=True, echo=False) 6 | 7 | async_session_factory = async_sessionmaker(engine, expire_on_commit=False) 8 | -------------------------------------------------------------------------------- /examples/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi_pagination import add_pagination 4 | 5 | from .api.api import api_router 6 | 7 | app = FastAPI( 8 | title="fastapi-sqlalchemy-toolkit demo", 9 | openapi_url="/api/openapi.json", 10 | docs_url="/api/docs", 11 | redoc_url="/api/redoc", 12 | ) 13 | 14 | app.add_middleware( 15 | CORSMiddleware, 16 | allow_origins=["*"], 17 | allow_credentials=True, 18 | allow_methods=["*"], 19 | allow_headers=["*"], 20 | ) 21 | 22 | add_pagination(app) 23 | 24 | app.include_router(api_router, prefix="/api") 25 | -------------------------------------------------------------------------------- /examples/app/managers.py: -------------------------------------------------------------------------------- 1 | from fastapi_sqlalchemy_toolkit import ModelManager 2 | 3 | from .models import Child 4 | from .schemas import CreateChildSchema, PatchChildSchema 5 | 6 | child_manager = ModelManager[Child, CreateChildSchema, PatchChildSchema]( 7 | Child, default_ordering=Child.title 8 | ) 9 | -------------------------------------------------------------------------------- /examples/app/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID, uuid4 3 | 4 | from sqlalchemy import DateTime, ForeignKey, func 5 | from sqlalchemy.orm import ( 6 | DeclarativeBase, 7 | Mapped, 8 | declared_attr, 9 | mapped_column, 10 | relationship, 11 | ) 12 | 13 | 14 | class Base(DeclarativeBase): 15 | id: Mapped[UUID] = mapped_column( 16 | primary_key=True, 17 | default=uuid4, 18 | server_default=func.gen_random_uuid(), 19 | ) 20 | 21 | created_at: Mapped[datetime] = mapped_column( 22 | DateTime(timezone=True), server_default=func.now() 23 | ) 24 | 25 | @declared_attr.directive 26 | def __tablename__(cls) -> str: 27 | return cls.__name__.lower() 28 | 29 | 30 | class Parent(Base): 31 | title: Mapped[str] 32 | slug: Mapped[str] = mapped_column(unique=True) 33 | 34 | children: Mapped[list["Child"]] = relationship(back_populates="parent") 35 | 36 | 37 | class Child(Base): 38 | title: Mapped[str] 39 | slug: Mapped[str] = mapped_column(unique=True) 40 | 41 | parent_id: Mapped[UUID] = mapped_column(ForeignKey("parent.id", ondelete="CASCADE")) 42 | parent: Mapped[Parent] = relationship(back_populates="children") 43 | -------------------------------------------------------------------------------- /examples/app/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID 3 | 4 | from fastapi_sqlalchemy_toolkit.utils import make_partial_model 5 | from pydantic import BaseModel 6 | 7 | 8 | class ChildBaseSchema(BaseModel): 9 | title: str 10 | slug: str 11 | 12 | 13 | class CreateChildSchema(ChildBaseSchema): 14 | parent_id: UUID 15 | 16 | 17 | PatchChildSchema = make_partial_model(CreateChildSchema) 18 | 19 | 20 | class ParentBaseSchema(BaseModel): 21 | title: str 22 | slug: str 23 | 24 | 25 | class ChildListSchema(ChildBaseSchema): 26 | id: UUID 27 | created_at: datetime 28 | 29 | 30 | class ChildDetailSchema(ChildListSchema): 31 | parent: ParentBaseSchema 32 | 33 | 34 | class HTTPErrorSchema(BaseModel): 35 | detail: str 36 | -------------------------------------------------------------------------------- /fastapi_sqlalchemy_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | from .filters import NullableQuery 2 | from .model_manager import ModelManager, sqlalchemy_model_to_dict 3 | from .ordering import ordering_depends 4 | from .utils import CommaSepQuery, comma_sep_q_to_list, make_partial_model 5 | -------------------------------------------------------------------------------- /fastapi_sqlalchemy_toolkit/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | NullableQuery = Literal["", "null"] 4 | null_query_values = ("", "null") 5 | -------------------------------------------------------------------------------- /fastapi_sqlalchemy_toolkit/model_manager.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: UP006 2 | from collections.abc import Callable, Iterable 3 | from typing import Any, Generic, List, TypeVar # noqa: UP035 4 | 5 | from fastapi import HTTPException, status 6 | from fastapi_pagination.bases import BasePage 7 | from fastapi_pagination.ext.sqlalchemy import paginate 8 | from pydantic import BaseModel 9 | from sqlalchemy import ( 10 | Integer, 11 | Row, 12 | String, 13 | UniqueConstraint, 14 | delete, 15 | func, 16 | insert, 17 | select, 18 | update, 19 | ) 20 | from sqlalchemy.dialects.postgresql import BOOLEAN 21 | from sqlalchemy.ext.asyncio import AsyncSession 22 | from sqlalchemy.orm import DeclarativeBase, contains_eager, load_only 23 | from sqlalchemy.orm.attributes import InstrumentedAttribute 24 | from sqlalchemy.orm.relationships import Relationship 25 | from sqlalchemy.sql import Select 26 | from sqlalchemy.sql.elements import UnaryExpression 27 | from sqlalchemy.sql.functions import Function 28 | from sqlalchemy.sql.schema import ScalarElementColumnDefault 29 | from sqlalchemy.sql.selectable import Exists 30 | 31 | from .filters import null_query_values 32 | 33 | ModelT = TypeVar("ModelT", bound=DeclarativeBase) 34 | CreateSchemaT = TypeVar("CreateSchemaT", bound=BaseModel) 35 | UpdateSchemaT = TypeVar("UpdateSchemaT", bound=BaseModel) 36 | ModelDict = dict[str, Any] 37 | 38 | 39 | def sqlalchemy_model_to_dict(model: DeclarativeBase) -> dict: 40 | db_obj_dict = model.__dict__.copy() 41 | db_obj_dict.pop("_sa_instance_state", None) 42 | return db_obj_dict 43 | 44 | 45 | class ModelManager(Generic[ModelT, CreateSchemaT, UpdateSchemaT]): 46 | def __init__( 47 | self, 48 | model: type[ModelT], 49 | default_ordering: InstrumentedAttribute | UnaryExpression | None = None, 50 | ) -> None: 51 | """ 52 | Создание экземпляра ModelManager под конкретную модель. 53 | 54 | :param model: модель SQLAlchemy 55 | 56 | :param default_ordering: поле модели, по которому должна выполняться 57 | сортировка по умолчанию 58 | """ 59 | self.model = model 60 | self.default_ordering = default_ordering 61 | 62 | # str() of FK attr to related model 63 | # "parent_id": 64 | # Используется для валидации существования FK при создании/обновлении объекта 65 | self.fk_name_to_model: dict[str, type[ModelT]] = {} 66 | 67 | self.unique_constraints: List[List[str]] = [] 68 | 69 | if hasattr(self.model, "__table_args__"): 70 | for table_arg in self.model.__table_args__: 71 | if isinstance(table_arg, UniqueConstraint): 72 | if table_arg.columns.keys(): 73 | self.unique_constraints.append(table_arg.columns.keys()) 74 | else: 75 | self.unique_constraints.append(table_arg._pending_colargs) 76 | 77 | self.reverse_relationships: dict[str, type[ModelT]] = {} 78 | self.m2m_relationships: dict[str, type[ModelT]] = {} 79 | # Model to related attr 80 | # Parent : Child.parent 81 | # Используется при составлении join'ов для фильтрации и сортировки 82 | self.models_to_relationship_attrs: dict[ 83 | type[ModelT], InstrumentedAttribute 84 | ] = {} 85 | # Значения по умолчанию для полей (используется для валидации) 86 | self.defaults: dict[str, Any] = {} 87 | 88 | attr: InstrumentedAttribute 89 | model_attrs = self.model.__dict__.copy() 90 | for attr_name, attr in model_attrs.items(): 91 | # Перебираем только атрибуты модели 92 | if not attr_name.startswith("_"): 93 | # Обрабатываем связи 94 | if hasattr(attr, "prop") and isinstance(attr.prop, Relationship): 95 | self.models_to_relationship_attrs[attr.prop.mapper.class_] = attr 96 | if attr.prop.collection_class == list: 97 | # Выбираем обратные связи (ManyToOne, ManyToMany) 98 | self.reverse_relationships[attr_name] = attr.prop.mapper.class_ 99 | else: 100 | # Выбираем OneToMany связи 101 | self.fk_name_to_model[ 102 | str(attr.expression.right).split(".")[1] 103 | ] = attr.prop.mapper.class_ 104 | # Выбираем ManyToMany связи 105 | if attr.prop.secondary is not None: 106 | self.m2m_relationships[attr_name] = attr.prop.mapper.class_ 107 | if hasattr(attr, "nullable") and attr.nullable: 108 | self.defaults[attr_name] = None 109 | if hasattr(attr, "default") and attr.default is not None: 110 | if isinstance(attr.default, ScalarElementColumnDefault): 111 | self.defaults[attr_name] = attr.default.arg 112 | elif ( 113 | hasattr(attr, "server_default") 114 | and attr.server_default is not None 115 | and hasattr(attr.server_default, "arg") 116 | ): 117 | if isinstance(attr.type, BOOLEAN): 118 | self.defaults[attr_name] = attr.server_default.arg != "False" 119 | elif isinstance(attr.type, Integer): 120 | self.defaults[attr_name] = int(attr.server_default.arg) 121 | elif isinstance(attr.type, String): 122 | self.defaults[attr_name] = attr.server_default.arg 123 | 124 | ################################################################################## 125 | # Public API 126 | ################################################################################## 127 | 128 | async def create( 129 | self, 130 | session: AsyncSession, 131 | in_obj: CreateSchemaT | None = None, 132 | refresh_attribute_names: Iterable[str] | None = None, 133 | *, 134 | commit: bool = True, 135 | **attrs: Any, 136 | ) -> ModelT: 137 | """ 138 | Создание экземпляра модели и сохранение в БД. 139 | Также выполняет валидацию на уровне БД. 140 | 141 | :param session: сессия SQLAlchemy 142 | 143 | :param in_obj: модель Pydantic для создания объекта 144 | 145 | :param refresh_attribute_names: названия полей, которые нужно обновить 146 | (может использоваться для подгрузки связанных полей) 147 | 148 | :param commit: нужно ли вызывать `session.commit()`, если используется 149 | подход commit as you go 150 | 151 | :param attrs: дополнительные значения полей создаваемого экземпляра 152 | (какие-то поля можно установить напрямую, 153 | например, пользователя запроса) 154 | 155 | :returns: созданный экземпляр модели 156 | """ 157 | create_data = in_obj.model_dump() if in_obj else {} 158 | create_data.update(attrs) 159 | # Добавляем дефолтные значения полей для валидации уникальности 160 | for field, default in self.defaults.items(): 161 | if field not in create_data: 162 | create_data[field] = default 163 | 164 | await self.run_db_validation(session, in_obj=create_data) 165 | db_obj = self.model(**create_data) 166 | session.add(db_obj) 167 | await self.save(session, commit=commit) 168 | await session.refresh(db_obj, attribute_names=refresh_attribute_names) 169 | return db_obj 170 | 171 | async def bulk_create( 172 | self, 173 | session: AsyncSession, 174 | in_objs: list[CreateSchemaT], 175 | *, 176 | commit: bool = True, 177 | returning: bool = True, 178 | **attrs: Any, 179 | ) -> list[ModelT] | None: 180 | """ 181 | Создание экземпляров модели пачкой и сохранение в БД. 182 | 183 | Валидация на уровне БД выполняется для каждого объекта 184 | (могут быть ошибки, если несколько создаваемых объектов 185 | имеют одинаковое значение для уникального поля). 186 | 187 | :param session: сессия SQLAlchemy 188 | 189 | :param in_objs: модели Pydantic для создания объектов 190 | 191 | :param commit: нужно ли вызывать `session.commit()`, если используется 192 | подход commit as you go 193 | 194 | :param returning: нужно ли возвращать созданные объекты 195 | 196 | :param attrs: дополнительные значения полей создаваемых экземпляров 197 | 198 | :returns: созданные экземпляры модели или None 199 | """ 200 | create_data = [in_obj.model_dump() | attrs for in_obj in in_objs] 201 | for in_obj in create_data: 202 | await self.run_db_validation(session, in_obj) 203 | 204 | stmt = insert(self.model).values(create_data) 205 | if returning: 206 | stmt = stmt.returning(self.model) 207 | result = await session.execute(stmt) 208 | await self.save(session, commit=commit) 209 | if returning: 210 | return result.scalars().all() 211 | return None 212 | 213 | async def get( 214 | self, 215 | session: AsyncSession, 216 | options: List[Any] | Any | None = None, 217 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 218 | where: Any | None = None, 219 | base_stmt: Select | None = None, 220 | *, 221 | unique: bool = False, 222 | **simple_filters: Any, 223 | ) -> ModelT | Row | None: 224 | """ 225 | Получение одного экземпляра модели при существовании 226 | 227 | :param session: сессия SQLAlchemy 228 | 229 | :param options: параметры для метода .options() загрузчика SQLAlchemy 230 | 231 | :param order_by: поле для сортировки 232 | 233 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 234 | 235 | :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт 236 | экземпляр Row, а не ModelT. 237 | Примечание: фильтрация и сортировка по связанным моделям скорее всего 238 | не будут работать вместе с этим параметром. 239 | 240 | :param unique: определяет необходимость вызова метода .unique() 241 | у результата SQLAlchemy 242 | 243 | :param simple_filters: параметры для фильтрации по точному соответствию, 244 | аналогично методу .filter_by() SQLAlchemy 245 | 246 | :returns: экземпляр модели, Row или None, если подходящего нет в БД 247 | """ 248 | stmt = self.assemble_stmt(base_stmt, order_by, options, where, **simple_filters) 249 | 250 | result = await session.execute(stmt) 251 | if unique: 252 | result = result.unique() 253 | if base_stmt is None: 254 | if order_by is not None: 255 | return result.scalars().first() 256 | return result.scalar_one_or_none() 257 | return result.first() 258 | 259 | async def get_or_404( 260 | self, 261 | session: AsyncSession, 262 | options: List[Any] | Any | None = None, 263 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 264 | where: Any | None = None, 265 | base_stmt: Select | None = None, 266 | *, 267 | unique: bool = False, 268 | **simple_filters: Any, 269 | ) -> ModelT | Row: 270 | """ 271 | Получение одного экземпляра модели или вызов HTTP исключения 404. 272 | 273 | :param session: сессия SQLAlchemy 274 | 275 | :param options: параметры для метода .options() загрузчика SQLAlchemy 276 | 277 | :param order_by: поле для сортировки 278 | 279 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 280 | 281 | :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт 282 | экземпляр Row, а не ModelT. 283 | 284 | :param unique: определяет необходимость вызова метода .unique() 285 | у результата SQLAlchemy 286 | 287 | :param simple_filters: параметры для фильтрации по точному соответствию, 288 | аналогично методу .filter_by() SQLAlchemy 289 | 290 | :returns: экземпляр модели или Row 291 | 292 | :raises: fastapi.HTTPException 404 293 | """ 294 | 295 | db_obj = await self.get( 296 | session, 297 | options=options, 298 | order_by=order_by, 299 | where=where, 300 | base_stmt=base_stmt, 301 | unique=unique, 302 | **simple_filters, 303 | ) 304 | if not db_obj: 305 | attrs_str = ", ".join( 306 | [f"{key}={value}" for key, value in simple_filters.items()] 307 | ) 308 | if where is not None: 309 | attrs_str += f", {where}" 310 | raise HTTPException( 311 | status_code=status.HTTP_404_NOT_FOUND, 312 | detail=f"{self.model.__tablename__} with {attrs_str} not found", 313 | ) 314 | return db_obj 315 | 316 | async def exists( 317 | self, 318 | session: AsyncSession, 319 | options: List[Any] | Any | None = None, 320 | where: Any | None = None, 321 | **simple_filters: Any, 322 | ) -> bool: 323 | """ 324 | Проверка существования экземпляра модели. 325 | 326 | :param session: сессия SQLAlchemy 327 | 328 | :param options: параметры для метода .options() загрузчика SQLAlchemy 329 | 330 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 331 | 332 | :param simple_filters: параметры для фильтрации по точному соответствию, 333 | аналогично методу .filter_by() SQLAlchemy 334 | 335 | :returns: True если объект существует, иначе False 336 | """ 337 | stmt = self.assemble_stmt( 338 | select(self.model.id), None, options, where, **simple_filters 339 | ) 340 | result = await session.execute(stmt) 341 | return result.first() is not None 342 | 343 | async def exists_or_404( 344 | self, 345 | session: AsyncSession, 346 | options: List[Any] | Any | None = None, 347 | where: Any | None = None, 348 | **simple_filters: Any, 349 | ) -> bool: 350 | """ 351 | Проверка существования экземпляра модели. 352 | Если объект не существует, вызывает HTTP исключение 404. 353 | 354 | :param session: сессия SQLAlchemy 355 | 356 | :param options: параметры для метода .options() загрузчика SQLAlchemy 357 | 358 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 359 | 360 | :param simple_filters: параметры для фильтрации по точному соответствию, 361 | аналогично методу .filter_by() SQLAlchemy 362 | 363 | :returns: True, если объект существует 364 | 365 | :raises: fastapi.HTTPException 404 366 | """ 367 | 368 | exists = await self.exists( 369 | session, 370 | options=options, 371 | where=where, 372 | **simple_filters, 373 | ) 374 | if not exists: 375 | attrs_str = ", ".join( 376 | [f"{key}={value}" for key, value in simple_filters.items()] 377 | ) 378 | if where is not None: 379 | attrs_str += f", {where}" 380 | raise HTTPException( 381 | status_code=status.HTTP_404_NOT_FOUND, 382 | detail=f"{self.model.__tablename__} with {attrs_str} does not exist", 383 | ) 384 | return True 385 | 386 | async def paginated_filter( 387 | self, 388 | session: AsyncSession, 389 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 390 | options: List[Any] | Any | None = None, 391 | where: Any | None = None, 392 | base_stmt: Select | None = None, 393 | transformer: Callable | None = None, 394 | **simple_filters: Any, 395 | ) -> BasePage[ModelT | Row]: 396 | """ 397 | Получение списка объектов с фильтрами и пагинацией. 398 | 399 | :param session: сессия SQLAlchemy 400 | 401 | :param order_by: поле для сортировки 402 | 403 | :param options: параметры для метода .options() загрузчика SQLAlchemy 404 | 405 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 406 | 407 | :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт 408 | страницу Row, а не ModelT. 409 | 410 | :param transformer: функция для преобразования атрибутов Row к 411 | модели Pydantic в пагинированном результате. См: 412 | https://uriyyo-fastapi-pagination.netlify.app/integrations/sqlalchemy/#scalar-column 413 | 414 | :param simple_filters: параметры для фильтрации по точному соответствию, 415 | аналогично методу .filter_by() SQLAlchemy 416 | 417 | :returns: пагинированный список объектов или Row 418 | """ 419 | stmt = self.assemble_stmt(base_stmt, order_by, options, where, **simple_filters) 420 | return await paginate(session, stmt, transformer=transformer) 421 | 422 | async def paginated_list( 423 | self, 424 | session: AsyncSession, 425 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 426 | filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = None, 427 | nullable_filter_expressions: ( 428 | dict[InstrumentedAttribute | Callable, Any] | None 429 | ) = None, 430 | options: List[Any] | Any | None = None, 431 | where: Any | None = None, 432 | base_stmt: Select | None = None, 433 | transformer: Callable | None = None, 434 | **simple_filters: Any, 435 | ) -> BasePage[ModelT | Row]: 436 | """ 437 | Получение списка объектов с фильтрами и пагинацией. 438 | Пропускает фильтры, значения которых None. 439 | 440 | :param session: сессия SQLAlchemy 441 | 442 | :param order_by: поле для сортировки 443 | 444 | :param filter_expressions: словарь, отображающий поля для фильтрации 445 | на их значения. Фильтрация по None не применяется. См. раздел "фильтрация" 446 | в документации. 447 | 448 | :param nullable_filter_expressions: словарь, отображающий поля для фильтрации 449 | на их значения. Фильтрация по None применятеся, если значение 450 | в fastapi_sqlalchemy_toolkit.NullableQuery. См. раздел "фильтрация" 451 | в документации. 452 | 453 | :param options: параметры для метода .options() загрузчика SQLAlchemy 454 | 455 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 456 | 457 | :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт 458 | страницу Row, а не ModelT. 459 | Примечание: фильтрация и сортировка по связанным моделям скорее всего 460 | не будет работать вместе с этим параметром. 461 | 462 | :param transformer: функция для преобразования атрибутов Row к 463 | модели Pydantic в пагинированном результате. См: 464 | https://uriyyo-fastapi-pagination.netlify.app/integrations/sqlalchemy/#scalar-column 465 | 466 | :param simple_filters: параметры для фильтрации по точному соответствию, 467 | аналогично методу .filter_by() SQLAlchemy 468 | 469 | :returns: пагинированный список объектов или Row 470 | """ 471 | if filter_expressions is None: 472 | filter_expressions = {} 473 | if nullable_filter_expressions is None: 474 | nullable_filter_expressions = {} 475 | self.remove_optional_filter_bys(simple_filters) 476 | self.handle_filter_expressions(filter_expressions) 477 | self.handle_nullable_filter_expressions(nullable_filter_expressions) 478 | filter_expressions = filter_expressions | nullable_filter_expressions 479 | 480 | stmt = self.assemble_stmt(base_stmt, order_by, options, where, **simple_filters) 481 | stmt = self.get_joins( 482 | stmt, 483 | options=options, 484 | order_by=order_by, 485 | filter_expressions=filter_expressions, 486 | ) 487 | 488 | for filter_expression, value in filter_expressions.items(): 489 | if isinstance(filter_expression, InstrumentedAttribute | Function): 490 | stmt = stmt.filter(filter_expression == value) 491 | else: 492 | stmt = stmt.filter(filter_expression(value)) 493 | 494 | return await paginate(session, stmt, transformer=transformer) 495 | 496 | async def filter( 497 | self, 498 | session: AsyncSession, 499 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 500 | options: List[Any] | Any | None = None, 501 | where: Any | None = None, 502 | base_stmt: Select | None = None, 503 | limit: int | None = None, 504 | offset: int | None = None, 505 | *, 506 | unique: bool = False, 507 | **simple_filters: Any, 508 | ) -> List[ModelT] | List[Row]: 509 | """ 510 | Получение списка объектов с фильтрами 511 | 512 | :param session: сессия SQLAlchemy 513 | 514 | :param order_by: поле для сортировки 515 | 516 | :param options: параметры для метода .options() загрузчика SQLAlchemy 517 | 518 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 519 | 520 | :param unique: определяет необходимость вызова метода .unique() 521 | у результата SQLAlchemy 522 | 523 | :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт 524 | список Row, а не ModelT. 525 | 526 | :param limit: ограничение, передаётся в параметр limit запроса SQLAlchemy 527 | 528 | :param offset: смещение, передаётся в параметр offset запроса SQLAlchemy 529 | 530 | :param simple_filters: параметры для фильтрации по точному соответствию, 531 | аналогично методу .filter_by() SQLAlchemy 532 | 533 | :returns: список объектов или Row 534 | """ 535 | stmt = self.assemble_stmt( 536 | base_stmt, 537 | order_by, 538 | options, 539 | where, 540 | limit=limit, 541 | offset=offset, 542 | **simple_filters, 543 | ) 544 | result = await session.execute(stmt) 545 | 546 | if base_stmt is None: 547 | if unique: 548 | return result.scalars().unique().all() 549 | return result.scalars().all() 550 | return result.all() 551 | 552 | async def list( 553 | self, 554 | session: AsyncSession, 555 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 556 | filter_expressions: dict[InstrumentedAttribute | Callable, Any] | None = None, 557 | nullable_filter_expressions: ( 558 | dict[InstrumentedAttribute | Callable, Any] | None 559 | ) = None, 560 | options: List[Any] | Any | None = None, 561 | where: Any | None = None, 562 | base_stmt: Select | None = None, 563 | limit: int | None = None, 564 | offset: int | None = None, 565 | *, 566 | unique: bool = False, 567 | **simple_filters: Any, 568 | ) -> List[ModelT] | List[Row]: 569 | """ 570 | Получение списка объектов с фильтрами. 571 | Пропускает фильтры, значения которых None. 572 | 573 | :param session: сессия SQLAlchemy 574 | 575 | :param order_by: поле для сортировки 576 | 577 | :param filter_expressions: словарь, отображающий поля для фильтрации 578 | на их значения. Фильтрация по None не применяется. См. раздел "фильтрация" 579 | в документации. 580 | 581 | :param nullable_filter_expressions: словарь, отображающий поля для фильтрации 582 | на их значения. Фильтрация по None применятеся, если значение 583 | в fastapi_sqlalchemy_toolkit.NullableQuery. См. раздел "фильтрация" 584 | в документации. 585 | 586 | :param options: параметры для метода .options() загрузчика SQLAlchemy 587 | 588 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 589 | 590 | :param unique: определяет необходимость вызова метода .unique() 591 | у результата SQLAlchemy 592 | 593 | :param base_stmt: объект Select для SQL запроса. Если передан, то метод вернёт 594 | список Row, а не ModelT. 595 | Примечание: фильтрация и сортировка по связанным моделям скорее всего 596 | не будут работать вместе с этим параметром. 597 | 598 | :param limit: ограничение, передаётся в параметр limit запроса SQLAlchemy 599 | 600 | :param offset: смещение, передаётся в параметр offset запроса SQLAlchemy 601 | 602 | :param simple_filters: параметры для фильтрации по точному соответствию, 603 | аналогично методу .filter_by() SQLAlchemy 604 | 605 | :returns: список объектов или Row 606 | """ 607 | if filter_expressions is None: 608 | filter_expressions = {} 609 | if nullable_filter_expressions is None: 610 | nullable_filter_expressions = {} 611 | self.remove_optional_filter_bys(simple_filters) 612 | self.handle_filter_expressions(filter_expressions) 613 | self.handle_nullable_filter_expressions(nullable_filter_expressions) 614 | filter_expressions = filter_expressions | nullable_filter_expressions 615 | 616 | stmt = self.assemble_stmt( 617 | base_stmt, 618 | order_by, 619 | options, 620 | where, 621 | limit=limit, 622 | offset=offset, 623 | **simple_filters, 624 | ) 625 | stmt = self.get_joins( 626 | stmt, 627 | options=options, 628 | order_by=order_by, 629 | filter_expressions=filter_expressions, 630 | ) 631 | 632 | for filter_expression, value in filter_expressions.items(): 633 | if isinstance(filter_expression, InstrumentedAttribute): 634 | stmt = stmt.filter(filter_expression == value) 635 | else: 636 | stmt = stmt.filter(filter_expression(value)) 637 | 638 | result = await session.execute(stmt) 639 | 640 | if base_stmt is None: 641 | if unique: 642 | return result.scalars().unique().all() 643 | return result.scalars().all() 644 | return result.all() 645 | 646 | async def count( 647 | self, 648 | session: AsyncSession, 649 | where: Any | None = None, 650 | **simple_filters: Any, 651 | ) -> int: 652 | """ 653 | Возвращает количество экземпляров модели по данным фильтрам. 654 | 655 | :param session: сессия SQLAlchemy 656 | 657 | :param where: выражение, которое будет передано в метод .where() SQLAlchemy 658 | 659 | :param simple_filters: параметры для фильтрации по точному соответствию, 660 | аналогично методу .filter_by() SQLAlchemy 661 | 662 | :returns: количество объектов по переданным фильтрам 663 | """ 664 | # TODO: reference primary key instead of hardcode model.id 665 | stmt = select(func.count(self.model.id)) 666 | if where is not None: 667 | stmt = stmt.where(where) 668 | if simple_filters: 669 | stmt = stmt.filter_by(**simple_filters) 670 | result = await session.execute(stmt) 671 | return result.scalar_one_or_none() or 0 672 | 673 | async def update( 674 | self, 675 | session: AsyncSession, 676 | db_obj: ModelT, 677 | in_obj: UpdateSchemaT | None = None, 678 | refresh_attribute_names: Iterable[str] | None = None, 679 | *, 680 | commit: bool = True, 681 | exclude_unset: bool = True, 682 | **attrs: Any, 683 | ) -> ModelT: 684 | """ 685 | Обновление экземпляра модели в БД. 686 | Также выполняет валидацию на уровне БД. 687 | 688 | :param session: сессия SQLAlchemy 689 | 690 | :param db_obj: обновляемый объект 691 | 692 | :param in_obj: модель Pydantic для обновления значений полей объекта 693 | 694 | :param attrs: дополнительные значения обновляемых полей 695 | (какие-то поля можно установить напрямую, 696 | например, пользователя запроса) 697 | 698 | :param refresh_attribute_names: названия полей, которые нужно обновить 699 | (может использоваться для подгрузки связанных полей) 700 | 701 | :param commit: нужно ли вызывать `session.commit()`, если используется 702 | подход commit as you go 703 | 704 | :param exclude_unset: передаётся в метод `.model_dump()` Pydantic модели. 705 | При использовании метода в PATCH-запросах имеет смысл оставлять его True 706 | для изменения только переданных полей; при PUT-запросах имеет смысл 707 | передавать False, чтобы установить дефолтные значения полей, заданные 708 | в модели Pydantic. 709 | 710 | :returns: обновлённый экземпляр модели 711 | """ 712 | update_data = in_obj.model_dump(exclude_unset=exclude_unset) if in_obj else {} 713 | update_data.update(attrs) 714 | update_data = await self.run_db_validation( 715 | session, db_obj=db_obj, in_obj=update_data 716 | ) 717 | for field in update_data: 718 | setattr(db_obj, field, update_data[field]) 719 | await self.save(session, commit=commit) 720 | await session.refresh(db_obj, attribute_names=refresh_attribute_names) 721 | return db_obj 722 | 723 | async def bulk_update( 724 | self, 725 | session: AsyncSession, 726 | in_obj: UpdateSchemaT | None = None, 727 | ids: Iterable | None = None, 728 | where: Any | None = None, 729 | *, 730 | commit: bool = True, 731 | returning: bool = True, 732 | **attrs: Any, 733 | ) -> List[ModelT] | None: 734 | """ 735 | Обновление экземпляров модели пачкой и сохранение в БД. 736 | Не выполняет валидацию на уровне БД. 737 | 738 | :param session: сессия SQLAlchemy 739 | 740 | :param in_obj: модель Pydantic для обновления объектов 741 | 742 | :param ids: ID обновляемых объектов (в списке, кортеже и т.д.) 743 | 744 | :param where: фильтр для обновления объектов, 745 | передаётся в метод .where() SQLAlchemy. 746 | Используется, если не передан параметр :ids: 747 | 748 | :param commit: нужно ли вызывать `session.commit()`, если используется 749 | подход commit as you go 750 | 751 | :param returning: нужно ли возвращать обновлённые объекты 752 | 753 | :param attrs: дополнительные значения полей обновляемых экземпляров 754 | 755 | :returns: обновлённые экземпляры модели или None 756 | """ 757 | update_data = in_obj.model_dump() if in_obj else {} 758 | update_data.update(attrs) 759 | 760 | stmt = update(self.model).values(update_data) 761 | if ids: 762 | stmt = stmt.where(self.model.id.in_(ids)) 763 | elif where is not None: 764 | stmt = stmt.where(where) 765 | if returning: 766 | stmt = stmt.returning(self.model) 767 | result = await session.execute(stmt) 768 | await self.save(session, commit=commit) 769 | if returning: 770 | return result.scalars().all() 771 | return None 772 | 773 | async def delete( 774 | self, session: AsyncSession, db_obj: ModelT, *, commit: bool = True 775 | ) -> ModelT: 776 | """ 777 | Удаление экземпляра модели из БД. 778 | 779 | :param session: сессия SQLAlchemy 780 | 781 | :param db_obj: удаляемый объект 782 | 783 | :param commit: нужно ли вызывать `session.commit()`, если используется 784 | подход commit as you go 785 | 786 | :returns: переданный в функцию экземпляр модели 787 | """ 788 | await session.delete(db_obj) 789 | await self.save(session, commit=commit) 790 | return db_obj 791 | 792 | async def bulk_delete( 793 | self, 794 | session: AsyncSession, 795 | ids: Iterable | None = None, 796 | where: Any | None = None, 797 | *, 798 | commit: bool = True, 799 | ) -> None: 800 | """ 801 | Удаление экземпляров модели пачкой из БД. 802 | 803 | :param session: сессия SQLAlchemy 804 | 805 | :param ids: ID удаляемых объектов (в списке, кортеже и т.д.) 806 | 807 | :param where: фильтр для удаления объектов, 808 | передаётся в метод .where() SQLAlchemy. 809 | Используется, если не передан параметр :ids: 810 | 811 | :param commit: нужно ли вызывать `session.commit()`, если используется 812 | подход commit as you go 813 | 814 | :returns: None 815 | """ 816 | stmt = delete(self.model) 817 | if ids: 818 | stmt = stmt.where(self.model.id.in_(ids)) 819 | elif where is not None: 820 | stmt = stmt.where(where) 821 | await session.execute(stmt) 822 | await self.save(session, commit=commit) 823 | 824 | ################################################################################## 825 | # Internal methods 826 | ################################################################################## 827 | 828 | @staticmethod 829 | async def save(session: AsyncSession, *, commit: bool = True) -> None: 830 | """ 831 | Сохраняет изменения в БД, обрабатывая разное использовании сессии: 832 | "commit as you go" или "begin once". 833 | 834 | Если используется подход "commit as you go", и параметр :commit: передан 835 | True, то выполняется `commit()`; иначе `flush()`. 836 | 837 | Если используется подход "begin once" (`async with session.begin():`), 838 | то не выполняется `flush()`, чтобы не закрывать транзакцию в контекстном 839 | менеджере. 840 | """ 841 | 842 | if session.sync_session._trans_context_manager: 843 | await session.flush() 844 | elif commit: 845 | await session.commit() 846 | else: 847 | await session.flush() 848 | 849 | async def run_db_validation( 850 | self, 851 | session: AsyncSession, 852 | in_obj: ModelDict, 853 | db_obj: ModelT | None = None, 854 | ) -> ModelDict: 855 | """ 856 | Выполнить валидацию на соответствие ограничениям БД. 857 | """ 858 | if db_obj: 859 | db_obj_dict = sqlalchemy_model_to_dict(db_obj) 860 | db_obj_dict.update(in_obj) 861 | in_obj = db_obj_dict 862 | if self.fk_name_to_model: 863 | await self.validate_fk_exists(session, in_obj) 864 | if self.m2m_relationships: 865 | await self.handle_m2m_fields(session, in_obj) 866 | await self.validate_unique_fields(session, in_obj, db_obj=db_obj) 867 | await self.validate_unique_constraints(session, in_obj) 868 | return in_obj 869 | 870 | def get_select(self, base_stmt: Select | None = None, **_kwargs: Any) -> Select: 871 | if base_stmt is not None: 872 | return base_stmt 873 | return select(self.model) 874 | 875 | def get_joins( 876 | self, 877 | base_query: Select, 878 | filter_expressions: dict[InstrumentedAttribute | Callable, Any], 879 | options: List[Any] | None = None, 880 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 881 | ) -> Select: 882 | """ 883 | Делает необходимые join'ы при фильтрации и сортировке по полям 884 | связанных моделей. 885 | Поддерживает только глубину связи 1. 886 | """ 887 | if options is not None: 888 | if not isinstance(options, list): 889 | options = [options] 890 | else: 891 | options = [] 892 | 893 | joined_query = base_query 894 | models_to_join = set() 895 | 896 | if order_by is not None: 897 | if isinstance(order_by, InstrumentedAttribute): 898 | ordering_model = order_by.parent.class_ 899 | else: 900 | ordering_model = order_by._propagate_attrs[ 901 | "plugin_subject" 902 | ]._identity_class 903 | if ordering_model != self.model: 904 | models_to_join.add(ordering_model) 905 | 906 | for filter_expression in filter_expressions: 907 | if isinstance(filter_expression, InstrumentedAttribute): 908 | model = filter_expression.parent._identity_class 909 | elif isinstance(filter_expression, Function): 910 | model = filter_expression.entity_namespace 911 | else: 912 | model = filter_expression.__self__.parent._identity_class 913 | if model != self.model: 914 | models_to_join.add(model) 915 | for model in models_to_join: 916 | if model in self.models_to_relationship_attrs: 917 | joined_query = joined_query.outerjoin( 918 | self.models_to_relationship_attrs[model] 919 | ) 920 | 921 | if options: 922 | # Если в .options передана стратегия загрузки модели, 923 | # которая должна быть подгружена для фильтрации или сортировки, 924 | # то не добавляем contains_eager для этой модели 925 | models_for_additional_options = models_to_join.copy() 926 | for option in options: 927 | if option.path.entity.class_ in models_for_additional_options: 928 | models_for_additional_options.remove(option.path.entity.class_) 929 | for model in models_for_additional_options: 930 | options.append(contains_eager(self.models_to_relationship_attrs[model])) 931 | return joined_query 932 | 933 | def get_order_by_expression( 934 | self, order_by: InstrumentedAttribute | UnaryExpression | None 935 | ) -> ( 936 | InstrumentedAttribute 937 | | UnaryExpression 938 | | None 939 | | tuple[ 940 | InstrumentedAttribute | UnaryExpression, 941 | InstrumentedAttribute | UnaryExpression, 942 | ] 943 | ): 944 | if order_by is not None: 945 | if self.default_ordering is not None: 946 | return order_by, self.default_ordering 947 | return order_by 948 | return self.default_ordering 949 | 950 | @staticmethod 951 | def remove_optional_filter_bys( 952 | filters: dict[str, Any], 953 | ) -> None: 954 | for filter_by_name, filter_by_value in filters.copy().items(): 955 | if filter_by_value is None: 956 | del filters[filter_by_name] 957 | 958 | @staticmethod 959 | def handle_filter_expressions( 960 | filter_expressions: dict[InstrumentedAttribute | Callable, Any], 961 | ) -> None: 962 | for filter_expression, value in filter_expressions.copy().items(): 963 | if value is None: 964 | del filter_expressions[filter_expression] 965 | elif "ilike" in str(filter_expression): 966 | filter_expressions[filter_expression] = ( 967 | f"%{filter_expressions[filter_expression]}%" 968 | ) 969 | 970 | @staticmethod 971 | def handle_nullable_filter_expressions( 972 | nullable_filter_expressions: dict[InstrumentedAttribute | Callable, Any], 973 | ) -> None: 974 | for filter_expression, value in nullable_filter_expressions.copy().items(): 975 | if value in null_query_values: 976 | nullable_filter_expressions[filter_expression] = None 977 | elif value is None: 978 | del nullable_filter_expressions[filter_expression] 979 | elif "ilike" in str(filter_expression): 980 | nullable_filter_expressions[filter_expression] = ( 981 | f"%{nullable_filter_expressions[filter_expression]}%" 982 | ) 983 | 984 | def get_reverse_relation_filter_stmt( 985 | self, 986 | field_name: str, 987 | value: Any, 988 | ) -> Exists: 989 | relationship: InstrumentedAttribute = getattr(self.model, field_name) 990 | return relationship.any(self.reverse_relationships[field_name].id.in_(value)) 991 | 992 | def assemble_stmt( 993 | self, 994 | base_stmt: Select | None = None, 995 | order_by: InstrumentedAttribute | UnaryExpression | None = None, 996 | options: List[Any] | Any | None = None, 997 | where: Any | None = None, 998 | limit: int | None = None, 999 | offset: int | None = None, 1000 | **simple_filters: Any, 1001 | ) -> Select: 1002 | if base_stmt is not None: 1003 | stmt = base_stmt 1004 | else: 1005 | stmt = self.get_select( 1006 | base_stmt=base_stmt, order_by=order_by, **simple_filters 1007 | ) 1008 | 1009 | for field_name, value in simple_filters.copy().items(): 1010 | if field_name in self.reverse_relationships: 1011 | stmt = stmt.filter( 1012 | self.get_reverse_relation_filter_stmt(field_name, value) 1013 | ) 1014 | del simple_filters[field_name] 1015 | 1016 | if simple_filters: 1017 | stmt = stmt.filter_by(**simple_filters) 1018 | 1019 | order_by_expression = self.get_order_by_expression(order_by) 1020 | if order_by_expression is not None: 1021 | stmt = ( 1022 | stmt.order_by(*order_by_expression) 1023 | if isinstance(order_by_expression, tuple) 1024 | else stmt.order_by(order_by_expression) 1025 | ) 1026 | 1027 | if options is not None: 1028 | if not isinstance(options, list): 1029 | options = [options] 1030 | else: 1031 | options = [] 1032 | for option in options: 1033 | stmt = stmt.options(option) 1034 | 1035 | if where is not None: 1036 | if isinstance(where, tuple): 1037 | stmt = stmt.where(*where) 1038 | else: 1039 | stmt = stmt.where(where) 1040 | 1041 | if limit is not None: 1042 | stmt = stmt.limit(limit) 1043 | if offset is not None: 1044 | stmt = stmt.offset(offset) 1045 | 1046 | return stmt 1047 | 1048 | async def validate_fk_exists( 1049 | self, session: AsyncSession, in_obj: ModelDict 1050 | ) -> None: 1051 | """ 1052 | Проверить, существуют ли связанные объекты с переданными для записи id. 1053 | """ 1054 | 1055 | for key in in_obj: 1056 | if key in self.fk_name_to_model and in_obj[key] is not None: 1057 | related_object_exists = await session.get( 1058 | self.fk_name_to_model[key], 1059 | in_obj[key], 1060 | options=[load_only(self.fk_name_to_model[key].id)], 1061 | ) 1062 | if not related_object_exists: 1063 | raise HTTPException( 1064 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 1065 | detail=( 1066 | f"{self.fk_name_to_model[key].__tablename__} с id " 1067 | f"{in_obj[key]} не существует." 1068 | ), 1069 | ) 1070 | 1071 | async def validate_unique_constraints( 1072 | self, session: AsyncSession, in_obj: ModelDict 1073 | ) -> None: 1074 | """ 1075 | Проверить, не нарушаются ли UniqueConstraint модели. 1076 | """ 1077 | for unique_constraint in self.unique_constraints: 1078 | null_found = False 1079 | query = {} 1080 | for field in unique_constraint: 1081 | if in_obj[field] is None: 1082 | null_found = True 1083 | break 1084 | 1085 | query[field] = in_obj[field] 1086 | if null_found: 1087 | continue 1088 | if query: 1089 | object_exists = await self.exists( 1090 | session, **query, where=(self.model.id != in_obj.get("id")) 1091 | ) 1092 | if object_exists: 1093 | conflicting_fields = ", ".join(unique_constraint) 1094 | raise HTTPException( 1095 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 1096 | detail=( 1097 | f"{self.model.__tablename__} с такими " 1098 | + conflicting_fields 1099 | + " уже существует." 1100 | ), 1101 | ) 1102 | 1103 | async def validate_unique_fields( 1104 | self, 1105 | session: AsyncSession, 1106 | in_obj: ModelDict, 1107 | db_obj: ModelT | None = None, 1108 | ) -> None: 1109 | """ 1110 | Проверить соблюдение уникальности полей. 1111 | """ 1112 | for column in self.model.__table__.columns._all_columns: 1113 | if ( 1114 | column.unique 1115 | and column.name in in_obj 1116 | and in_obj[column.name] is not None 1117 | ): 1118 | if db_obj and getattr(db_obj, column.name) == in_obj[column.name]: 1119 | continue 1120 | attrs_to_check = {column.name: in_obj[column.name]} 1121 | object_exists = await self.exists( 1122 | session=session, 1123 | **attrs_to_check, 1124 | where=(self.model.id != in_obj.get("id")), 1125 | ) 1126 | if object_exists: 1127 | raise HTTPException( 1128 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 1129 | detail=( 1130 | f"{self.model.__tablename__} c {column.name} " 1131 | f"{in_obj[column.name]} уже существует" 1132 | ), 1133 | ) 1134 | 1135 | async def handle_m2m_fields(self, session: AsyncSession, in_obj: ModelDict) -> None: 1136 | for field in in_obj: 1137 | if field in self.m2m_relationships: 1138 | related_model = self.m2m_relationships[field] 1139 | related_objects = [] 1140 | for related_object_id in in_obj[field]: 1141 | related_object = await session.get(related_model, related_object_id) 1142 | if not related_object: 1143 | raise HTTPException( 1144 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 1145 | detail=( 1146 | f"{related_model.__tablename__} с id " 1147 | f"{related_object_id} не существует." 1148 | ), 1149 | ) 1150 | related_objects.append(related_object) 1151 | in_obj[field] = related_objects 1152 | -------------------------------------------------------------------------------- /fastapi_sqlalchemy_toolkit/ordering.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from enum import Enum 3 | from typing import Annotated 4 | from uuid import uuid4 5 | 6 | from fastapi import Depends 7 | from sqlalchemy.orm.attributes import InstrumentedAttribute 8 | from sqlalchemy.sql.elements import UnaryExpression 9 | 10 | 11 | def get_ordering_enum( 12 | ordering_fields_mapping: dict[str, InstrumentedAttribute], 13 | ) -> Enum: 14 | """ 15 | Собирает Enum из возможных значений сортировки для документации OpenAPI 16 | """ 17 | enum_attrs = {} 18 | for field_name in ordering_fields_mapping: 19 | enum_attrs[field_name] = field_name 20 | enum_attrs[f"desc_{field_name}"] = "-" + field_name 21 | return Enum(str(uuid4()), enum_attrs) 22 | 23 | 24 | def ordering_depends( 25 | ordering_fields: Sequence[InstrumentedAttribute] | dict[str, InstrumentedAttribute], 26 | ) -> object: 27 | """ 28 | Создаёт fastapi.Depends для квери параметра сортировки по переданным полям модели. 29 | 30 | :ordering_fields: поля для сортировки. 31 | Может быть последовательностью полей основной модели: 32 | ordering_fields=(MyModel.title, MyModel.created_at) 33 | В таком случае будут доступны параметры сортировки "title", "-title", 34 | "created_at", "-created_at". 35 | Дефис первым символом означает сортировку по убыванию. 36 | Либо может быть маппингом строковых полей для сортировки 37 | на соответствующие поля моделей: 38 | ordering_fields={ 39 | "title": MyModel.title, 40 | "parent_title": ParentModel.title 41 | } 42 | В таком случае будут доступны параметры сортировки "title", "-title", 43 | "parent_title", "-parent_title". 44 | Если order_by передаётся в методы list или paginated_list, 45 | и поле для сортировки относится к модели, напрямую связанную с основной, 46 | то будет выполнен необходимый join для применения сортировки. 47 | """ 48 | 49 | if isinstance(ordering_fields, dict): 50 | ordering_fields_mapping = ordering_fields 51 | 52 | else: 53 | ordering_fields_mapping = {field.name: field for field in ordering_fields} 54 | 55 | def get_ordering_field( 56 | order_by: get_ordering_enum(ordering_fields_mapping) = None, 57 | ) -> InstrumentedAttribute | UnaryExpression | None: 58 | if order_by: 59 | desc = order_by.value.startswith("-") 60 | field = ordering_fields_mapping[order_by.value.lstrip("-")] 61 | if desc: 62 | return field.desc() 63 | return field 64 | return None 65 | 66 | return Annotated[ 67 | InstrumentedAttribute | UnaryExpression | None, 68 | Depends(get_ordering_field), 69 | ] 70 | -------------------------------------------------------------------------------- /fastapi_sqlalchemy_toolkit/utils.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Annotated, Any, Optional, TypeVar 3 | 4 | import pydantic 5 | import pydantic_core 6 | from fastapi import Query 7 | 8 | BaseModelT = TypeVar("BaseModelT", bound=pydantic.BaseModel) 9 | 10 | 11 | def _make_field_optional( 12 | field: pydantic.fields.FieldInfo, 13 | ) -> tuple[Any, pydantic.fields.FieldInfo]: 14 | new = deepcopy(field) 15 | new.default = ( 16 | None if field.default == pydantic_core.PydanticUndefined else field.default 17 | ) 18 | new.annotation = Optional[field.annotation] # type: ignore # noqa: UP007 19 | return (new.annotation, new) 20 | 21 | 22 | def make_partial_model(model: type[BaseModelT]) -> type[BaseModelT]: 23 | """ 24 | Функция, создающая Pydantic модель из переданной, 25 | делая все поля модели необязательными. 26 | Полезно для схем PATCH запросов. 27 | """ 28 | return pydantic.create_model( # type: ignore 29 | f"Partial{model.__name__}", 30 | __base__=model, 31 | __module__=model.__module__, 32 | **{ 33 | field_name: _make_field_optional(field_info) 34 | for field_name, field_info in model.model_fields.items() 35 | }, 36 | ) 37 | 38 | 39 | # Утилиты для передачи нескольких значений для фильтрации в одном 40 | # квери параметре через запятую 41 | CommaSepQuery = Annotated[ 42 | str | None, Query(description="Несколько значений можно передать через запятую") 43 | ] 44 | 45 | 46 | def comma_sep_q_to_list(query: str | None, type_: type) -> list | None: 47 | """ 48 | :param query: Значение квери параметра 49 | (строка со значениями, перечисленными через запятую) 50 | :param type_: Тип значений в списке для конвертирования 51 | """ 52 | if query: 53 | return [type_(query_value) for query_value in query.split(",")] 54 | return None 55 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastAPI SQLAlchemy Toolkit 2 | site_url: https://e-kondr01.github.io/fastapi-sqlalchemy-toolkit/ 3 | repo_url: https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit 4 | repo_name: fastapi-sqlalchemy-toolkit 5 | 6 | nav: 7 | - About: index.md 8 | - Usage: usage.md 9 | - Filters: filtering.md 10 | - Sorting: sorting.md 11 | - Transactions: transactions.md 12 | - Database Validation: db_validation.md 13 | - Extending: extension.md 14 | - Utilities: utils.md 15 | - Benefits: benefits.md 16 | 17 | plugins: 18 | - search 19 | - i18n: 20 | docs_structure: folder 21 | reconfigure_material: true 22 | reconfigure_search: true 23 | languages: 24 | - locale: en 25 | default: true 26 | name: English 27 | - locale: ru 28 | link: /ru/ 29 | name: Русский 30 | nav: 31 | - Обзор: index.md 32 | - Использование: usage.md 33 | - Фильтрация: filtering.md 34 | - Сортировка: sorting.md 35 | - Транзакции: transactions.md 36 | - Валидация на уровне БД: db_validation.md 37 | - Расширение: extension.md 38 | - Утилиты: utils.md 39 | - Предпосылки: benefits.md 40 | 41 | theme: 42 | name: material 43 | icon: 44 | repo: fontawesome/brands/github 45 | palette: 46 | - media: "(prefers-color-scheme: light)" 47 | scheme: default 48 | primary: deep purple 49 | accent: deep purple 50 | toggle: 51 | icon: material/brightness-7 52 | name: Switch to dark mode 53 | - media: "(prefers-color-scheme: dark)" 54 | scheme: slate 55 | primary: deep purple 56 | accent: deep purple 57 | toggle: 58 | icon: material/brightness-4 59 | name: Switch to light mode 60 | features: 61 | - navigation.path 62 | - navigation.tracking 63 | - navigation.top 64 | - navigation.sections 65 | - search.suggest 66 | - content.code.copy 67 | 68 | markdown_extensions: 69 | - pymdownx.highlight: 70 | anchor_linenums: true 71 | line_spans: __span 72 | pygments_lang_class: true 73 | - pymdownx.details 74 | - pymdownx.inlinehilite 75 | - pymdownx.snippets 76 | - pymdownx.superfences 77 | - toc: 78 | permalink: true 79 | - admonition 80 | - tables 81 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "fastapi_sqlalchemy_toolkit" 7 | version = "0.7.17" 8 | authors = [ 9 | { name="Egor Kondrashov", email="e.kondr01@gmail.com" }, 10 | ] 11 | description = "FastAPI SQLAlchemy Toolkit" 12 | readme = "README.md" 13 | requires-python = ">=3.11" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "Operating System :: OS Independent", 17 | ] 18 | dependencies = [ 19 | "fastapi>=0.100.0", 20 | "sqlalchemy>=2.0.0", 21 | "fastapi_pagination>=0.12.12", 22 | "pydantic>=2.0.0", 23 | ] 24 | 25 | [project.urls] 26 | "Homepage" = "https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit" 27 | 28 | [tool.ruff.lint] 29 | extend-select = [ 30 | # pycodestyle 31 | "E", 32 | "W", 33 | # Pyflakes 34 | "F", 35 | # pyupgrade 36 | "UP", 37 | # flake8-bugbear 38 | "B", 39 | # flake8-simplify 40 | "SIM", 41 | # isort 42 | "I", 43 | # pylint 44 | "PL", 45 | # mccabe 46 | "C901", 47 | # flake8-return 48 | "RET", 49 | # pep8-naming 50 | "N", 51 | # flake8-annotations 52 | "ANN", 53 | # flake8-async 54 | "ASYNC", 55 | # flake8-bandit 56 | "S", 57 | # flake8-blind-except 58 | "BLE", 59 | # flake8-boolean-trap 60 | "FBT", 61 | # flake8-builtins 62 | "A", 63 | # flake8-comprehensions 64 | "C4", 65 | # flake8-logging-format 66 | "G", 67 | # flake8-pie 68 | "PIE", 69 | # flake8-print 70 | "T20", 71 | # flake8-pytest-style 72 | "PT", 73 | # flake8-self 74 | "SLF", 75 | # flake8-type-checking 76 | "TCH", 77 | # flake8-unused-arguments 78 | "ARG", 79 | # flake8-use-pathlib 80 | "PTH", 81 | # flake8-todos 82 | "TD", 83 | # flake8-fixme 84 | "FIX", 85 | # eradicate 86 | "ERA", 87 | # perflint 88 | "PERF", 89 | # Ruff-specific rules 90 | "RUF", 91 | ] 92 | ignore = ["ANN101", "ANN102", "ANN401", "PLR0913", "RUF001", "RUF002", "RUF003", "SLF001", "ERA001"] 93 | 94 | [tool.ruff.lint.per-file-ignores] 95 | "app/tests/*" = ["S101"] 96 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.100.0 2 | sqlalchemy>=2.0.0 3 | fastapi_pagination>=0.12.12 4 | pydantic>=2.0.0 5 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.6.0 2 | mkdocs-material==9.5.26 3 | mkdocs-static-i18n==1.2.3 4 | pymdown-extensions==10.8.1 -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | pylint 4 | mypy -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | pytest>=8.0.0 2 | asyncpg>=0.29.0 3 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | WORKDIR /fastapi-sqlalchemy-toolkit 4 | 5 | RUN pip install --upgrade pip 6 | COPY ../requirements/base.txt . 7 | COPY ../requirements/test.txt . 8 | RUN pip install -r base.txt 9 | RUN pip install -r test.txt 10 | 11 | COPY ../fastapi_sqlalchemy_toolkit ./fastapi_sqlalchemy_toolkit 12 | COPY ../tests ./tests 13 | 14 | ENTRYPOINT ["/bin/sh", "-c", "pytest"] 15 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/e-kondr01/fastapi-sqlalchemy-toolkit/906086c451e9f7d3161fc6255ee8285898ff1021/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | import pytest 4 | from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, AsyncTransaction 5 | 6 | from tests.db import async_session_factory, engine 7 | from tests.models import Base 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | async def create_metadata(): 12 | async with engine.begin() as conn: 13 | await conn.run_sync(Base.metadata.create_all) 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def anyio_backend(): 18 | return "asyncio" 19 | 20 | 21 | @pytest.fixture(scope="session") 22 | async def connection(anyio_backend) -> AsyncGenerator[AsyncConnection, None]: 23 | async with engine.connect() as connection: 24 | yield connection 25 | 26 | 27 | @pytest.fixture() 28 | async def transaction( 29 | connection: AsyncConnection, 30 | ) -> AsyncGenerator[AsyncTransaction, None]: 31 | async with connection.begin() as transaction: 32 | yield transaction 33 | 34 | 35 | @pytest.fixture(scope="session") 36 | async def persistent_session() -> AsyncGenerator[AsyncSession, None]: 37 | async with async_session_factory() as session: 38 | yield session 39 | 40 | 41 | @pytest.fixture() 42 | async def session( 43 | connection: AsyncConnection, transaction: AsyncTransaction 44 | ) -> AsyncGenerator[AsyncSession, None]: 45 | """ 46 | Фикстура с сессией SQLAlchemy, которая откатывает все внесённые изменения 47 | после выхода из функции-теста 48 | """ 49 | async_session = AsyncSession( 50 | bind=connection, 51 | join_transaction_mode="create_savepoint", 52 | expire_on_commit=False, 53 | ) 54 | 55 | async with async_session as session: 56 | yield session 57 | 58 | await transaction.rollback() 59 | -------------------------------------------------------------------------------- /tests/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sets up postgres connection pool. 3 | """ 4 | 5 | from pydantic import PostgresDsn 6 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 7 | 8 | SQLALCHEMY_DATABASE_URL = str( 9 | PostgresDsn.build( 10 | scheme="postgresql+asyncpg", 11 | username="postgres", 12 | password="postgres", 13 | host="postgres", 14 | port=5432, 15 | path="toolkit-test", 16 | ) 17 | ) 18 | 19 | 20 | engine = create_async_engine( 21 | SQLALCHEMY_DATABASE_URL, 22 | echo=False, 23 | connect_args={"server_settings": {"jit": "off"}}, 24 | ) 25 | 26 | async_session_factory = async_sessionmaker(engine, expire_on_commit=False) 27 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | name: toolkit-test 3 | 4 | services: 5 | postgres: 6 | image: postgres:15.2 7 | expose: 8 | - "5430:5432" 9 | environment: 10 | - POSTGRES_DB=toolkit-test 11 | - POSTGRES_USER=postgres 12 | - POSTGRES_PASSWORD=postgres 13 | networks: 14 | - toolkit-test 15 | 16 | networks: 17 | toolkit-test: 18 | name: toolkit-test 19 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID, uuid4 3 | 4 | from fastapi_sqlalchemy_toolkit.model_manager import ModelManager 5 | from pydantic import BaseModel 6 | from sqlalchemy import Column, DateTime, ForeignKey, Table, UniqueConstraint, func 7 | from sqlalchemy.orm import ( 8 | DeclarativeBase, 9 | Mapped, 10 | declared_attr, 11 | mapped_column, 12 | relationship, 13 | ) 14 | 15 | 16 | class Base(DeclarativeBase): 17 | id: Mapped[UUID] = mapped_column( 18 | primary_key=True, 19 | default=uuid4, 20 | server_default=func.gen_random_uuid(), 21 | ) 22 | 23 | created_at: Mapped[datetime] = mapped_column( 24 | DateTime(timezone=True), server_default=func.now() 25 | ) 26 | 27 | @declared_attr.directive 28 | def __tablename__(cls) -> str: 29 | return cls.__name__.lower() 30 | 31 | 32 | categories_parents_association = Table( 33 | "categories_parents_m2m", 34 | Base.metadata, 35 | Column("category_id", ForeignKey("category.id"), primary_key=True), 36 | Column("parent_id", ForeignKey("parent.id"), primary_key=True), 37 | ) 38 | 39 | 40 | class Category(Base): 41 | title: Mapped[str] = mapped_column(unique=True) 42 | 43 | parents: Mapped[list["Parent"]] = relationship( 44 | back_populates="categories", secondary=categories_parents_association 45 | ) 46 | 47 | 48 | class CategorySchema(BaseModel): 49 | title: str 50 | 51 | 52 | class Parent(Base): 53 | __table_args__ = (UniqueConstraint("title", "description"),) 54 | 55 | title: Mapped[str] 56 | slug: Mapped[str] = mapped_column(unique=True) 57 | description: Mapped[str | None] 58 | 59 | children: Mapped[list["Child"]] = relationship(back_populates="parent") 60 | categories: Mapped[list["Category"]] = relationship( 61 | back_populates="parents", secondary=categories_parents_association 62 | ) 63 | 64 | 65 | class ParentSchema(BaseModel): 66 | title: str 67 | slug: str 68 | description: str | None = None 69 | 70 | 71 | class Child(Base): 72 | title: Mapped[str] 73 | slug: Mapped[str] = mapped_column(unique=True) 74 | 75 | parent_id: Mapped[UUID] = mapped_column(ForeignKey("parent.id", ondelete="CASCADE")) 76 | parent: Mapped[Parent] = relationship(back_populates="children") 77 | 78 | 79 | class ChildSchema(BaseModel): 80 | title: str 81 | slug: str 82 | parent_id: UUID 83 | 84 | 85 | child_manager = ModelManager[Child, ChildSchema, ChildSchema]( 86 | Child, default_ordering=Child.title 87 | ) 88 | parent_manager = ModelManager[Parent, ParentSchema, ParentSchema](Parent) 89 | category_manager = ModelManager[Category, CategorySchema, CategorySchema](Category) 90 | -------------------------------------------------------------------------------- /tests/test_public_methods.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | from fastapi import HTTPException 5 | from sqlalchemy import insert, select 6 | from sqlalchemy.exc import MissingGreenlet 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | from sqlalchemy.orm import selectinload 9 | 10 | from tests.models import ( 11 | Category, 12 | CategorySchema, 13 | Child, 14 | Parent, 15 | ParentSchema, 16 | category_manager, 17 | child_manager, 18 | parent_manager, 19 | ) 20 | 21 | 22 | async def test_get(session: AsyncSession): 23 | category = Category(title="test-get-category-title") 24 | session.add(category) 25 | await session.commit() 26 | category = await category_manager.get( 27 | session=session, title="test-get-category-title" 28 | ) 29 | category_to_check = await session.execute( 30 | select(Category).where(Category.title == "test-get-category-title") 31 | ) 32 | assert ( 33 | category == category_to_check.scalars().first() 34 | ), "Got not equal to object in database" 35 | 36 | nonexistent = await session.execute( 37 | select(Category).where(Category.title == "nonexistent-test-get-category-title") 38 | ) 39 | assert nonexistent.scalars().first() is None, "Got nonexistent object" 40 | 41 | 42 | async def test_get_with_options(session: AsyncSession): 43 | parent = Parent( 44 | id=uuid4(), 45 | title="test-parent-title", 46 | slug="test-parent-slug", 47 | description="test-parent-description", 48 | ) 49 | child = Child( 50 | title="test-child-title", 51 | slug="test-child-slug", 52 | parent_id=parent.id, 53 | ) 54 | session.add_all([parent, child]) 55 | await session.commit() 56 | 57 | parent_without_options = await parent_manager.get(session=session) 58 | with pytest.raises(MissingGreenlet): 59 | assert len(parent_without_options.children) == 1 60 | assert parent_without_options.children[0].id == child.id 61 | 62 | parent_with_options = await parent_manager.get( 63 | session=session, id=parent.id, options=selectinload(Parent.children) 64 | ) 65 | assert len(parent_with_options.children) == 1 66 | assert parent_with_options.children[0].id == child.id 67 | 68 | 69 | async def test_get_with_order_by(session: AsyncSession): 70 | await session.execute( 71 | insert(Category), 72 | [ 73 | {"title": "test-category-b"}, 74 | {"title": "test-category-a"}, 75 | {"title": "test-category-c"}, 76 | ], 77 | ) 78 | await session.commit() 79 | 80 | alphabetical_first_category = await category_manager.get( 81 | session=session, order_by=Category.title 82 | ) 83 | assert alphabetical_first_category.title[-1] == "a" 84 | 85 | alphabetical_last_category = await category_manager.get( 86 | session=session, order_by=Category.title.desc() 87 | ) 88 | assert alphabetical_last_category.title[-1] == "c" 89 | 90 | 91 | async def test_get_with_where(session: AsyncSession): 92 | category_title = "test-category-title" 93 | category = Category(title=category_title) 94 | session.add(category) 95 | await session.commit() 96 | 97 | category_with_correct_where = await category_manager.get( 98 | session=session, where=(Category.title == category_title) 99 | ) 100 | assert category_with_correct_where.title == category_title 101 | 102 | category_with_wrong_title = await category_manager.get( 103 | session=session, where=(Category.title == f"{category_title}-wrong") 104 | ) 105 | assert category_with_wrong_title is None 106 | 107 | 108 | async def test_exists(session: AsyncSession): 109 | category = Category(title="test-exists-category-title") 110 | session.add(category) 111 | await session.commit() 112 | category_exists = await category_manager.exists( 113 | session=session, title="test-exists-category-title" 114 | ) 115 | assert category_exists, "Existing object not found" 116 | 117 | category_doesnt_exists = await category_manager.exists( 118 | session=session, title="nonexistent-test-exists-category-title" 119 | ) 120 | assert not category_doesnt_exists, "Nonexistent object found" 121 | 122 | 123 | async def test_exists_with_where(session: AsyncSession): 124 | category_title = "test-category-title" 125 | category = Category(title=category_title) 126 | session.add(category) 127 | await session.commit() 128 | 129 | category_exists = await category_manager.exists( 130 | session=session, where=(Category.title == category_title) 131 | ) 132 | assert category_exists 133 | 134 | category_doesnt_exists = await category_manager.exists( 135 | session=session, where=(Category.title == f"nonexistent-{category_title}") 136 | ) 137 | assert not category_doesnt_exists 138 | 139 | 140 | async def test_get_or_404(session: AsyncSession): 141 | test_category = Category(title="test-category-title") 142 | session.add(test_category) 143 | await session.commit() 144 | 145 | category_from_manager = await category_manager.get_or_404( 146 | session=session, title="test-category-title" 147 | ) 148 | category = await session.execute( 149 | select(Category).where(Category.title == "test-category-title") 150 | ) 151 | assert category_from_manager == category.scalars().first() 152 | 153 | with pytest.raises( 154 | HTTPException, 155 | match="404: category with title=nonexistent-test-get-category-title not found", 156 | ): 157 | await category_manager.get_or_404( 158 | session=session, title="nonexistent-test-get-category-title" 159 | ) 160 | 161 | 162 | async def test_get_or_404_with_options(session: AsyncSession): 163 | parent = Parent( 164 | id=uuid4(), 165 | title="test-parent-title", 166 | slug="test-parent-slug", 167 | description="test-parent-description", 168 | ) 169 | child = Child( 170 | title="test-child-title", 171 | slug="test-child-slug", 172 | parent_id=parent.id, 173 | ) 174 | session.add_all([parent, child]) 175 | await session.commit() 176 | 177 | parent_without_options = await parent_manager.get_or_404(session=session) 178 | with pytest.raises(MissingGreenlet): 179 | assert len(parent_without_options.children) == 1 180 | assert parent_without_options.children[0].id == child.id 181 | 182 | parent_with_options = await parent_manager.get_or_404( 183 | session=session, id=parent.id, options=selectinload(Parent.children) 184 | ) 185 | assert len(parent_with_options.children) == 1 186 | assert parent_with_options.children[0].id == child.id 187 | 188 | 189 | async def test_get_or_404_with_order_by(session: AsyncSession): 190 | await session.execute( 191 | insert(Category), 192 | [ 193 | {"title": "test-category-b"}, 194 | {"title": "test-category-a"}, 195 | {"title": "test-category-c"}, 196 | ], 197 | ) 198 | await session.commit() 199 | 200 | alphabetical_first_category = await category_manager.get_or_404( 201 | session=session, order_by=Category.title 202 | ) 203 | assert alphabetical_first_category.title[-1] == "a" 204 | 205 | alphabetical_last_category = await category_manager.get_or_404( 206 | session=session, order_by=Category.title.desc() 207 | ) 208 | assert alphabetical_last_category.title[-1] == "c" 209 | 210 | 211 | async def test_get_or_404_with_where(session: AsyncSession): 212 | category_title = "test-category-title" 213 | category = Category(title=category_title) 214 | session.add(category) 215 | await session.commit() 216 | 217 | category_with_correct_where = await category_manager.get_or_404( 218 | session=session, where=(Category.title == category_title) 219 | ) 220 | assert category_with_correct_where.title == category_title 221 | 222 | with pytest.raises( 223 | HTTPException, 224 | match="404: category with , category.title = :title_1 not found", 225 | ): 226 | await category_manager.get_or_404( 227 | session=session, where=(Category.title == f"{category_title}-wrong") 228 | ) 229 | 230 | 231 | async def test_exists_or_404(session: AsyncSession): 232 | test_category = Category(title="test-category-title") 233 | session.add(test_category) 234 | await session.commit() 235 | 236 | category_from_manager = await category_manager.exists( 237 | session=session, title="test-category-title" 238 | ) 239 | category = await session.execute( 240 | select(Category).where(Category.title == "test-category-title") 241 | ) 242 | assert category_from_manager == (category.scalars().first() is not None) 243 | 244 | with pytest.raises( 245 | HTTPException, 246 | match="404: category with title=nonexistent-test-exists-category-title does not exist", 247 | ): 248 | await category_manager.exists_or_404( 249 | session=session, title="nonexistent-test-exists-category-title" 250 | ) 251 | 252 | 253 | async def test_exists_or_404_with_where(session: AsyncSession): 254 | category_title = "test-category-title" 255 | category = Category(title=category_title) 256 | session.add(category) 257 | await session.commit() 258 | 259 | category_exists = await category_manager.exists_or_404( 260 | session=session, where=(Category.title == category_title) 261 | ) 262 | assert category_exists 263 | 264 | with pytest.raises( 265 | HTTPException, 266 | match="404: category with , category.title = :title_1 does not exist", 267 | ): 268 | await category_manager.exists_or_404( 269 | session=session, where=(Category.title == f"nonexistent-{category_title}") 270 | ) 271 | 272 | 273 | async def test_create(session: AsyncSession): 274 | created = await category_manager.create( 275 | session=session, in_obj=CategorySchema(title="test-create-category-title") 276 | ) 277 | category_to_check = await session.execute( 278 | select(Category).where(Category.title == "test-create-category-title") 279 | ) 280 | assert ( 281 | created == category_to_check.scalars().first() 282 | ), "Created not equal to object in database" 283 | 284 | 285 | async def test_create_unique_filed_validation(session: AsyncSession): 286 | await category_manager.create( 287 | session=session, in_obj=CategorySchema(title="test-create-category-title") 288 | ) 289 | with pytest.raises( 290 | HTTPException, 291 | match="422: category c title test-create-category-title уже существует", 292 | ): 293 | await category_manager.create( 294 | session=session, title="test-create-category-title" 295 | ) 296 | 297 | 298 | async def test_create_without_commit(session: AsyncSession): 299 | category_title = "test-category-title" 300 | no_commit_category_title = f"{category_title}-no-commit" 301 | 302 | await category_manager.create( 303 | session=session, in_obj=CategorySchema(title=category_title), commit=True 304 | ) 305 | await category_manager.create( 306 | session=session, 307 | in_obj=CategorySchema(title=no_commit_category_title), 308 | commit=False, 309 | ) 310 | 311 | await session.close() 312 | 313 | async with session: 314 | category_to_check_without_commit = await session.execute( 315 | select(Category).where(Category.title == no_commit_category_title) 316 | ) 317 | assert category_to_check_without_commit.scalars().first() is None 318 | 319 | category_to_check_with_commit = await session.execute( 320 | select(Category).where(Category.title == category_title) 321 | ) 322 | assert category_to_check_with_commit.scalars().first().title == category_title 323 | 324 | 325 | async def test_update(session: AsyncSession): 326 | category = Category(title="test-update-category-title") 327 | session.add(category) 328 | await session.commit() 329 | category = await category_manager.get( 330 | session=session, title="test-update-category-title" 331 | ) 332 | updated = await category_manager.update( 333 | session=session, 334 | db_obj=category, 335 | in_obj=CategorySchema(title="UPDATED-test-update-category-title"), 336 | ) 337 | category_to_check = await session.execute( 338 | select(Category).where(Category.title == "UPDATED-test-update-category-title") 339 | ) 340 | assert ( 341 | updated == category_to_check.scalars().first() 342 | ), "Updated not equal to object in database" 343 | 344 | 345 | async def test_update_unique_filed_validation(session: AsyncSession): 346 | await session.execute( 347 | insert(Category), 348 | [{"title": "test-category-title1"}, {"title": "test-category-title2"}], 349 | ) 350 | await session.commit() 351 | category_to_update = await session.execute( 352 | select(Category).where(Category.title == "test-category-title2") 353 | ) 354 | with pytest.raises( 355 | HTTPException, 356 | match="422: category c title test-category-title1 уже существует", 357 | ): 358 | await category_manager.update( 359 | session=session, 360 | db_obj=category_to_update.scalars().first(), 361 | in_obj=CategorySchema(title="test-category-title1"), 362 | ) 363 | 364 | 365 | async def test_update_without_exclude_unset(session: AsyncSession): 366 | parent_title = "test-parent-title" 367 | parent_slug = "test-parent-slug" 368 | parent_description = "test-parent-description" 369 | parent = await parent_manager.create( 370 | session=session, 371 | in_obj=ParentSchema( 372 | title=parent_title, 373 | slug="test-parent-slug", 374 | description=parent_description, 375 | ), 376 | id=uuid4(), 377 | ) 378 | assert parent.description == parent_description 379 | 380 | updated_parent = await parent_manager.update( 381 | session=session, 382 | db_obj=parent, 383 | in_obj=ParentSchema(title=f"{parent_title}-1", slug=parent_slug), 384 | ) 385 | assert updated_parent.title == f"{parent_title}-1" 386 | assert updated_parent.description == parent_description 387 | 388 | updated_parent_without_exclude_unset = await parent_manager.update( 389 | session=session, 390 | db_obj=parent, 391 | in_obj=ParentSchema(title=f"{parent_title}-2", slug=parent_slug), 392 | exclude_unset=False, 393 | ) 394 | assert updated_parent_without_exclude_unset.title == f"{parent_title}-2" 395 | assert updated_parent.description is None 396 | 397 | 398 | async def test_delete(session: AsyncSession): 399 | category = Category(title="test-delete-category-title") 400 | session.add(category) 401 | await session.commit() 402 | category = await category_manager.get( 403 | session=session, title="test-delete-category-title" 404 | ) 405 | await category_manager.delete( 406 | session=session, 407 | db_obj=category, 408 | ) 409 | category_to_check = await session.execute( 410 | select(Category).where(Category.title == "test-delete-category-title") 411 | ) 412 | assert category_to_check.first() is None 413 | 414 | 415 | async def test_count(session: AsyncSession): 416 | await session.execute( 417 | insert(Category), 418 | [ 419 | {"title": "test-count-category-title1"}, 420 | {"title": "test-count-category-title2"}, 421 | {"title": "test-count-category-title3"}, 422 | ], 423 | ) 424 | await session.commit() 425 | amount = await category_manager.count(session=session) 426 | assert amount == 3, "Incorrect count result" 427 | 428 | 429 | async def count_with_where(session: AsyncSession): 430 | same_title = "test-count-category-title" 431 | await session.execute( 432 | insert(Parent), 433 | [ 434 | { 435 | "title": same_title, 436 | "slug": "test-child-slug1", 437 | "description": "test-parent-description1", 438 | }, 439 | { 440 | "title": same_title, 441 | "slug": "test-parent-slug2", 442 | "description": "test-parent-description2", 443 | }, 444 | { 445 | "title": f"not-{same_title}", 446 | "slug": "test-parent-slug3", 447 | "description": "test-parent-description3", 448 | }, 449 | ], 450 | ) 451 | await session.commit() 452 | 453 | amount = await parent_manager.count( 454 | session=session, where=(Parent.title == same_title) 455 | ) 456 | assert amount == 2, "Incorrect count result" 457 | 458 | 459 | async def test_filter_with_where(session: AsyncSession): 460 | await session.execute( 461 | insert(Category), 462 | [ 463 | {"title": "test-list-category-title1"}, 464 | {"title": "test-list-category-title2"}, 465 | {"title": "test-list-category-title3"}, 466 | ], 467 | ) 468 | await session.commit() 469 | 470 | category_list = await category_manager.filter( 471 | session=session, 472 | where=( 473 | Category.title.in_( 474 | ["test-list-category-title1", "test-list-category-title2"] 475 | ) 476 | ), 477 | ) 478 | categories = await session.execute( 479 | select(Category).where( 480 | Category.title.in_( 481 | ["test-list-category-title1", "test-list-category-title2"] 482 | ) 483 | ) 484 | ) 485 | 486 | assert len(category_list) == 2 487 | assert category_list == categories.scalars().all() 488 | 489 | 490 | async def test_filter_with_simple_filter(session: AsyncSession): 491 | await session.execute( 492 | insert(Category), 493 | [ 494 | {"title": "test-list-category-title1"}, 495 | {"title": "test-list-category-title2"}, 496 | {"title": "test-list-category-title3"}, 497 | ], 498 | ) 499 | await session.commit() 500 | 501 | category_list = await category_manager.filter( 502 | session=session, title="test-list-category-title1" 503 | ) 504 | categories = await session.execute( 505 | select(Category).where(Category.title == "test-list-category-title1") 506 | ) 507 | 508 | assert len(category_list) == 1 509 | assert category_list == categories.scalars().all() 510 | 511 | 512 | async def test_filter_with_order_by(session: AsyncSession): 513 | same_title = "test-count-category-title" 514 | await session.execute( 515 | insert(Parent), 516 | [ 517 | { 518 | "title": f"not-{same_title}", 519 | "slug": "test-parent-slug3", 520 | "description": "test-parent-description-b", 521 | }, 522 | { 523 | "title": same_title, 524 | "slug": "test-parent-slug2", 525 | "description": "test-parent-description-c", 526 | }, 527 | { 528 | "title": same_title, 529 | "slug": "test-child-slug1", 530 | "description": "test-parent-description-a", 531 | }, 532 | ], 533 | ) 534 | await session.commit() 535 | 536 | parents = await parent_manager.filter( 537 | session=session, title=same_title, order_by=Parent.description 538 | ) 539 | assert len(parents) == 2 540 | assert parents[0].description[-1] == "a" 541 | assert parents[1].description[-1] == "c" 542 | 543 | 544 | async def test_filter_with_options(session: AsyncSession): 545 | parent = Parent( 546 | id=uuid4(), 547 | title="test-parent-title-1", 548 | slug="test-parent-slug-1", 549 | description=None, 550 | ) 551 | parent_without_children = Parent( 552 | id=uuid4(), 553 | title="test-parent-title-2", 554 | slug="test-parent-slug-2", 555 | description="test-parent-description-2", 556 | ) 557 | child = Child( 558 | title="test-child-title", 559 | slug="test-child-slug", 560 | parent_id=parent.id, 561 | ) 562 | session.add_all([parent, parent_without_children, child]) 563 | await session.commit() 564 | 565 | parents_without_options = await parent_manager.filter( 566 | session=session, description=None 567 | ) 568 | with pytest.raises(MissingGreenlet): 569 | assert parents_without_options[0].children[0].id == child.id 570 | 571 | parents_with_options = await parent_manager.filter( 572 | session=session, 573 | id=parent.id, 574 | options=selectinload(Parent.children), 575 | description=None, 576 | ) 577 | assert len(parents_with_options) == 1 578 | assert parents_with_options[0].children[0].id == child.id 579 | 580 | 581 | async def test_list_with_simple_filter_expressions(session: AsyncSession): 582 | await session.execute( 583 | insert(Category), 584 | [ 585 | {"title": "test-list-category-title1"}, 586 | {"title": "test-list-category-title2"}, 587 | {"title": "test-list-category-title3"}, 588 | ], 589 | ) 590 | await session.commit() 591 | 592 | category_list = await category_manager.list( 593 | session=session, 594 | filter_expressions={ 595 | Category.title.in_: [ 596 | "test-list-category-title1", 597 | "test-list-category-title2", 598 | ] 599 | }, 600 | ) 601 | categories = await session.execute( 602 | select(Category).where( 603 | Category.title.in_( 604 | ["test-list-category-title1", "test-list-category-title2"] 605 | ) 606 | ) 607 | ) 608 | 609 | assert len(category_list) == 2 610 | assert category_list == categories.scalars().all() 611 | 612 | 613 | async def test_list_with_simple_filter(session: AsyncSession): 614 | await session.execute( 615 | insert(Category), 616 | [ 617 | {"title": "test-list-category-title1"}, 618 | {"title": "test-list-category-title2"}, 619 | {"title": "test-list-category-title3"}, 620 | ], 621 | ) 622 | await session.commit() 623 | 624 | category_list = await category_manager.list( 625 | session=session, title="test-list-category-title1" 626 | ) 627 | categories = await session.execute( 628 | select(Category).where(Category.title == "test-list-category-title1") 629 | ) 630 | 631 | assert len(category_list) == 1 632 | assert category_list == categories.scalars().all() 633 | 634 | 635 | async def test_list_with_order_by(session: AsyncSession): 636 | await session.execute( 637 | insert(Parent), 638 | [ 639 | { 640 | "title": "test-parent-title1", 641 | "slug": "test-parent-slug1", 642 | "description": "test-parent-description-b", 643 | }, 644 | { 645 | "title": "test-parent-title2", 646 | "slug": "test-parent-slug2", 647 | "description": "test-parent-description-c", 648 | }, 649 | { 650 | "title": "test-parent-title3", 651 | "slug": "test-child-slug3", 652 | "description": "test-parent-description-a", 653 | }, 654 | ], 655 | ) 656 | await session.commit() 657 | 658 | parents = await parent_manager.list(session=session, order_by=Parent.description) 659 | assert len(parents) == 3 660 | assert parents[0].description[-1] == "a" 661 | assert parents[-1].description[-1] == "c" 662 | 663 | 664 | async def test_list_with_options(session: AsyncSession): 665 | parent = Parent( 666 | id=uuid4(), 667 | title="test-parent-title-1", 668 | slug="test-parent-slug-1", 669 | description="test-parent-description-1", 670 | ) 671 | parent_without_children = Parent( 672 | id=uuid4(), 673 | title="test-parent-title-2", 674 | slug="test-parent-slug-2", 675 | description="test-parent-description-2", 676 | ) 677 | child = Child( 678 | title="test-child-title", 679 | slug="test-child-slug", 680 | parent_id=parent.id, 681 | ) 682 | session.add_all([parent, parent_without_children, child]) 683 | await session.commit() 684 | 685 | parents_without_options = await parent_manager.list(session=session) 686 | with pytest.raises(MissingGreenlet): 687 | assert parents_without_options[0].children[0].id == child.id 688 | 689 | parents_with_options = await parent_manager.list( 690 | session=session, 691 | id=parent.id, 692 | options=selectinload(Parent.children), 693 | ) 694 | assert len(parents_with_options) == 1 695 | assert parents_with_options[0].children[0].id == child.id 696 | 697 | 698 | async def test_list_with_where(session: AsyncSession): 699 | same_title = "test-count-category-title" 700 | await session.execute( 701 | insert(Parent), 702 | [ 703 | { 704 | "title": same_title, 705 | "slug": "test-child-slug1", 706 | "description": "test-parent-description1", 707 | }, 708 | { 709 | "title": same_title, 710 | "slug": "test-parent-slug2", 711 | "description": "test-parent-description2", 712 | }, 713 | { 714 | "title": f"not-{same_title}", 715 | "slug": "test-parent-slug3", 716 | "description": "test-parent-description3", 717 | }, 718 | ], 719 | ) 720 | await session.commit() 721 | 722 | parents = await parent_manager.list( 723 | session=session, where=(Parent.title == same_title) 724 | ) 725 | assert len(parents) == 2, "Incorrect result amount" 726 | for parent in parents: 727 | assert parent.title == same_title 728 | 729 | 730 | async def test_create_unique_constraint_validation(session: AsyncSession): 731 | await parent_manager.create( 732 | session=session, 733 | in_obj=ParentSchema( 734 | title="test-parent-title", 735 | slug="test-parent-slug1", 736 | description="test-parent-description", 737 | ), 738 | ) 739 | with pytest.raises( 740 | HTTPException, match="422: parent с такими title, description уже существует." 741 | ): 742 | await parent_manager.create( 743 | session=session, 744 | in_obj=ParentSchema( 745 | title="test-parent-title", 746 | slug="test-parent-slug2", 747 | description="test-parent-description", 748 | ), 749 | ) 750 | 751 | 752 | async def test_update_unique_constraint_validation(session: AsyncSession): 753 | await session.execute( 754 | insert(Parent), 755 | [ 756 | { 757 | "title": "test-list-parent-title", 758 | "slug": "test-child-slug1", 759 | "description": "test-parent-description1", 760 | }, 761 | { 762 | "title": "test-list-parent-title", 763 | "slug": "test-parent-slug2", 764 | "description": "test-parent-description2", 765 | }, 766 | ], 767 | ) 768 | await session.commit() 769 | parent_to_update = await session.execute( 770 | select(Parent).where(Parent.slug == "test-parent-slug2") 771 | ) 772 | with pytest.raises( 773 | HTTPException, match="422: parent с такими title, description уже существует." 774 | ): 775 | await parent_manager.update( 776 | session=session, 777 | db_obj=parent_to_update.scalars().first(), 778 | in_obj=ParentSchema( 779 | title="test-list-parent-title", 780 | slug="test-parent-slug2", 781 | description="test-parent-description1", 782 | ), 783 | ) 784 | 785 | 786 | async def test_list_with_related_model_field_filter(session: AsyncSession): 787 | first_parent_id = uuid4() 788 | second_parent_id = uuid4() 789 | await parent_manager.create( 790 | session=session, 791 | in_obj=ParentSchema( 792 | title="test-parent-title", 793 | slug="test-parent-slug1", 794 | description="test-parent-description1", 795 | ), 796 | id=first_parent_id, 797 | ) 798 | await parent_manager.create( 799 | session=session, 800 | in_obj=ParentSchema( 801 | title="test-parent-title", 802 | slug="test-parent-slug2", 803 | description="test-parent-description2", 804 | ), 805 | id=second_parent_id, 806 | ) 807 | await session.execute( 808 | insert(Child), 809 | [ 810 | { 811 | "title": "test-list-child-title1", 812 | "slug": "test-child-slug1", 813 | "parent_id": first_parent_id, 814 | }, 815 | { 816 | "title": "test-list-child-title2", 817 | "slug": "test-child-slug2", 818 | "parent_id": first_parent_id, 819 | }, 820 | { 821 | "title": "test-list-child-title3", 822 | "slug": "test-child-slug3", 823 | "parent_id": second_parent_id, 824 | }, 825 | ], 826 | ) 827 | await session.commit() 828 | 829 | children_list = await child_manager.list( 830 | session=session, 831 | filter_expressions={Parent.slug: "test-parent-slug1"}, 832 | ) 833 | children = await session.execute( 834 | select(Child).join(Parent).where(Parent.slug == "test-parent-slug1") 835 | ) 836 | 837 | assert len(children_list) == 2 838 | assert children_list == children.scalars().all() 839 | 840 | 841 | async def test_null_filtration(session: AsyncSession): 842 | await session.execute( 843 | insert(Parent), 844 | [ 845 | { 846 | "title": "test-list-parent-title1", 847 | "slug": "test-child-slug1", 848 | "description": "test-parent-description", 849 | }, 850 | { 851 | "title": "test-list-parent-title2", 852 | "slug": "test-parent-slug2", 853 | }, 854 | { 855 | "title": "test-list-parent-title3", 856 | "slug": "test-parent-slug3", 857 | }, 858 | ], 859 | ) 860 | await session.commit() 861 | parents = await parent_manager.list( 862 | session=session, nullable_filter_expressions={Parent.description: "null"} 863 | ) 864 | parents_list = await session.execute( 865 | select(Parent).where(Parent.description.is_(None)) 866 | ) 867 | 868 | assert len(parents) == 2 869 | assert parents == parents_list.scalars().all() 870 | --------------------------------------------------------------------------------