1616 Any ,
1717 Callable ,
1818 Literal ,
19- NamedTuple ,
2019 Optional ,
2120 TypeVar ,
2221 Union ,
2322 cast ,
2423 overload ,
2524)
26- from uuid import UUID
2725
2826from fastapi import Depends , Query , Request
2927from fastapi .exceptions import RequestValidationError
3028from sqlalchemy import Select
3129from sqlalchemy .ext .asyncio import AsyncSession
3230from sqlalchemy .orm import Session
33- from typing_extensions import NotRequired , TypedDict
3431
3532from advanced_alchemy .extensions .fastapi .extension import AdvancedAlchemy
3633from advanced_alchemy .filters import (
5148 SQLAlchemyAsyncRepositoryService ,
5249 SQLAlchemySyncRepositoryService ,
5350)
54- from advanced_alchemy .utils .singleton import SingletonMeta
51+ from advanced_alchemy .utils .dependencies import (
52+ DependencyCache ,
53+ FieldNameType ,
54+ FilterConfig ,
55+ make_hashable ,
56+ normalize_sort_field ,
57+ )
5558from advanced_alchemy .utils .text import camelize
5659
5760logger = logging .getLogger ("advanced_alchemy.extensions.fastapi" )
6770BooleanOrNone = Optional [bool ]
6871SortOrder = Literal ["asc" , "desc" ]
6972SortOrderOrNone = Optional [SortOrder ]
70- FilterConfigValues = Union [
71- bool , str , list [str ], type [Union [str , int ]]
72- ] # Simplified compared to Litestar's UUID/int flexibility for now
7373AsyncServiceT_co = TypeVar ("AsyncServiceT_co" , bound = SQLAlchemyAsyncRepositoryService [Any , Any ], covariant = True )
7474SyncServiceT_co = TypeVar ("SyncServiceT_co" , bound = SQLAlchemySyncRepositoryService [Any , Any ], covariant = True )
75- HashableValue = Union [str , int , float , bool , None ]
76- HashableType = Union [HashableValue , tuple [Any , ...], tuple [tuple [str , Any ], ...], tuple [HashableValue , ...]]
77-
78-
79- class FieldNameType (NamedTuple ):
80- """Type for field name and associated type information.
8175
82- This allows for specifying both the field name and the expected type for filter values.
83- """
76+ __all__ = (
77+ "DEPENDENCY_DEFAULTS" ,
78+ "DependencyCache" ,
79+ "DependencyDefaults" ,
80+ "FieldNameType" ,
81+ "FilterConfig" ,
82+ "dep_cache" ,
83+ "provide_filters" ,
84+ "provide_service" ,
85+ )
8486
85- name : str
86- """Name of the field to filter on."""
87- type_hint : type [Any ] = str
88- """Type of the filter value. Defaults to str."""
87+ _CACHE_NAMESPACE = "advanced_alchemy.extensions.fastapi.providers"
8988
9089
9190class DependencyDefaults :
@@ -110,51 +109,9 @@ class DependencyDefaults:
110109DEPENDENCY_DEFAULTS = DependencyDefaults ()
111110
112111
113- class DependencyCache (metaclass = SingletonMeta ):
114- """Simple dependency cache for the application. This is used to help memoize dependencies that are generated dynamically."""
115-
116- def __init__ (self ) -> None :
117- self .dependencies : dict [int , Callable [[Any ], list [FilterTypes ]]] = {}
118-
119- def add_dependencies (self , key : int , dependencies : Callable [[Any ], list [FilterTypes ]]) -> None :
120- self .dependencies [key ] = dependencies
121-
122- def get_dependencies (self , key : int ) -> Optional [Callable [[Any ], list [FilterTypes ]]]:
123- return self .dependencies .get (key )
124-
125-
126112dep_cache = DependencyCache ()
127113
128114
129- class FilterConfig (TypedDict ):
130- """Configuration for generating dynamic filters for FastAPI."""
131-
132- id_filter : NotRequired [type [Union [UUID , int , str ]]]
133- """Indicates that the id filter should be enabled."""
134- id_field : NotRequired [str ]
135- """The field on the model that stored the primary key or identifier. Defaults to 'id'."""
136- sort_field : NotRequired [Union [str , set [str ]]]
137- """The default field(s) to use for the sort filter."""
138- sort_order : NotRequired [SortOrder ]
139- """The default order to use for the sort filter. Defaults to 'desc'."""
140- pagination_type : NotRequired [Literal ["limit_offset" ]]
141- """When set, pagination is enabled based on the type specified."""
142- pagination_size : NotRequired [int ]
143- """The size of the pagination. Defaults to `DEFAULT_PAGINATION_SIZE`."""
144- search : NotRequired [Union [str , set [str ]]]
145- """Fields to enable search on. Can be a comma-separated string or a set of field names."""
146- search_ignore_case : NotRequired [bool ]
147- """When set, search is case insensitive by default. Defaults to False."""
148- created_at : NotRequired [bool ]
149- """When set, created_at filter is enabled. Defaults to 'created_at' field."""
150- updated_at : NotRequired [bool ]
151- """When set, updated_at filter is enabled. Defaults to 'updated_at' field."""
152- not_in_fields : NotRequired [Union [FieldNameType , set [FieldNameType ]]]
153- """Fields that support not-in collection filters. Can be a single field or a set of fields with type information."""
154- in_fields : NotRequired [Union [FieldNameType , set [FieldNameType ]]]
155- """Fields that support in-collection filters. Can be a single field or a set of fields with type information."""
156-
157-
158115def _should_commit_for_status (status_code : int , commit_mode : str ) -> bool :
159116 """Determine if we should commit based on status code and commit mode.
160117
@@ -174,6 +131,16 @@ def _should_commit_for_status(status_code: int, commit_mode: str) -> bool:
174131 return False
175132
176133
134+ def _normalize_field_name_types (
135+ field_definitions : Union [str , FieldNameType , set [FieldNameType ], list [Union [str , FieldNameType ]]],
136+ ) -> set [FieldNameType ]:
137+ raw_fields = {field_definitions } if isinstance (field_definitions , (str , FieldNameType )) else set (field_definitions )
138+ return {
139+ FieldNameType (name = field_definition , type_hint = str ) if isinstance (field_definition , str ) else field_definition
140+ for field_definition in raw_fields
141+ }
142+
143+
177144@overload
178145def provide_service (
179146 service_class : type ["AsyncServiceT_co" ],
@@ -400,10 +367,10 @@ def provide_filters(
400367 return list
401368
402369 # Calculate cache key using hashable version of config
403- cache_key = hash (_make_hashable ( config ))
370+ cache_key = hash (( _CACHE_NAMESPACE , make_hashable ( config ) ))
404371
405372 # Check cache first
406- cached_dep = dep_cache .get_dependencies (cache_key )
373+ cached_dep = cast ( "Optional[Callable[..., list[FilterTypes]]]" , dep_cache .get_dependencies (cache_key ) )
407374 if cached_dep is not None :
408375 return cached_dep
409376
@@ -412,37 +379,6 @@ def provide_filters(
412379 return dep
413380
414381
415- def _make_hashable (value : Any ) -> HashableType :
416- """Convert a value into a hashable type.
417-
418- This function converts any value into a hashable type by:
419- - Converting dictionaries to sorted tuples of (key, value) pairs
420- - Converting lists and sets to sorted tuples
421- - Preserving primitive types (str, int, float, bool, None)
422- - Converting any other type to its string representation
423-
424- Args:
425- value: Any value that needs to be made hashable.
426-
427- Returns:
428- A hashable version of the value.
429- """
430- if isinstance (value , dict ):
431- # Convert dict to tuple of tuples with sorted keys
432- items = []
433- for k in sorted (value .keys ()): # pyright: ignore
434- v = value [k ] # pyright: ignore
435- items .append ((str (k ), _make_hashable (v ))) # pyright: ignore
436- return tuple (items ) # pyright: ignore
437- if isinstance (value , (list , set )):
438- hashable_items = [_make_hashable (item ) for item in value ] # pyright: ignore
439- filtered_items = [item for item in hashable_items if item is not None ] # pyright: ignore
440- return tuple (sorted (filtered_items , key = str ))
441- if isinstance (value , (str , int , float , bool , type (None ))):
442- return value
443- return str (value )
444-
445-
446382def _create_filter_aggregate_function_fastapi ( # noqa: C901, PLR0915
447383 config : FilterConfig ,
448384 dep_defaults : DependencyDefaults = DEPENDENCY_DEFAULTS ,
@@ -649,7 +585,7 @@ def provide_search_filter(
649585 ),
650586 ] = config .get ("search_ignore_case" , False ),
651587 ) -> SearchFilter :
652- field_names = set (search_fields .split ("," )) if isinstance (search_fields , str ) else search_fields
588+ field_names = set (search_fields .split ("," )) if isinstance (search_fields , str ) else set ( search_fields )
653589
654590 return SearchFilter (
655591 field_name = field_names ,
@@ -669,6 +605,7 @@ def provide_search_filter(
669605
670606 # Add sort filter providers
671607 if sort_field := config .get ("sort_field" ):
608+ sort_field_default = normalize_sort_field (sort_field )
672609 sort_order_default = config .get ("sort_order" , "desc" )
673610
674611 def provide_order_by (
@@ -679,7 +616,7 @@ def provide_order_by(
679616 description = "Field to order by." ,
680617 required = False ,
681618 ),
682- ] = sort_field , # type: ignore[assignment]
619+ ] = sort_field_default ,
683620 sort_order : Annotated [
684621 Optional [SortOrder ],
685622 Query (
@@ -703,8 +640,7 @@ def provide_order_by(
703640
704641 # Add not_in filter providers
705642 if not_in_fields := config .get ("not_in_fields" ):
706- not_in_fields = {not_in_fields } if isinstance (not_in_fields , (str , FieldNameType )) else not_in_fields
707- for field_def in not_in_fields :
643+ for field_def in _normalize_field_name_types (not_in_fields ):
708644 # Capture field_def by value to avoid Python closure late binding gotcha
709645 # Without default parameter, all closures would reference the loop variable's final value
710646 def create_not_in_filter_provider ( # pyright: ignore
@@ -736,8 +672,7 @@ def provide_not_in_filter( # pyright: ignore
736672
737673 # Add in filter providers
738674 if in_fields := config .get ("in_fields" ):
739- in_fields = {in_fields } if isinstance (in_fields , (str , FieldNameType )) else in_fields
740- for field_def in in_fields :
675+ for field_def in _normalize_field_name_types (in_fields ):
741676 # Capture field_def by value to avoid Python closure late binding gotcha
742677 # Without default parameter, all closures would reference the loop variable's final value
743678 def create_in_filter_provider ( # pyright: ignore
0 commit comments