fastapi-toolsets 2.1.0__tar.gz → 2.2.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 (36) hide show
  1. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/PKG-INFO +1 -1
  2. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/pyproject.toml +1 -1
  3. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/__init__.py +1 -1
  4. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/crud/factory.py +157 -24
  5. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/LICENSE +0 -0
  6. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/README.md +0 -0
  7. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/_imports.py +0 -0
  8. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
  9. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/app.py +0 -0
  10. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  11. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  12. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/config.py +0 -0
  13. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  14. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/cli/utils.py +0 -0
  15. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
  16. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/crud/search.py +0 -0
  17. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/db.py +0 -0
  18. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/dependencies.py +0 -0
  19. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  20. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  21. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  22. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  23. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  24. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  25. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  26. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/logger.py +0 -0
  27. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  28. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
  29. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
  30. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/models.py +0 -0
  31. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/py.typed +0 -0
  32. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  33. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  34. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
  35. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/schemas.py +0 -0
  36. {fastapi_toolsets-2.1.0 → fastapi_toolsets-2.2.1}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 2.1.0
3
+ Version: 2.2.1
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "2.1.0"
3
+ version = "2.2.1"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,4 +21,4 @@ Example usage:
21
21
  return Response(data={"user": user.username}, message="Success")
22
22
  """
23
23
 
24
- __version__ = "2.1.0"
24
+ __version__ = "2.2.1"
@@ -14,7 +14,6 @@ from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
14
14
  from fastapi import Query
15
15
  from pydantic import BaseModel
16
16
  from sqlalchemy import Date, DateTime, Float, Integer, Numeric, Uuid, and_, func, select
17
- from sqlalchemy import delete as sql_delete
18
17
  from sqlalchemy.dialects.postgresql import insert
19
18
  from sqlalchemy.exc import NoResultFound
20
19
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -80,13 +79,13 @@ class AsyncCrud(Generic[ModelType]):
80
79
  facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
81
80
  order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
82
81
  m2m_fields: ClassVar[M2MFieldType | None] = None
83
- default_load_options: ClassVar[list[ExecutableOption] | None] = None
82
+ default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
84
83
  cursor_column: ClassVar[Any | None] = None
85
84
 
86
85
  @classmethod
87
86
  def _resolve_load_options(
88
- cls, load_options: list[ExecutableOption] | None
89
- ) -> list[ExecutableOption] | None:
87
+ cls, load_options: Sequence[ExecutableOption] | None
88
+ ) -> Sequence[ExecutableOption] | None:
90
89
  """Return load_options if provided, else fall back to default_load_options."""
91
90
  if load_options is not None:
92
91
  return load_options
@@ -361,7 +360,7 @@ class AsyncCrud(Generic[ModelType]):
361
360
  joins: JoinType | None = None,
362
361
  outer_join: bool = False,
363
362
  with_for_update: bool = False,
364
- load_options: list[ExecutableOption] | None = None,
363
+ load_options: Sequence[ExecutableOption] | None = None,
365
364
  schema: type[SchemaType],
366
365
  ) -> Response[SchemaType]: ...
367
366
 
@@ -375,7 +374,7 @@ class AsyncCrud(Generic[ModelType]):
375
374
  joins: JoinType | None = None,
376
375
  outer_join: bool = False,
377
376
  with_for_update: bool = False,
378
- load_options: list[ExecutableOption] | None = None,
377
+ load_options: Sequence[ExecutableOption] | None = None,
379
378
  schema: None = ...,
380
379
  ) -> ModelType: ...
381
380
 
@@ -388,7 +387,7 @@ class AsyncCrud(Generic[ModelType]):
388
387
  joins: JoinType | None = None,
389
388
  outer_join: bool = False,
390
389
  with_for_update: bool = False,
391
- load_options: list[ExecutableOption] | None = None,
390
+ load_options: Sequence[ExecutableOption] | None = None,
392
391
  schema: type[BaseModel] | None = None,
393
392
  ) -> ModelType | Response[Any]:
394
393
  """Get exactly one record. Raises NotFoundError if not found.
@@ -410,6 +409,82 @@ class AsyncCrud(Generic[ModelType]):
410
409
  NotFoundError: If no record found
411
410
  MultipleResultsFound: If more than one record found
412
411
  """
412
+ result = await cls.get_or_none(
413
+ session,
414
+ filters,
415
+ joins=joins,
416
+ outer_join=outer_join,
417
+ with_for_update=with_for_update,
418
+ load_options=load_options,
419
+ schema=schema,
420
+ )
421
+ if result is None:
422
+ raise NotFoundError()
423
+ return result
424
+
425
+ @overload
426
+ @classmethod
427
+ async def get_or_none( # pragma: no cover
428
+ cls: type[Self],
429
+ session: AsyncSession,
430
+ filters: list[Any],
431
+ *,
432
+ joins: JoinType | None = None,
433
+ outer_join: bool = False,
434
+ with_for_update: bool = False,
435
+ load_options: Sequence[ExecutableOption] | None = None,
436
+ schema: type[SchemaType],
437
+ ) -> Response[SchemaType] | None: ...
438
+
439
+ @overload
440
+ @classmethod
441
+ async def get_or_none( # pragma: no cover
442
+ cls: type[Self],
443
+ session: AsyncSession,
444
+ filters: list[Any],
445
+ *,
446
+ joins: JoinType | None = None,
447
+ outer_join: bool = False,
448
+ with_for_update: bool = False,
449
+ load_options: Sequence[ExecutableOption] | None = None,
450
+ schema: None = ...,
451
+ ) -> ModelType | None: ...
452
+
453
+ @classmethod
454
+ async def get_or_none(
455
+ cls: type[Self],
456
+ session: AsyncSession,
457
+ filters: list[Any],
458
+ *,
459
+ joins: JoinType | None = None,
460
+ outer_join: bool = False,
461
+ with_for_update: bool = False,
462
+ load_options: Sequence[ExecutableOption] | None = None,
463
+ schema: type[BaseModel] | None = None,
464
+ ) -> ModelType | Response[Any] | None:
465
+ """Get exactly one record, or ``None`` if not found.
466
+
467
+ Like :meth:`get` but returns ``None`` instead of raising
468
+ :class:`~fastapi_toolsets.exceptions.NotFoundError` when no record
469
+ matches the filters.
470
+
471
+ Args:
472
+ session: DB async session
473
+ filters: List of SQLAlchemy filter conditions
474
+ joins: List of (model, condition) tuples for joining related tables
475
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
476
+ with_for_update: Lock the row for update
477
+ load_options: SQLAlchemy loader options (e.g., selectinload)
478
+ schema: Pydantic schema to serialize the result into. When provided,
479
+ the result is automatically wrapped in a ``Response[schema]``.
480
+
481
+ Returns:
482
+ Model instance, ``Response[schema]`` when ``schema`` is given,
483
+ or ``None`` when no record matches.
484
+
485
+ Raises:
486
+ MultipleResultsFound: If more than one record found
487
+ """
413
488
  q = select(cls.model)
414
489
  q = _apply_joins(q, joins, outer_join)
415
490
  q = q.where(and_(*filters))
@@ -419,12 +494,40 @@ class AsyncCrud(Generic[ModelType]):
419
494
  q = q.with_for_update()
420
495
  result = await session.execute(q)
421
496
  item = result.unique().scalar_one_or_none()
422
- if not item:
423
- raise NotFoundError()
424
- result = cast(ModelType, item)
497
+ if item is None:
498
+ return None
499
+ db_model = cast(ModelType, item)
425
500
  if schema:
426
- return Response(data=schema.model_validate(result))
427
- return result
501
+ return Response(data=schema.model_validate(db_model))
502
+ return db_model
503
+
504
+ @overload
505
+ @classmethod
506
+ async def first( # pragma: no cover
507
+ cls: type[Self],
508
+ session: AsyncSession,
509
+ filters: list[Any] | None = None,
510
+ *,
511
+ joins: JoinType | None = None,
512
+ outer_join: bool = False,
513
+ with_for_update: bool = False,
514
+ load_options: Sequence[ExecutableOption] | None = None,
515
+ schema: type[SchemaType],
516
+ ) -> Response[SchemaType] | None: ...
517
+
518
+ @overload
519
+ @classmethod
520
+ async def first( # pragma: no cover
521
+ cls: type[Self],
522
+ session: AsyncSession,
523
+ filters: list[Any] | None = None,
524
+ *,
525
+ joins: JoinType | None = None,
526
+ outer_join: bool = False,
527
+ with_for_update: bool = False,
528
+ load_options: Sequence[ExecutableOption] | None = None,
529
+ schema: None = ...,
530
+ ) -> ModelType | None: ...
428
531
 
429
532
  @classmethod
430
533
  async def first(
@@ -434,8 +537,10 @@ class AsyncCrud(Generic[ModelType]):
434
537
  *,
435
538
  joins: JoinType | None = None,
436
539
  outer_join: bool = False,
437
- load_options: list[ExecutableOption] | None = None,
438
- ) -> ModelType | None:
540
+ with_for_update: bool = False,
541
+ load_options: Sequence[ExecutableOption] | None = None,
542
+ schema: type[BaseModel] | None = None,
543
+ ) -> ModelType | Response[Any] | None:
439
544
  """Get the first matching record, or None.
440
545
 
441
546
  Args:
@@ -443,10 +548,14 @@ class AsyncCrud(Generic[ModelType]):
443
548
  filters: List of SQLAlchemy filter conditions
444
549
  joins: List of (model, condition) tuples for joining related tables
445
550
  outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
446
- load_options: SQLAlchemy loader options
551
+ with_for_update: Lock the row for update
552
+ load_options: SQLAlchemy loader options (e.g., selectinload)
553
+ schema: Pydantic schema to serialize the result into. When provided,
554
+ the result is automatically wrapped in a ``Response[schema]``.
447
555
 
448
556
  Returns:
449
- Model instance or None
557
+ Model instance, ``Response[schema]`` when ``schema`` is given,
558
+ or ``None`` when no record matches.
450
559
  """
451
560
  q = select(cls.model)
452
561
  q = _apply_joins(q, joins, outer_join)
@@ -454,8 +563,16 @@ class AsyncCrud(Generic[ModelType]):
454
563
  q = q.where(and_(*filters))
455
564
  if resolved := cls._resolve_load_options(load_options):
456
565
  q = q.options(*resolved)
566
+ if with_for_update:
567
+ q = q.with_for_update()
457
568
  result = await session.execute(q)
458
- return cast(ModelType | None, result.unique().scalars().first())
569
+ item = result.unique().scalars().first()
570
+ if item is None:
571
+ return None
572
+ db_model = cast(ModelType, item)
573
+ if schema:
574
+ return Response(data=schema.model_validate(db_model))
575
+ return db_model
459
576
 
460
577
  @classmethod
461
578
  async def get_multi(
@@ -465,7 +582,7 @@ class AsyncCrud(Generic[ModelType]):
465
582
  filters: list[Any] | None = None,
466
583
  joins: JoinType | None = None,
467
584
  outer_join: bool = False,
468
- load_options: list[ExecutableOption] | None = None,
585
+ load_options: Sequence[ExecutableOption] | None = None,
469
586
  order_by: OrderByClause | None = None,
470
587
  limit: int | None = None,
471
588
  offset: int | None = None,
@@ -674,8 +791,10 @@ class AsyncCrud(Generic[ModelType]):
674
791
  ``None``, or ``Response[None]`` when ``return_response=True``.
675
792
  """
676
793
  async with get_transaction(session):
677
- q = sql_delete(cls.model).where(and_(*filters))
678
- await session.execute(q)
794
+ result = await session.execute(select(cls.model).where(and_(*filters)))
795
+ objects = result.scalars().all()
796
+ for obj in objects:
797
+ await session.delete(obj)
679
798
  if return_response:
680
799
  return Response(data=None)
681
800
  return None
@@ -741,7 +860,7 @@ class AsyncCrud(Generic[ModelType]):
741
860
  filters: list[Any] | None = None,
742
861
  joins: JoinType | None = None,
743
862
  outer_join: bool = False,
744
- load_options: list[ExecutableOption] | None = None,
863
+ load_options: Sequence[ExecutableOption] | None = None,
745
864
  order_by: OrderByClause | None = None,
746
865
  page: int = 1,
747
866
  items_per_page: int = 20,
@@ -852,7 +971,7 @@ class AsyncCrud(Generic[ModelType]):
852
971
  filters: list[Any] | None = None,
853
972
  joins: JoinType | None = None,
854
973
  outer_join: bool = False,
855
- load_options: list[ExecutableOption] | None = None,
974
+ load_options: Sequence[ExecutableOption] | None = None,
856
975
  order_by: OrderByClause | None = None,
857
976
  items_per_page: int = 20,
858
977
  search: str | SearchConfig | None = None,
@@ -993,7 +1112,7 @@ def CrudFactory(
993
1112
  facet_fields: Sequence[FacetFieldType] | None = None,
994
1113
  order_fields: Sequence[QueryableAttribute[Any]] | None = None,
995
1114
  m2m_fields: M2MFieldType | None = None,
996
- default_load_options: list[ExecutableOption] | None = None,
1115
+ default_load_options: Sequence[ExecutableOption] | None = None,
997
1116
  cursor_column: Any | None = None,
998
1117
  ) -> type[AsyncCrud[ModelType]]:
999
1118
  """Create a CRUD class for a specific model.
@@ -1092,12 +1211,26 @@ def CrudFactory(
1092
1211
  )
1093
1212
  ```
1094
1213
  """
1214
+ pk_key = model.__mapper__.primary_key[0].key
1215
+ assert pk_key is not None
1216
+ pk_col = getattr(model, pk_key)
1217
+
1218
+ if searchable_fields is None:
1219
+ effective_searchable = [pk_col]
1220
+ else:
1221
+ existing_keys = {f.key for f in searchable_fields if not isinstance(f, tuple)}
1222
+ effective_searchable = (
1223
+ [pk_col, *searchable_fields]
1224
+ if pk_key not in existing_keys
1225
+ else list(searchable_fields)
1226
+ )
1227
+
1095
1228
  cls = type(
1096
1229
  f"Async{model.__name__}Crud",
1097
1230
  (AsyncCrud,),
1098
1231
  {
1099
1232
  "model": model,
1100
- "searchable_fields": searchable_fields,
1233
+ "searchable_fields": effective_searchable,
1101
1234
  "facet_fields": facet_fields,
1102
1235
  "order_fields": order_fields,
1103
1236
  "m2m_fields": m2m_fields,