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 DependencyCache , FieldNameType , FilterConfig , make_hashable
5552from advanced_alchemy .utils .text import camelize
5653
5754logger = logging .getLogger ("advanced_alchemy.extensions.fastapi" )
6764BooleanOrNone = Optional [bool ]
6865SortOrder = Literal ["asc" , "desc" ]
6966SortOrderOrNone = 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
7367AsyncServiceT_co = TypeVar ("AsyncServiceT_co" , bound = SQLAlchemyAsyncRepositoryService [Any , Any ], covariant = True )
7468SyncServiceT_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 , ...]]
7769
70+ __all__ = (
71+ "DEPENDENCY_DEFAULTS" ,
72+ "DependencyCache" ,
73+ "DependencyDefaults" ,
74+ "FieldNameType" ,
75+ "FilterConfig" ,
76+ "dep_cache" ,
77+ "provide_filters" ,
78+ "provide_service" ,
79+ )
7880
79- class FieldNameType (NamedTuple ):
80- """Type for field name and associated type information.
81-
82- This allows for specifying both the field name and the expected type for filter values.
83- """
84-
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."""
81+ _CACHE_NAMESPACE = "advanced_alchemy.extensions.fastapi.providers"
8982
9083
9184class DependencyDefaults :
@@ -110,51 +103,9 @@ class DependencyDefaults:
110103DEPENDENCY_DEFAULTS = DependencyDefaults ()
111104
112105
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-
126106dep_cache = DependencyCache ()
127107
128108
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-
158109def _should_commit_for_status (status_code : int , commit_mode : str ) -> bool :
159110 """Determine if we should commit based on status code and commit mode.
160111
@@ -174,6 +125,16 @@ def _should_commit_for_status(status_code: int, commit_mode: str) -> bool:
174125 return False
175126
176127
128+ def _normalize_field_name_types (
129+ field_definitions : Union [str , FieldNameType , set [FieldNameType ], list [Union [str , FieldNameType ]]],
130+ ) -> set [FieldNameType ]:
131+ raw_fields = {field_definitions } if isinstance (field_definitions , (str , FieldNameType )) else set (field_definitions )
132+ return {
133+ FieldNameType (name = field_definition , type_hint = str ) if isinstance (field_definition , str ) else field_definition
134+ for field_definition in raw_fields
135+ }
136+
137+
177138@overload
178139def provide_service (
179140 service_class : type ["AsyncServiceT_co" ],
@@ -400,10 +361,10 @@ def provide_filters(
400361 return list
401362
402363 # Calculate cache key using hashable version of config
403- cache_key = hash (_make_hashable ( config ))
364+ cache_key = hash (( _CACHE_NAMESPACE , make_hashable ( config ) ))
404365
405366 # Check cache first
406- cached_dep = dep_cache .get_dependencies (cache_key )
367+ cached_dep = cast ( "Optional[Callable[..., list[FilterTypes]]]" , dep_cache .get_dependencies (cache_key ) )
407368 if cached_dep is not None :
408369 return cached_dep
409370
@@ -412,37 +373,6 @@ def provide_filters(
412373 return dep
413374
414375
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-
446376def _create_filter_aggregate_function_fastapi ( # noqa: C901, PLR0915
447377 config : FilterConfig ,
448378 dep_defaults : DependencyDefaults = DEPENDENCY_DEFAULTS ,
@@ -649,7 +579,7 @@ def provide_search_filter(
649579 ),
650580 ] = config .get ("search_ignore_case" , False ),
651581 ) -> SearchFilter :
652- field_names = set (search_fields .split ("," )) if isinstance (search_fields , str ) else search_fields
582+ field_names = set (search_fields .split ("," )) if isinstance (search_fields , str ) else set ( search_fields )
653583
654584 return SearchFilter (
655585 field_name = field_names ,
@@ -703,8 +633,7 @@ def provide_order_by(
703633
704634 # Add not_in filter providers
705635 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 :
636+ for field_def in _normalize_field_name_types (not_in_fields ):
708637 # Capture field_def by value to avoid Python closure late binding gotcha
709638 # Without default parameter, all closures would reference the loop variable's final value
710639 def create_not_in_filter_provider ( # pyright: ignore
@@ -736,8 +665,7 @@ def provide_not_in_filter( # pyright: ignore
736665
737666 # Add in filter providers
738667 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 :
668+ for field_def in _normalize_field_name_types (in_fields ):
741669 # Capture field_def by value to avoid Python closure late binding gotcha
742670 # Without default parameter, all closures would reference the loop variable's final value
743671 def create_in_filter_provider ( # pyright: ignore
0 commit comments