fastapi-sqlalchemy-toolkit 0.0.2.3__tar.gz → 0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/PKG-INFO +136 -67
  2. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/README.md +135 -66
  3. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/.env +2 -2
  4. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/api/api.py +1 -1
  5. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/api/endpoints/child.py +27 -10
  6. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/config.py +12 -10
  7. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/db.py +2 -4
  8. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/deps.py +1 -1
  9. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/main.py +1 -2
  10. fastapi_sqlalchemy_toolkit-0.1/examples/app/managers.py +8 -0
  11. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/models.py +2 -1
  12. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/schemas.py +5 -1
  13. fastapi_sqlalchemy_toolkit-0.1/fastapi_sqlalchemy_toolkit/__init__.py +4 -0
  14. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/fastapi_sqlalchemy_toolkit/filters.py +2 -2
  15. fastapi_sqlalchemy_toolkit-0.0.2.3/fastapi_sqlalchemy_toolkit/db_crud.py → fastapi_sqlalchemy_toolkit-0.1/fastapi_sqlalchemy_toolkit/model_manager.py +11 -10
  16. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/fastapi_sqlalchemy_toolkit/utils.py +9 -6
  17. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/pyproject.toml +1 -1
  18. fastapi_sqlalchemy_toolkit-0.0.2.3/examples/app/api/endpoints/parent.py +0 -0
  19. fastapi_sqlalchemy_toolkit-0.0.2.3/examples/app/db_crud.py +0 -7
  20. fastapi_sqlalchemy_toolkit-0.0.2.3/fastapi_sqlalchemy_toolkit/__init__.py +0 -4
  21. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/.flake8 +0 -0
  22. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/.gitignore +0 -0
  23. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/LICENSE +0 -0
  24. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/__init__.py +0 -0
  25. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/api/__init__.py +0 -0
  26. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/examples/app/api/endpoints/__init__.py +0 -0
  27. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/fastapi_sqlalchemy_toolkit/base_model.py +0 -0
  28. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/fastapi_sqlalchemy_toolkit/ordering.py +0 -0
  29. {fastapi_sqlalchemy_toolkit-0.0.2.3 → fastapi_sqlalchemy_toolkit-0.1}/requirements.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi_sqlalchemy_toolkit
3
- Version: 0.0.2.3
3
+ Version: 0.1
4
4
  Summary: FastAPI SQLAlchemy Toolkit
5
5
  Project-URL: Homepage, https://github.com/e-kondr01/fastapi-sqlalchemy-toolkit
6
6
  Author-email: Egor Kondrashov <e.kondr01@gmail.com>
@@ -49,61 +49,59 @@ pip install fastapi-sqlalchemy-toolkit
49
49
 
50
50
  Пример использования `fastapi-sqlalchemy-toolkit` доступен в директории `examples/app`
51
51
 
52
- ## Получение DB CRUD
52
+ ## Инициализация ModelManager
53
53
 
54
- Для использования `fastapi-sqlaclhemy-toolkit` необходимо создать экземпляр `BaseCRUD` для своей модели:
54
+ Для использования `fastapi-sqlaclhemy-toolkit` необходимо создать экземпляр `ModelManager` для своей модели:
55
55
 
56
56
  ```python
57
- from fastapi_sqlalchemy_toolkit import BaseCRUD
57
+ from fastapi_sqlalchemy_toolkit import ModelManager
58
58
 
59
59
  from .models import MyModel
60
60
  from .schemas import MyModelCreateSchema, MyModelUpdateSchema
61
61
 
62
- my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel)
62
+ my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel)
63
63
  ```
64
64
 
65
- При инициализации DB CRUD также можно задать параметр `fk_mapping`, необходимый для валидации внешних ключей.
66
- `fk_mapping` — это словарь, в котором ключи — это названия полей внешних ключей, а значения — модели SQLAlchemy, на которые эти ключи ссылаются.
65
+ При инициализации ModelManager можно задать параметр `fk_mapping`, необходимый для валидации внешних ключей.
66
+ `fk_mapping` — это словарь, в котором ключи — это названия внешних ключей, а значения — модели SQLAlchemy, на которые эти ключи ссылаются.
67
67
 
68
68
  ```python
69
- from fastapi_sqlalchemy_toolkit import BaseCRUD
69
+ from fastapi_sqlalchemy_toolkit import ModelManager
70
70
 
71
71
  from .models import MyModel, MyParentModel
72
72
  from .schemas import MyModelCreateSchema, MyModelUpdateSchema
73
73
 
74
- my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
75
- MyModel,
76
- fk_mapping={"parent_id": MyParentModel}
74
+ my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
75
+ MyModel, fk_mapping={"parent_id": MyParentModel}
77
76
  )
78
77
  ```
79
78
 
80
- Атрибут `default_ordering` определяет сортировку по умолчанию при получении списков объектов. В него нужно передать поле основной модели.
79
+ Атрибут `default_ordering` определяет сортировку по умолчанию при получении списка объектов. В него нужно передать поле основной модели.
81
80
 
82
81
  ```python
83
- from fastapi_sqlalchemy_toolkit import BaseCRUD
82
+ from fastapi_sqlalchemy_toolkit import ModelManager
84
83
 
85
84
  from .models import MyModel
86
85
  from .schemas import MyModelCreateSchema, MyModelUpdateSchema
87
86
 
88
- my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
89
- MyModel,
90
- default_ordering=MyModel.title
87
+ my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
88
+ MyModel, default_ordering=MyModel.title
91
89
  )
92
90
  ```
93
91
 
94
- ## Доступные методы `BaseCRUD`
92
+ ## Доступные методы `ModelManager`
95
93
 
96
- Ниже перечислены доступные CRUD методы, предоставляемые `BaseCRUD`.
94
+ Ниже перечислены CRUD методы, предоставляемые `ModelManager`.
97
95
  Документация параметров, принимаемых методами, находится в докстрингах методов.
98
96
 
99
- - `create` - создание объекта (также выполняет валидацию значений полей на уровне БД)
97
+ - `create` - создание объекта; выполняет валидацию значений полей на уровне БД
100
98
  - `get` - получение объекта
101
- - `get_or_404` - получение объекта или ошибки 404
99
+ - `get_or_404` - получение объекта или ошибки HTTP 404
102
100
  - `exists` - проверка существования объекта
103
- - `paginated_filter` - получение списка объектов с пагинацией через `fastapi_pagination`
104
- - `filter` - получение списка объектов
101
+ - `paginated_filter` - получение списка объектов с фильтрами и пагинацией через `fastapi_pagination`
102
+ - `filter` - получение списка объектов с фильтрами
105
103
  - `count` - получение количества объектов
106
- - `update` - обновление объекта (также выполняет валидацию значений полей на уровне БД)
104
+ - `update` - обновление объекта; выполняет валидацию значений полей на уровне БД
107
105
  - `delete` - удаление объекта
108
106
 
109
107
  ## Фильтрация
@@ -113,69 +111,86 @@ my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
113
111
  ```python
114
112
  from typing import Annotated
115
113
  from uuid import UUID
114
+
116
115
  from fastapi import APIRouter, Depends, Response, status
117
116
  from sqlalchemy import select
118
117
  from sqlalchemy.ext.asyncio import AsyncSession
118
+
119
119
  from app.deps import get_async_session
120
- from app.models import MyModel
120
+ from app.models import MyModel, MyParentModel
121
121
  from app.schemas import MyObjectListSchema
122
122
 
123
-
124
123
  router = APIRouter()
125
124
  CurrentSession = Annotated[AsyncSession, Depends(get_async_session)]
126
125
 
126
+
127
127
  @router.get("/my-objects")
128
128
  async def get_my_objects(
129
129
  session: CurrentSession,
130
130
  user_id: UUID | None = None,
131
- name: str | None = None
131
+ name: str | None = None,
132
+ parent_name: str | None = None,
132
133
  ) -> list[MyObjectListSchema]:
133
134
  stmt = select(MyModel)
134
135
  if user_id is not None:
135
136
  stmt = stmt.filter_by(user_id=user_id)
136
137
  if name is not None:
137
- stmt = stmt.filter_by(name=name)
138
+ stmt = stmt.filter(MyModel.name.ilike == name)
139
+ if parent_name is not None:
140
+ stmt = stmt.join(MyModel.parent)
141
+ stmt = stmt.filter(ParentModel.name.ilike == parent_name)
138
142
  result = await session.execute(stmt)
139
- return results.scalars().all()
143
+ return result.scalars().all()
140
144
  ```
141
- Как можно заметить, присутствует дубликация шаблонного кода. А это только строгие сравнения, и поля находятся на основной модели.
145
+ Как можно заметить, для реализации фильтрации необходима дубликация шаблонного кода.
142
146
 
143
147
  В `fastapi-sqlalchemy-toolkit` этот эндпоинт выглядит так:
144
148
 
145
149
  ```python
146
- from app.db_crud import my_object_db
150
+ from fastapi_sqlalchemy_toolkit import FieldFilter
151
+
152
+ from app.managers import my_object_manager
147
153
 
148
154
  @router.get("/my-objects")
149
155
  async def get_my_objects(
150
156
  session: CurrentSession,
151
157
  user_id: UUID | None = None,
152
- name: str | None = None
158
+ name: str | None = None,
159
+ parent_name: str | None = None,
153
160
  ) -> list[MyObjectListSchema]:
154
- return await my_object_db.filter(session, user_id=user_id, name=name)
161
+ return await my_object_manager.filter(
162
+ session,
163
+ user_id=user_id,
164
+ name=FieldFilter(name, operator="ilike"),
165
+ parent_name=FieldFilter(parent_name, operator="ilike", model=ParentModel),
166
+ )
155
167
  ```
156
168
  ### Использование FieldFilter
157
169
  Дополнительные возможности декларативной фильтрации поддерживаются использованием класса `FieldFilter`.
158
170
  `FieldFilter` позволяет:
159
171
  - фильтровать по значениям полей связанных моделей при установке атрибута `model`.
160
- При этом `BaseCRUD` автоматически сделает необходимые join'ы, если это модель, которая напрямую связана с главной
161
- - использовать любые операторы сравнения через атрибут `operator`
172
+ При этом `ModelManager` автоматически сделает необходимые join'ы, если это модель, которая напрямую связана с главной
173
+ - использовать любые методы и атрибуты полей SQLAlchemy через атрибут `operator`
162
174
  - применять функции SQLAlchemy к полям (например, `date()`) через атрибут `func`
163
175
 
164
176
  ```python
165
- from fastapi_sqlalchemy_toolkit import FieldFilter
166
- from app.db_crud import parent_db
177
+ from app.managers import parent_manager
167
178
  from app.models import Child
168
179
 
169
- await parent_db.filter(session, child_title=FieldFilter(value=child_title, model=Child, operator="ilike"))
180
+ from fastapi_sqlalchemy_toolkit import FieldFilter
181
+
182
+ await parent_manager.filter(
183
+ session, child_title=FieldFilter(child_title, model=Child, operator="ilike")
184
+ )
170
185
  ```
171
186
  ### Фильтрация по обратным связям
172
187
  Также в методах `filter` и `paginated_filter` есть поддержка фильтрации
173
188
  по обратным связям (`relationship()` в направлении один ко многим) с использованием метода `.any()`.
174
189
 
175
190
  ```python
176
- # Если Parent.children -- это связь один ко многим
177
- await parent_db.filter(session, children=[1, 2])
178
- # Вернёт объекты Parent, у которых есть связь с Child с id 1 или 2
191
+ # Если ParentModel.children -- это связь один ко многим
192
+ await parent_manager.filter(session, children=[1, 2])
193
+ # Вернёт объекты Parent, у которых есть связь с ChildModel с id 1 или 2
179
194
  ```
180
195
  ### Фильтрация по null
181
196
  Для того чтобы осуществить фильтрацию по `null`, квери параметр должен принимать
@@ -187,18 +202,19 @@ from fastapi_sqlalchemy_toolkit import NullableQuery
187
202
  @router.get("")
188
203
  async def get_children(
189
204
  session: CurrentSession,
190
- title: NullableQuery | UUID | None = None,
205
+ activated_at: NullableQuery | datetime.datetime | None = None,
191
206
  ) -> Page[ChildRetrieveSchema]:
207
+ ...
192
208
  ```
193
- `NullableQuery` это пустая строка. То есть запрос с фильтрацией по `title == None` должен выглядеть так:
194
- `GET /children?title=`
209
+ `NullableQuery` -- это пустая строка. Запрос с фильтрацией по `activated_at == None` должен выглядеть так:
210
+ `GET /children?activated_at=`
195
211
 
196
212
  *Почему так?*
197
213
 
198
214
 
199
- При запросе `GET /children?title=alex` ожидается, что будут возвращены
200
- объекты с `title == alex`, но при GET `/children` мы не ожидаем, что будут возвращены
201
- объекты с `title == None`.
215
+ При запросе `GET /children?activated_at=2023-08-01` ожидается, что будут возвращены
216
+ объекты с `activated_at == 2023-08-01`, но при запросе GET `/children` мы не ожидаем, что будут возвращены
217
+ объекты с `activated_at == None` (ожидаемым поведением является отсутствие фильтрации по `activated_at`).
202
218
 
203
219
  Если в эндпоинте FastAPI определён необязательный квери параметр, и он не передан
204
220
  в запросе, то значение этого параметра будет равно `None`. Чтобы не возникала описанная выше некорректная фильтрация, фильтр
@@ -210,7 +226,7 @@ async def get_children(
210
226
  а также по полям связанных моделей. При этом необходимые для сортировки по полям
211
227
  связанных моделей join'ы будут сделаны автоматически.
212
228
 
213
- Для применения декларативной сортировки нужно сделать следующее:
229
+ Для применения декларативной сортировки нужно:
214
230
  1. Определить список полей, по которым доступна фильтрация. Поле может быть
215
231
  строкой, если это поле основной модели, или атрибутом модели, если оно находится
216
232
  на связанной модели.
@@ -218,12 +234,12 @@ async def get_children(
218
234
  ```python
219
235
  from app.models import Parent
220
236
 
221
- child_ordering_fields = [
237
+ child_ordering_fields = (
222
238
  "title",
223
239
  "created_at",
224
240
  Parent.title,
225
241
  Parent.created_at
226
- ]
242
+ )
227
243
  ```
228
244
 
229
245
  Для каждого из указаных полей будет доступна сортировка по возрастанию и убыванию.
@@ -243,39 +259,40 @@ async def get_child_objects(
243
259
  session: CurrentSession,
244
260
  order_by: ordering_dep(child_ordering_fields)
245
261
  ) -> list[ChildListSchema]
262
+ ...
246
263
  ```
247
264
 
248
- 3. Передать параметр сортировки как параметр `order_by` в методы `BaseCRUD`
265
+ 3. Передать параметр сортировки как параметр `order_by` в методы `ModelManager`
249
266
 
250
267
  ```python
251
- return await child_db.filter(session=session, order_by=order_by)
268
+ return await child_manager.filter(session=session, order_by=order_by)
252
269
  ```
253
270
 
254
271
 
255
272
  ## Расширение
256
- Методы `BaseCRUD` легко расширить дополнительной логикой.
273
+ Методы `ModelManager` легко расширить дополнительной логикой.
257
274
 
258
275
 
259
- В первую очередь необходимо определить свой класс DB CRUD, унаследовав его от `BaseCRUD`
276
+ В первую очередь необходимо определить свой класс ModelManager:
260
277
 
261
278
  ```python
262
- from fastapi_sqlalchemy_toolkit import BaseCRUD
279
+ from fastapi_sqlalchemy_toolkit import ModelManager
263
280
 
264
281
 
265
- class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](BaseCRUD):
282
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager):
266
283
  ...
267
284
  ```
268
285
  ### Дополнительная валидация
269
286
  Дополнительную валидацию можно добавить, переопределив метод `validate`:
270
287
 
271
288
  ```python
272
- class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](BaseCRUD):
289
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager):
273
290
  async def validate_parent_type(self, session: AsyncSession, validated_data: ModelDict) -> None:
274
291
  """
275
292
  Проверяет тип выбранного объекта Parent
276
293
  """
277
294
  # объект Parent с таким ID точно есть, так как это проверяется ранее в super().validate
278
- parent = await parent_db.get(session, id=in_obj["parent_id"])
295
+ parent = await parent_manager.get(session, id=in_obj["parent_id"])
279
296
  if parent.type != ParentTypes.CanHaveChildren:
280
297
  raise HTTPException(
281
298
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -285,19 +302,36 @@ class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](BaseCRUD):
285
302
  async def run_db_validation(
286
303
  self,
287
304
  session: AsyncSession,
288
- db_obj: ModelType | None = None,
305
+ db_obj: MyModel | None = None,
289
306
  in_obj: ModelDict | None = None,
290
307
  ) -> ModelDict:
291
308
  validated_data = await super().validate(session, db_obj, in_obj)
292
309
  await self.validate_parent_type(session, validated_data)
293
310
  return validated_data
294
311
  ```
312
+
313
+ ### Дополнительная бизнес логика при CRUD операциях
314
+ Если при CRUD операциях с моделью необходимо выполнить какую-то дополнительную бизнес логику,
315
+ это можно сделать, переопределив соответствующие методы ModelManager:
316
+
317
+ ```python
318
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager):
319
+ async def create(
320
+ self, *args, background_tasks: BackgroundTasks | None = None, **kwargs
321
+ ) -> MyModel:
322
+ created = await super().create(*args, **kwargs)
323
+ background_tasks.add_task(send_email, created.id)
324
+ return created
325
+ ```
326
+
327
+ Такой подход соответствует принципу "Fat Models, Skinny Views" из Django.
328
+
295
329
  ### Использование декларативных фильтров в нестандартных списочных запросах
296
330
  Если необходимо получить не просто список объектов, но и какие-то другие поля (допустим, кол-во дочерних объектов)
297
- или агрегации, но также необходима декларативная фильтрация, то можно определить свой метод DB CRUD,
331
+ или агрегации, но также необходима декларативная фильтрация, то можно новый свой метод менеджера,
298
332
  вызвав в нём метод `super().get_filter_expression`:
299
333
  ```python
300
- class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel):
334
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel):
301
335
  async def get_parents_with_children_count(
302
336
  self, session: AsyncSession, **kwargs
303
337
  ) -> list[RetrieveParentWithChildrenCountSchema]:
@@ -311,29 +345,64 @@ class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel):
311
345
  )
312
346
 
313
347
  # Вызываем метод для получения фильтров SQLAlchemy из аргументов методов
314
- # filter и paginated_filter BaseCRUD
348
+ # filter и paginated_filter
315
349
  query = query.filter(self.get_filter_expression(**kwargs))
316
350
 
317
351
  result = await session.execute(query)
318
352
  result = result.unique().all()
319
- for row in result:
353
+ for i, row in enumerate(result):
320
354
  row.Parent.children_count = row.children_count
321
- return [row.Parent for row in result]
355
+ result[i] = row.Parent
356
+ return result
322
357
  ```
323
358
 
324
359
  ## Другие полезности
325
360
  ### Сохранение пользователя запроса
326
361
 
327
- Задать в создаваемом объекте пользователя запроса можно, передав дополнительный параметр методу `create` (аналогично с `update`)
362
+ Пользователя запроса можно задать в создаваемом/обновляемом объекте,
363
+ передав дополнительный параметр в метод `create` (`update`):
328
364
  ```python
329
365
  @router.post("")
330
366
  async def create_child(
331
367
  child_in: CreateUpdateChildSchema, session: CurrentSession, user: CurrentUser
332
368
  ) -> CreateUpdateChildSchema:
333
- return await child_db.create(session=session, in_obj=child_in, author_id=user.id)
369
+ return await child_manager.create(session=session, in_obj=child_in, author_id=user.id)
334
370
  ```
335
371
 
336
372
  ### Создание и обновление объектов с M2M связями
337
- Если на модели определена M2M связь, то использование `BaseCRUD` позволяет передать в это поле список ID объектов.
373
+ Если на модели определена M2M связь, то использование `ModelManager` позволяет передать в это поле список ID объектов.
374
+
375
+ `fastapi-sqlalchemy-toolkit` провалидирует существование этих объектов и установит им M2M связь,
376
+ без необходимости создавать отдельные эндпоинты для работы с M2M связями.
377
+
378
+ ```python
379
+ # Пусть модели Person и House имеют M2M связь
380
+ from pydantic import BaseModel
381
+
382
+
383
+ class PersonCreateSchema(BaseModel):
384
+ house_ids: list[int]
385
+
386
+ ...
387
+
388
+ in_obj = PersonCreateSchema(house_ids=[1, 2, 3])
389
+ await person_manager.create(session, in_obj)
390
+ # Создаст объект Person и установит ему M2M связь с House с id 1, 2 и 3
391
+ ```
338
392
 
339
- `fastapi-sqlalchemy-toolkit` провалидирует существование этих объектов и установит им M2M связь, без необходимости создавать отдельные эндпоинты для работы с M2M связями.
393
+ ### Фильтрация по списку значений
394
+ Один из способов фильтрации по списку значений -- передать этот список в качестве
395
+ квери параметра в строку через запятую.
396
+ `fastapi-sqlalchemy-toolkit` предоставляет утилиту для фильтрации по списку значений, переданного в строку через запятую:
397
+ ```python
398
+ from uuid import UUID
399
+ from fastapi_sqlalchemy_toolkit.utils import comma_list_query, get_comma_list_values
400
+
401
+ @router.get("/children")
402
+ async def get_child_objects(
403
+ session: CurrentSession,
404
+ ids: comma_list_query = None,
405
+ ) -> list[ChildListSchema]
406
+ ids = get_comma_list_values(ids, UUID)
407
+ return await child_manager.filter(session, id=FieldFilter(ids, operator="in_"))
408
+ ```
@@ -32,61 +32,59 @@ pip install fastapi-sqlalchemy-toolkit
32
32
 
33
33
  Пример использования `fastapi-sqlalchemy-toolkit` доступен в директории `examples/app`
34
34
 
35
- ## Получение DB CRUD
35
+ ## Инициализация ModelManager
36
36
 
37
- Для использования `fastapi-sqlaclhemy-toolkit` необходимо создать экземпляр `BaseCRUD` для своей модели:
37
+ Для использования `fastapi-sqlaclhemy-toolkit` необходимо создать экземпляр `ModelManager` для своей модели:
38
38
 
39
39
  ```python
40
- from fastapi_sqlalchemy_toolkit import BaseCRUD
40
+ from fastapi_sqlalchemy_toolkit import ModelManager
41
41
 
42
42
  from .models import MyModel
43
43
  from .schemas import MyModelCreateSchema, MyModelUpdateSchema
44
44
 
45
- my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel)
45
+ my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel)
46
46
  ```
47
47
 
48
- При инициализации DB CRUD также можно задать параметр `fk_mapping`, необходимый для валидации внешних ключей.
49
- `fk_mapping` — это словарь, в котором ключи — это названия полей внешних ключей, а значения — модели SQLAlchemy, на которые эти ключи ссылаются.
48
+ При инициализации ModelManager можно задать параметр `fk_mapping`, необходимый для валидации внешних ключей.
49
+ `fk_mapping` — это словарь, в котором ключи — это названия внешних ключей, а значения — модели SQLAlchemy, на которые эти ключи ссылаются.
50
50
 
51
51
  ```python
52
- from fastapi_sqlalchemy_toolkit import BaseCRUD
52
+ from fastapi_sqlalchemy_toolkit import ModelManager
53
53
 
54
54
  from .models import MyModel, MyParentModel
55
55
  from .schemas import MyModelCreateSchema, MyModelUpdateSchema
56
56
 
57
- my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
58
- MyModel,
59
- fk_mapping={"parent_id": MyParentModel}
57
+ my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
58
+ MyModel, fk_mapping={"parent_id": MyParentModel}
60
59
  )
61
60
  ```
62
61
 
63
- Атрибут `default_ordering` определяет сортировку по умолчанию при получении списков объектов. В него нужно передать поле основной модели.
62
+ Атрибут `default_ordering` определяет сортировку по умолчанию при получении списка объектов. В него нужно передать поле основной модели.
64
63
 
65
64
  ```python
66
- from fastapi_sqlalchemy_toolkit import BaseCRUD
65
+ from fastapi_sqlalchemy_toolkit import ModelManager
67
66
 
68
67
  from .models import MyModel
69
68
  from .schemas import MyModelCreateSchema, MyModelUpdateSchema
70
69
 
71
- my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
72
- MyModel,
73
- default_ordering=MyModel.title
70
+ my_model_manager = ModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
71
+ MyModel, default_ordering=MyModel.title
74
72
  )
75
73
  ```
76
74
 
77
- ## Доступные методы `BaseCRUD`
75
+ ## Доступные методы `ModelManager`
78
76
 
79
- Ниже перечислены доступные CRUD методы, предоставляемые `BaseCRUD`.
77
+ Ниже перечислены CRUD методы, предоставляемые `ModelManager`.
80
78
  Документация параметров, принимаемых методами, находится в докстрингах методов.
81
79
 
82
- - `create` - создание объекта (также выполняет валидацию значений полей на уровне БД)
80
+ - `create` - создание объекта; выполняет валидацию значений полей на уровне БД
83
81
  - `get` - получение объекта
84
- - `get_or_404` - получение объекта или ошибки 404
82
+ - `get_or_404` - получение объекта или ошибки HTTP 404
85
83
  - `exists` - проверка существования объекта
86
- - `paginated_filter` - получение списка объектов с пагинацией через `fastapi_pagination`
87
- - `filter` - получение списка объектов
84
+ - `paginated_filter` - получение списка объектов с фильтрами и пагинацией через `fastapi_pagination`
85
+ - `filter` - получение списка объектов с фильтрами
88
86
  - `count` - получение количества объектов
89
- - `update` - обновление объекта (также выполняет валидацию значений полей на уровне БД)
87
+ - `update` - обновление объекта; выполняет валидацию значений полей на уровне БД
90
88
  - `delete` - удаление объекта
91
89
 
92
90
  ## Фильтрация
@@ -96,69 +94,86 @@ my_model_db = BaseCRUD[MyModel, MyModelCreateSchema, MyModelUpdateSchema](
96
94
  ```python
97
95
  from typing import Annotated
98
96
  from uuid import UUID
97
+
99
98
  from fastapi import APIRouter, Depends, Response, status
100
99
  from sqlalchemy import select
101
100
  from sqlalchemy.ext.asyncio import AsyncSession
101
+
102
102
  from app.deps import get_async_session
103
- from app.models import MyModel
103
+ from app.models import MyModel, MyParentModel
104
104
  from app.schemas import MyObjectListSchema
105
105
 
106
-
107
106
  router = APIRouter()
108
107
  CurrentSession = Annotated[AsyncSession, Depends(get_async_session)]
109
108
 
109
+
110
110
  @router.get("/my-objects")
111
111
  async def get_my_objects(
112
112
  session: CurrentSession,
113
113
  user_id: UUID | None = None,
114
- name: str | None = None
114
+ name: str | None = None,
115
+ parent_name: str | None = None,
115
116
  ) -> list[MyObjectListSchema]:
116
117
  stmt = select(MyModel)
117
118
  if user_id is not None:
118
119
  stmt = stmt.filter_by(user_id=user_id)
119
120
  if name is not None:
120
- stmt = stmt.filter_by(name=name)
121
+ stmt = stmt.filter(MyModel.name.ilike == name)
122
+ if parent_name is not None:
123
+ stmt = stmt.join(MyModel.parent)
124
+ stmt = stmt.filter(ParentModel.name.ilike == parent_name)
121
125
  result = await session.execute(stmt)
122
- return results.scalars().all()
126
+ return result.scalars().all()
123
127
  ```
124
- Как можно заметить, присутствует дубликация шаблонного кода. А это только строгие сравнения, и поля находятся на основной модели.
128
+ Как можно заметить, для реализации фильтрации необходима дубликация шаблонного кода.
125
129
 
126
130
  В `fastapi-sqlalchemy-toolkit` этот эндпоинт выглядит так:
127
131
 
128
132
  ```python
129
- from app.db_crud import my_object_db
133
+ from fastapi_sqlalchemy_toolkit import FieldFilter
134
+
135
+ from app.managers import my_object_manager
130
136
 
131
137
  @router.get("/my-objects")
132
138
  async def get_my_objects(
133
139
  session: CurrentSession,
134
140
  user_id: UUID | None = None,
135
- name: str | None = None
141
+ name: str | None = None,
142
+ parent_name: str | None = None,
136
143
  ) -> list[MyObjectListSchema]:
137
- return await my_object_db.filter(session, user_id=user_id, name=name)
144
+ return await my_object_manager.filter(
145
+ session,
146
+ user_id=user_id,
147
+ name=FieldFilter(name, operator="ilike"),
148
+ parent_name=FieldFilter(parent_name, operator="ilike", model=ParentModel),
149
+ )
138
150
  ```
139
151
  ### Использование FieldFilter
140
152
  Дополнительные возможности декларативной фильтрации поддерживаются использованием класса `FieldFilter`.
141
153
  `FieldFilter` позволяет:
142
154
  - фильтровать по значениям полей связанных моделей при установке атрибута `model`.
143
- При этом `BaseCRUD` автоматически сделает необходимые join'ы, если это модель, которая напрямую связана с главной
144
- - использовать любые операторы сравнения через атрибут `operator`
155
+ При этом `ModelManager` автоматически сделает необходимые join'ы, если это модель, которая напрямую связана с главной
156
+ - использовать любые методы и атрибуты полей SQLAlchemy через атрибут `operator`
145
157
  - применять функции SQLAlchemy к полям (например, `date()`) через атрибут `func`
146
158
 
147
159
  ```python
148
- from fastapi_sqlalchemy_toolkit import FieldFilter
149
- from app.db_crud import parent_db
160
+ from app.managers import parent_manager
150
161
  from app.models import Child
151
162
 
152
- await parent_db.filter(session, child_title=FieldFilter(value=child_title, model=Child, operator="ilike"))
163
+ from fastapi_sqlalchemy_toolkit import FieldFilter
164
+
165
+ await parent_manager.filter(
166
+ session, child_title=FieldFilter(child_title, model=Child, operator="ilike")
167
+ )
153
168
  ```
154
169
  ### Фильтрация по обратным связям
155
170
  Также в методах `filter` и `paginated_filter` есть поддержка фильтрации
156
171
  по обратным связям (`relationship()` в направлении один ко многим) с использованием метода `.any()`.
157
172
 
158
173
  ```python
159
- # Если Parent.children -- это связь один ко многим
160
- await parent_db.filter(session, children=[1, 2])
161
- # Вернёт объекты Parent, у которых есть связь с Child с id 1 или 2
174
+ # Если ParentModel.children -- это связь один ко многим
175
+ await parent_manager.filter(session, children=[1, 2])
176
+ # Вернёт объекты Parent, у которых есть связь с ChildModel с id 1 или 2
162
177
  ```
163
178
  ### Фильтрация по null
164
179
  Для того чтобы осуществить фильтрацию по `null`, квери параметр должен принимать
@@ -170,18 +185,19 @@ from fastapi_sqlalchemy_toolkit import NullableQuery
170
185
  @router.get("")
171
186
  async def get_children(
172
187
  session: CurrentSession,
173
- title: NullableQuery | UUID | None = None,
188
+ activated_at: NullableQuery | datetime.datetime | None = None,
174
189
  ) -> Page[ChildRetrieveSchema]:
190
+ ...
175
191
  ```
176
- `NullableQuery` это пустая строка. То есть запрос с фильтрацией по `title == None` должен выглядеть так:
177
- `GET /children?title=`
192
+ `NullableQuery` -- это пустая строка. Запрос с фильтрацией по `activated_at == None` должен выглядеть так:
193
+ `GET /children?activated_at=`
178
194
 
179
195
  *Почему так?*
180
196
 
181
197
 
182
- При запросе `GET /children?title=alex` ожидается, что будут возвращены
183
- объекты с `title == alex`, но при GET `/children` мы не ожидаем, что будут возвращены
184
- объекты с `title == None`.
198
+ При запросе `GET /children?activated_at=2023-08-01` ожидается, что будут возвращены
199
+ объекты с `activated_at == 2023-08-01`, но при запросе GET `/children` мы не ожидаем, что будут возвращены
200
+ объекты с `activated_at == None` (ожидаемым поведением является отсутствие фильтрации по `activated_at`).
185
201
 
186
202
  Если в эндпоинте FastAPI определён необязательный квери параметр, и он не передан
187
203
  в запросе, то значение этого параметра будет равно `None`. Чтобы не возникала описанная выше некорректная фильтрация, фильтр
@@ -193,7 +209,7 @@ async def get_children(
193
209
  а также по полям связанных моделей. При этом необходимые для сортировки по полям
194
210
  связанных моделей join'ы будут сделаны автоматически.
195
211
 
196
- Для применения декларативной сортировки нужно сделать следующее:
212
+ Для применения декларативной сортировки нужно:
197
213
  1. Определить список полей, по которым доступна фильтрация. Поле может быть
198
214
  строкой, если это поле основной модели, или атрибутом модели, если оно находится
199
215
  на связанной модели.
@@ -201,12 +217,12 @@ async def get_children(
201
217
  ```python
202
218
  from app.models import Parent
203
219
 
204
- child_ordering_fields = [
220
+ child_ordering_fields = (
205
221
  "title",
206
222
  "created_at",
207
223
  Parent.title,
208
224
  Parent.created_at
209
- ]
225
+ )
210
226
  ```
211
227
 
212
228
  Для каждого из указаных полей будет доступна сортировка по возрастанию и убыванию.
@@ -226,39 +242,40 @@ async def get_child_objects(
226
242
  session: CurrentSession,
227
243
  order_by: ordering_dep(child_ordering_fields)
228
244
  ) -> list[ChildListSchema]
245
+ ...
229
246
  ```
230
247
 
231
- 3. Передать параметр сортировки как параметр `order_by` в методы `BaseCRUD`
248
+ 3. Передать параметр сортировки как параметр `order_by` в методы `ModelManager`
232
249
 
233
250
  ```python
234
- return await child_db.filter(session=session, order_by=order_by)
251
+ return await child_manager.filter(session=session, order_by=order_by)
235
252
  ```
236
253
 
237
254
 
238
255
  ## Расширение
239
- Методы `BaseCRUD` легко расширить дополнительной логикой.
256
+ Методы `ModelManager` легко расширить дополнительной логикой.
240
257
 
241
258
 
242
- В первую очередь необходимо определить свой класс DB CRUD, унаследовав его от `BaseCRUD`
259
+ В первую очередь необходимо определить свой класс ModelManager:
243
260
 
244
261
  ```python
245
- from fastapi_sqlalchemy_toolkit import BaseCRUD
262
+ from fastapi_sqlalchemy_toolkit import ModelManager
246
263
 
247
264
 
248
- class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](BaseCRUD):
265
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager):
249
266
  ...
250
267
  ```
251
268
  ### Дополнительная валидация
252
269
  Дополнительную валидацию можно добавить, переопределив метод `validate`:
253
270
 
254
271
  ```python
255
- class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](BaseCRUD):
272
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager):
256
273
  async def validate_parent_type(self, session: AsyncSession, validated_data: ModelDict) -> None:
257
274
  """
258
275
  Проверяет тип выбранного объекта Parent
259
276
  """
260
277
  # объект Parent с таким ID точно есть, так как это проверяется ранее в super().validate
261
- parent = await parent_db.get(session, id=in_obj["parent_id"])
278
+ parent = await parent_manager.get(session, id=in_obj["parent_id"])
262
279
  if parent.type != ParentTypes.CanHaveChildren:
263
280
  raise HTTPException(
264
281
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -268,19 +285,36 @@ class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](BaseCRUD):
268
285
  async def run_db_validation(
269
286
  self,
270
287
  session: AsyncSession,
271
- db_obj: ModelType | None = None,
288
+ db_obj: MyModel | None = None,
272
289
  in_obj: ModelDict | None = None,
273
290
  ) -> ModelDict:
274
291
  validated_data = await super().validate(session, db_obj, in_obj)
275
292
  await self.validate_parent_type(session, validated_data)
276
293
  return validated_data
277
294
  ```
295
+
296
+ ### Дополнительная бизнес логика при CRUD операциях
297
+ Если при CRUD операциях с моделью необходимо выполнить какую-то дополнительную бизнес логику,
298
+ это можно сделать, переопределив соответствующие методы ModelManager:
299
+
300
+ ```python
301
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](ModelManager):
302
+ async def create(
303
+ self, *args, background_tasks: BackgroundTasks | None = None, **kwargs
304
+ ) -> MyModel:
305
+ created = await super().create(*args, **kwargs)
306
+ background_tasks.add_task(send_email, created.id)
307
+ return created
308
+ ```
309
+
310
+ Такой подход соответствует принципу "Fat Models, Skinny Views" из Django.
311
+
278
312
  ### Использование декларативных фильтров в нестандартных списочных запросах
279
313
  Если необходимо получить не просто список объектов, но и какие-то другие поля (допустим, кол-во дочерних объектов)
280
- или агрегации, но также необходима декларативная фильтрация, то можно определить свой метод DB CRUD,
314
+ или агрегации, но также необходима декларативная фильтрация, то можно новый свой метод менеджера,
281
315
  вызвав в нём метод `super().get_filter_expression`:
282
316
  ```python
283
- class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel):
317
+ class MyModelManager[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel):
284
318
  async def get_parents_with_children_count(
285
319
  self, session: AsyncSession, **kwargs
286
320
  ) -> list[RetrieveParentWithChildrenCountSchema]:
@@ -294,29 +328,64 @@ class MyModelCRUDB[MyModel, MyModelCreateSchema, MyModelUpdateSchema](MyModel):
294
328
  )
295
329
 
296
330
  # Вызываем метод для получения фильтров SQLAlchemy из аргументов методов
297
- # filter и paginated_filter BaseCRUD
331
+ # filter и paginated_filter
298
332
  query = query.filter(self.get_filter_expression(**kwargs))
299
333
 
300
334
  result = await session.execute(query)
301
335
  result = result.unique().all()
302
- for row in result:
336
+ for i, row in enumerate(result):
303
337
  row.Parent.children_count = row.children_count
304
- return [row.Parent for row in result]
338
+ result[i] = row.Parent
339
+ return result
305
340
  ```
306
341
 
307
342
  ## Другие полезности
308
343
  ### Сохранение пользователя запроса
309
344
 
310
- Задать в создаваемом объекте пользователя запроса можно, передав дополнительный параметр методу `create` (аналогично с `update`)
345
+ Пользователя запроса можно задать в создаваемом/обновляемом объекте,
346
+ передав дополнительный параметр в метод `create` (`update`):
311
347
  ```python
312
348
  @router.post("")
313
349
  async def create_child(
314
350
  child_in: CreateUpdateChildSchema, session: CurrentSession, user: CurrentUser
315
351
  ) -> CreateUpdateChildSchema:
316
- return await child_db.create(session=session, in_obj=child_in, author_id=user.id)
352
+ return await child_manager.create(session=session, in_obj=child_in, author_id=user.id)
317
353
  ```
318
354
 
319
355
  ### Создание и обновление объектов с M2M связями
320
- Если на модели определена M2M связь, то использование `BaseCRUD` позволяет передать в это поле список ID объектов.
356
+ Если на модели определена M2M связь, то использование `ModelManager` позволяет передать в это поле список ID объектов.
357
+
358
+ `fastapi-sqlalchemy-toolkit` провалидирует существование этих объектов и установит им M2M связь,
359
+ без необходимости создавать отдельные эндпоинты для работы с M2M связями.
360
+
361
+ ```python
362
+ # Пусть модели Person и House имеют M2M связь
363
+ from pydantic import BaseModel
364
+
365
+
366
+ class PersonCreateSchema(BaseModel):
367
+ house_ids: list[int]
368
+
369
+ ...
370
+
371
+ in_obj = PersonCreateSchema(house_ids=[1, 2, 3])
372
+ await person_manager.create(session, in_obj)
373
+ # Создаст объект Person и установит ему M2M связь с House с id 1, 2 и 3
374
+ ```
321
375
 
322
- `fastapi-sqlalchemy-toolkit` провалидирует существование этих объектов и установит им M2M связь, без необходимости создавать отдельные эндпоинты для работы с M2M связями.
376
+ ### Фильтрация по списку значений
377
+ Один из способов фильтрации по списку значений -- передать этот список в качестве
378
+ квери параметра в строку через запятую.
379
+ `fastapi-sqlalchemy-toolkit` предоставляет утилиту для фильтрации по списку значений, переданного в строку через запятую:
380
+ ```python
381
+ from uuid import UUID
382
+ from fastapi_sqlalchemy_toolkit.utils import comma_list_query, get_comma_list_values
383
+
384
+ @router.get("/children")
385
+ async def get_child_objects(
386
+ session: CurrentSession,
387
+ ids: comma_list_query = None,
388
+ ) -> list[ChildListSchema]
389
+ ids = get_comma_list_values(ids, UUID)
390
+ return await child_manager.filter(session, id=FieldFilter(ids, operator="in_"))
391
+ ```
@@ -1,5 +1,5 @@
1
1
  POSTGRES_DB=postgres
2
- POSTGRES_USER=postgres
3
- POSTGRES_PASSWORD=mysecretpassword
2
+ POSTGRES_USER=admin
3
+ POSTGRES_PASSWORD=password123
4
4
  POSTGRES_HOST=localhost
5
5
  POSTGRES_PORT=5432
@@ -1,6 +1,6 @@
1
1
  from fastapi import APIRouter
2
2
 
3
- from app.api.endpoints import child
3
+ from .endpoints import child
4
4
 
5
5
  api_router = APIRouter()
6
6
  api_router.include_router(
@@ -6,10 +6,10 @@ from fastapi_pagination import Page, Params
6
6
  from fastapi_sqlalchemy_toolkit import FieldFilter, ordering_dep
7
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
8
 
9
- from app.db_crud import child_db
10
9
  from app.deps import get_async_session
10
+ from app.managers import child_manager
11
11
  from app.models import Parent
12
- from app.schemas import CreateUpdateChildSchema, RetrieveChildSchema
12
+ from app.schemas import CreateUpdateChildSchema, HTTPErrorSchema, RetrieveChildSchema
13
13
 
14
14
  router = APIRouter()
15
15
 
@@ -29,7 +29,7 @@ async def get_children(
29
29
  parent_title: str | None = None,
30
30
  parent_slug: str | None = None,
31
31
  ) -> Page[RetrieveChildSchema]:
32
- return await child_db.paginated_filter(
32
+ return await child_manager.paginated_filter(
33
33
  session=session,
34
34
  pagination_params=params,
35
35
  title=FieldFilter(value=title, operator="ilike"),
@@ -40,12 +40,17 @@ async def get_children(
40
40
  )
41
41
 
42
42
 
43
- @router.get("/{child_id}")
43
+ @router.get(
44
+ "/{child_id}",
45
+ responses={
46
+ status.HTTP_404_NOT_FOUND: {"model": HTTPErrorSchema},
47
+ },
48
+ )
44
49
  async def get_child(
45
50
  child_id: UUID,
46
51
  session: CurrentSession,
47
52
  ) -> RetrieveChildSchema:
48
- return await child_db.get_or_404(
53
+ return await child_manager.get_or_404(
49
54
  session=session,
50
55
  id=child_id,
51
56
  )
@@ -55,21 +60,33 @@ async def get_child(
55
60
  async def create_child(
56
61
  child_in: CreateUpdateChildSchema, session: CurrentSession
57
62
  ) -> CreateUpdateChildSchema:
58
- return await child_db.create(session=session, in_obj=child_in)
63
+ return await child_manager.create(session=session, in_obj=child_in)
59
64
 
60
65
 
66
+ @router.get(
67
+ "/{child_id}",
68
+ responses={
69
+ status.HTTP_404_NOT_FOUND: {"model": HTTPErrorSchema},
70
+ },
71
+ )
61
72
  @router.patch("/{child_id}")
62
73
  async def update_child(
63
74
  child_id: UUID, child_in: CreateUpdateChildSchema, session: CurrentSession
64
75
  ) -> CreateUpdateChildSchema:
65
- child_to_update = await child_db.get_or_404(session=session, id=child_id)
66
- return await child_db.update(
76
+ child_to_update = await child_manager.get_or_404(session=session, id=child_id)
77
+ return await child_manager.update(
67
78
  session=session, db_obj=child_to_update, in_obj=child_in
68
79
  )
69
80
 
70
81
 
82
+ @router.get(
83
+ "/{child_id}",
84
+ responses={
85
+ status.HTTP_404_NOT_FOUND: {"model": HTTPErrorSchema},
86
+ },
87
+ )
71
88
  @router.delete("/{child_id}")
72
89
  async def delete_child(child_id: UUID, session: CurrentSession) -> Response:
73
- child_to_delete = await child_db.get_or_404(session=session, id=child_id)
74
- await child_db.delete(session=session, db_obj=child_to_delete)
90
+ child_to_delete = await child_manager.get_or_404(session=session, id=child_id)
91
+ await child_manager.delete(session=session, db_obj=child_to_delete)
75
92
  return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -3,7 +3,6 @@ from pathlib import Path
3
3
  from pydantic import FieldValidationInfo, PostgresDsn, field_validator
4
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
5
 
6
-
7
6
  ROOT_DIR = Path(__file__).resolve(strict=True).parent
8
7
 
9
8
 
@@ -15,10 +14,11 @@ class Settings(BaseSettings):
15
14
  POSTGRES_USER: str
16
15
  POSTGRES_PASSWORD: str
17
16
  POSTGRES_HOST: str
18
- POSTGRES_PORT: str
17
+ POSTGRES_PORT: int
19
18
  POSTGRES_DB: str
20
- SQLALCHEMY_DATABASE_URL: PostgresDsn | None = None
19
+ SQLALCHEMY_DATABASE_URL: str | None = None
21
20
 
21
+ SENTRY_DSN: str | None = None
22
22
 
23
23
  @field_validator("SQLALCHEMY_DATABASE_URL", mode="before")
24
24
  def assemble_db_connection_string(
@@ -26,13 +26,15 @@ class Settings(BaseSettings):
26
26
  ) -> str | PostgresDsn:
27
27
  if isinstance(value, str):
28
28
  return value
29
- return PostgresDsn.build(
30
- scheme="postgresql+asyncpg",
31
- username=info.data["POSTGRES_USER"],
32
- password=info.data["POSTGRES_PASSWORD"],
33
- host=info.data["POSTGRES_HOST"],
34
- port=int(info.data["POSTGRES_PORT"]),
35
- path=info.data["POSTGRES_DB"],
29
+ return str(
30
+ PostgresDsn.build(
31
+ scheme="postgresql+asyncpg",
32
+ username=info.data["POSTGRES_USER"],
33
+ password=info.data["POSTGRES_PASSWORD"],
34
+ host=info.data["POSTGRES_HOST"],
35
+ port=info.data["POSTGRES_PORT"],
36
+ path=info.data["POSTGRES_DB"],
37
+ )
36
38
  )
37
39
 
38
40
 
@@ -1,11 +1,9 @@
1
1
  from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
2
2
  from sqlalchemy.orm import sessionmaker
3
3
 
4
- from app.config import settings
4
+ from .config import settings
5
5
 
6
- engine = create_async_engine(
7
- str(settings.SQLALCHEMY_DATABASE_URL), future=True, echo=False
8
- )
6
+ engine = create_async_engine(settings.SQLALCHEMY_DATABASE_URL, future=True, echo=False)
9
7
 
10
8
  async_session_factory = sessionmaker(
11
9
  engine, class_=AsyncSession, expire_on_commit=False
@@ -2,7 +2,7 @@ from typing import AsyncGenerator
2
2
 
3
3
  from sqlalchemy.ext.asyncio import AsyncSession
4
4
 
5
- from app.db import async_session_factory
5
+ from .db import async_session_factory
6
6
 
7
7
 
8
8
  async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
@@ -1,8 +1,7 @@
1
1
  from fastapi import FastAPI
2
2
  from fastapi.middleware.cors import CORSMiddleware
3
3
 
4
- from app.api.api import api_router
5
-
4
+ from .api.api import api_router
6
5
 
7
6
  app = FastAPI(
8
7
  title="fastapi-sqlalchemy-toolkit demo",
@@ -0,0 +1,8 @@
1
+ from fastapi_sqlalchemy_toolkit import ModelManager
2
+
3
+ from .models import Child
4
+ from .schemas import CreateUpdateChildSchema
5
+
6
+ child_manager = ModelManager[Child, CreateUpdateChildSchema, CreateUpdateChildSchema](
7
+ Child
8
+ )
@@ -1,9 +1,10 @@
1
1
  from uuid import UUID
2
2
 
3
- from fastapi_sqlalchemy_toolkit import Base
4
3
  from sqlalchemy import ForeignKey
5
4
  from sqlalchemy.orm import Mapped, mapped_column, relationship
6
5
 
6
+ from fastapi_sqlalchemy_toolkit import Base
7
+
7
8
 
8
9
  class Parent(Base):
9
10
  title: Mapped[str]
@@ -22,4 +22,8 @@ class RetrieveChildSchema(ChildBaseSchema):
22
22
 
23
23
 
24
24
  class RetrieveParentSchema(ParentBaseSchema):
25
- children: list[ChildBaseSchema | None]
25
+ children: list[ChildBaseSchema] | None
26
+
27
+
28
+ class HTTPErrorSchema(BaseModel):
29
+ detail: str
@@ -0,0 +1,4 @@
1
+ from .base_model import Base
2
+ from .filters import FieldFilter
3
+ from .model_manager import ModelManager
4
+ from .ordering import ordering_dep
@@ -30,9 +30,9 @@ class FieldFilter:
30
30
  :param operator: Атрибут/метод модели, с помощью которого нужно фильтровать
31
31
  :param func: Функция SQLAlchemy, которую нужно применить к полю
32
32
  :param model: Модель SQLAlchemy, к которой применяется фильтр
33
- По умолчанию, модель из DB CRUD, в котором вызывается метод.
33
+ По умолчанию, модель из ModelManager, в котором вызывается метод.
34
34
  :param alias: Название поля модели
35
- (по умолчанию название параметра, по которому фильтр передаётся в db_crud)
35
+ (по умолчанию название параметра, по которому фильтр передаётся в ModelManager)
36
36
  """
37
37
  self.value = value
38
38
  self.operator = operator
@@ -22,7 +22,7 @@ UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
22
22
  ModelDict = dict[str, Any]
23
23
 
24
24
 
25
- class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
25
+ class ModelManager(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
26
26
  def __init__(
27
27
  self,
28
28
  model: Type[ModelType],
@@ -30,7 +30,7 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
30
30
  default_ordering: InstrumentedAttribute | None = None,
31
31
  ) -> None:
32
32
  """
33
- Создание экземпляра DB CRUD под конкретную модель.
33
+ Создание экземпляра ModelManager под конкретную модель.
34
34
 
35
35
  :param model: модель SQLAlchemy
36
36
 
@@ -38,7 +38,7 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
38
38
  которые являются внешними ключами, и модели SQLAlchemy,
39
39
  на которые ключи ссылаются.
40
40
  Этот параметр нужно определить для валидации внешних ключей.
41
- Например, если DB CRUD создаётся для модели Child, которая имеет
41
+ Например, если ModelManager создаётся для модели Child, которая имеет
42
42
  внешний ключ parent_id на модель Parent, то нужно передать
43
43
  fk_mapping={"parent_id": Parent}
44
44
 
@@ -162,10 +162,11 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
162
162
  """
163
163
 
164
164
  db_obj = await self.get(session=session, **attrs)
165
+ attrs_str = ", ".join([f"{key}={value}" for key, value in attrs.items()])
165
166
  if not db_obj:
166
167
  raise HTTPException(
167
168
  status_code=status.HTTP_404_NOT_FOUND,
168
- detail=f"{self.model.__tablename__.capitalize()} not found",
169
+ detail=f"{self.model.__tablename__} with {attrs_str} not found",
169
170
  )
170
171
  return db_obj
171
172
 
@@ -507,9 +508,9 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
507
508
  )
508
509
  if not related_object_exists:
509
510
  raise HTTPException(
510
- status_code=status.HTTP_400_BAD_REQUEST,
511
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
511
512
  detail=(
512
- f"{self.fk_mapping[key].__tablename__} с ID "
513
+ f"{self.fk_mapping[key].__tablename__} с id "
513
514
  f"{in_obj[key]} не существует."
514
515
  ),
515
516
  )
@@ -528,7 +529,7 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
528
529
  if object_exists:
529
530
  conflicting_fields = ", ".join(unique_constraint)
530
531
  raise HTTPException(
531
- status_code=status.HTTP_400_BAD_REQUEST,
532
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
532
533
  detail=(
533
534
  f"{self.model.__tablename__} с такими "
534
535
  + conflicting_fields
@@ -553,7 +554,7 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
553
554
  check_unique = await self.exists(session=session, **attrs_to_check)
554
555
  if check_unique:
555
556
  raise HTTPException(
556
- status_code=status.HTTP_400_BAD_REQUEST,
557
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
557
558
  detail=(
558
559
  f"{self.model.__tablename__} c {column.name} "
559
560
  f"{in_obj[column.name]} уже существует"
@@ -569,9 +570,9 @@ class BaseCRUD(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
569
570
  related_object = await session.get(related_model, related_object_id)
570
571
  if not related_object:
571
572
  raise HTTPException(
572
- status_code=status.HTTP_400_BAD_REQUEST,
573
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
573
574
  detail=(
574
- f"{related_model.__tablename__} с ID "
575
+ f"{related_model.__tablename__} с id "
575
576
  f"{related_object_id} не существует."
576
577
  ),
577
578
  )
@@ -1,10 +1,10 @@
1
- from typing import Any, Optional, Type
1
+ from typing import Annotated, Any, Optional, Type
2
2
 
3
3
  import pydantic
4
4
  from fastapi import Query
5
5
 
6
6
 
7
- class AllOptional(pydantic.main.ModelMetaclass):
7
+ class AllOptional(pydantic._internal._model_construction.ModelMetaclass):
8
8
  """
9
9
  Метакласс, который делает все поля модели Pydantic необязательными.
10
10
  Полезно для схем PATCH запросов.
@@ -33,13 +33,16 @@ class AllOptional(pydantic.main.ModelMetaclass):
33
33
 
34
34
  # Утилиты для передачи нескольких значений для фильтрации в одном
35
35
  # квери параметре через запятую
36
- comma_list_query = Query(
37
- description="Несколько значений можно передать через запятую",
38
- default=None,
39
- )
36
+ comma_list_query = Annotated[
37
+ str | None, Query(description="Несколько значений можно передать через запятую")
38
+ ]
40
39
 
41
40
 
42
41
  def get_comma_list_values(query: str | None, type_: Type) -> list | None:
42
+ """
43
+ :param query: Значение квери параметра
44
+ :param type_: Тип значений в списке
45
+ """
43
46
  if query:
44
47
  return [type_(query_value) for query_value in query.split(",")]
45
48
  return None
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi_sqlalchemy_toolkit"
7
- version = "0.0.2.3"
7
+ version = "0.1"
8
8
  authors = [
9
9
  { name="Egor Kondrashov", email="e.kondr01@gmail.com" },
10
10
  ]
@@ -1,7 +0,0 @@
1
- from fastapi_sqlalchemy_toolkit import BaseCRUD
2
-
3
- from app.models import Child, Parent
4
- from app.schemas import CreateUpdateChildSchema, ParentBaseSchema
5
-
6
- child_db = BaseCRUD[Child, CreateUpdateChildSchema, CreateUpdateChildSchema](Child)
7
- parent_db = BaseCRUD[Parent, ParentBaseSchema, ParentBaseSchema](Parent)
@@ -1,4 +0,0 @@
1
- from .base_model import Base
2
- from .db_crud import BaseCRUD
3
- from .ordering import ordering_dep
4
- from .filters import FieldFilter