Skip to content

Commit 204887d

Browse files
committed
refactor: extract provider dependency utilities
1 parent 83ec117 commit 204887d

6 files changed

Lines changed: 324 additions & 193 deletions

File tree

advanced_alchemy/extensions/fastapi/providers.py

Lines changed: 27 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,18 @@
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

2826
from fastapi import Depends, Query, Request
2927
from fastapi.exceptions import RequestValidationError
3028
from sqlalchemy import Select
3129
from sqlalchemy.ext.asyncio import AsyncSession
3230
from sqlalchemy.orm import Session
33-
from typing_extensions import NotRequired, TypedDict
3431

3532
from advanced_alchemy.extensions.fastapi.extension import AdvancedAlchemy
3633
from advanced_alchemy.filters import (
@@ -51,7 +48,7 @@
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
5552
from advanced_alchemy.utils.text import camelize
5653

5754
logger = logging.getLogger("advanced_alchemy.extensions.fastapi")
@@ -67,25 +64,21 @@
6764
BooleanOrNone = Optional[bool]
6865
SortOrder = Literal["asc", "desc"]
6966
SortOrderOrNone = 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
7367
AsyncServiceT_co = TypeVar("AsyncServiceT_co", bound=SQLAlchemyAsyncRepositoryService[Any, Any], covariant=True)
7468
SyncServiceT_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

9184
class DependencyDefaults:
@@ -110,51 +103,9 @@ class DependencyDefaults:
110103
DEPENDENCY_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-
126106
dep_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-
158109
def _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
178139
def 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-
446376
def _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

advanced_alchemy/extensions/litestar/providers.py

Lines changed: 19 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
Annotated,
1515
Any,
1616
Literal,
17-
NamedTuple,
1817
Optional,
19-
TypedDict,
2018
TypeVar,
2119
Union,
2220
cast,
@@ -26,7 +24,6 @@
2624

2725
from litestar.di import Provide
2826
from litestar.params import Dependency, FromQuery, QueryParameter
29-
from typing_extensions import NotRequired
3027

3128
from advanced_alchemy.filters import (
3229
BeforeAfter,
@@ -46,6 +43,7 @@
4643
SQLAlchemyAsyncRepositoryService,
4744
SQLAlchemySyncRepositoryService,
4845
)
46+
from advanced_alchemy.utils.dependencies import DependencyCache, FieldNameType, FilterConfig, make_hashable
4947
from advanced_alchemy.utils.singleton import SingletonMeta
5048
from advanced_alchemy.utils.text import camelize
5149

@@ -66,8 +64,21 @@
6664
SortOrderOrNone = Optional[SortOrder]
6765
AsyncServiceT_co = TypeVar("AsyncServiceT_co", bound=SQLAlchemyAsyncRepositoryService[Any, Any], covariant=True)
6866
SyncServiceT_co = TypeVar("SyncServiceT_co", bound=SQLAlchemySyncRepositoryService[Any, Any], covariant=True)
69-
HashableValue = Union[str, int, float, bool, None]
70-
HashableType = Union[HashableValue, tuple[Any, ...], tuple[tuple[str, Any], ...], tuple[HashableValue, ...]]
67+
68+
__all__ = (
69+
"DEPENDENCY_DEFAULTS",
70+
"DependencyCache",
71+
"DependencyDefaults",
72+
"FieldNameType",
73+
"FilterConfig",
74+
"SingletonMeta",
75+
"create_filter_dependencies",
76+
"create_service_dependencies",
77+
"create_service_provider",
78+
"dep_cache",
79+
)
80+
81+
_CACHE_NAMESPACE = "advanced_alchemy.extensions.litestar.providers"
7182

7283

7384
class DependencyDefaults:
@@ -92,60 +103,6 @@ class DependencyDefaults:
92103
DEPENDENCY_DEFAULTS = DependencyDefaults()
93104

94105

95-
class FieldNameType(NamedTuple):
96-
"""Type for field name and associated type information.
97-
98-
This allows for specifying both the field name and the expected type for filter values.
99-
"""
100-
101-
name: str
102-
"""Name of the field to filter on."""
103-
type_hint: type[Any] = str
104-
"""Type of the filter value. Defaults to str."""
105-
106-
107-
class FilterConfig(TypedDict):
108-
"""Configuration for generating dynamic filters."""
109-
110-
id_filter: NotRequired[type[Union[UUID, int, str]]]
111-
"""Indicates that the id filter should be enabled. When set, the type specified will be used for the :class:`CollectionFilter`."""
112-
id_field: NotRequired[str]
113-
"""The field on the model that stored the primary key or identifier."""
114-
sort_field: NotRequired[str]
115-
"""The default field to use for the sort filter."""
116-
sort_order: NotRequired[SortOrder]
117-
"""The default order to use for the sort filter."""
118-
pagination_type: NotRequired[Literal["limit_offset"]]
119-
"""When set, pagination is enabled based on the type specified."""
120-
pagination_size: NotRequired[int]
121-
"""The size of the pagination. Defaults to `DEFAULT_PAGINATION_SIZE`."""
122-
search: NotRequired[Union[str, set[str], list[str]]]
123-
"""Fields to enable search on. Can be a comma-separated string or a set of field names."""
124-
search_ignore_case: NotRequired[bool]
125-
"""When set, search is case insensitive by default."""
126-
created_at: NotRequired[bool]
127-
"""When set, created_at filter is enabled."""
128-
updated_at: NotRequired[bool]
129-
"""When set, updated_at filter is enabled."""
130-
not_in_fields: NotRequired[Union[FieldNameType, set[FieldNameType], list[Union[str, FieldNameType]]]]
131-
"""Fields that support not-in collection filters. Can be a single field or a set of fields with type information."""
132-
in_fields: NotRequired[Union[FieldNameType, set[FieldNameType], list[Union[str, FieldNameType]]]]
133-
"""Fields that support in-collection filters. Can be a single field or a set of fields with type information."""
134-
135-
136-
class DependencyCache(metaclass=SingletonMeta):
137-
"""Simple dependency cache for the application. This is used to help memoize dependencies that are generated dynamically."""
138-
139-
def __init__(self) -> None:
140-
self.dependencies: dict[Union[int, str], dict[str, Provide]] = {}
141-
142-
def add_dependencies(self, key: Union[int, str], dependencies: dict[str, Provide]) -> None:
143-
self.dependencies[key] = dependencies
144-
145-
def get_dependencies(self, key: Union[int, str]) -> Optional[dict[str, Provide]]:
146-
return self.dependencies.get(key)
147-
148-
149106
dep_cache = DependencyCache()
150107

151108

@@ -351,46 +308,15 @@ def create_filter_dependencies(
351308
Returns:
352309
A dependency provider function for the combined filter function.
353310
"""
354-
cache_key = hash(_make_hashable(config))
355-
deps = dep_cache.get_dependencies(cache_key)
311+
cache_key = hash((_CACHE_NAMESPACE, make_hashable(config)))
312+
deps = cast("Optional[dict[str, Provide]]", dep_cache.get_dependencies(cache_key))
356313
if deps is not None:
357314
return deps
358315
deps = _create_statement_filters(config, dep_defaults)
359316
dep_cache.add_dependencies(cache_key, deps)
360317
return deps
361318

362319

363-
def _make_hashable(value: Any) -> HashableType:
364-
"""Convert a value into a hashable type.
365-
366-
This function converts any value into a hashable type by:
367-
- Converting dictionaries to sorted tuples of (key, value) pairs
368-
- Converting lists and sets to sorted tuples
369-
- Preserving primitive types (str, int, float, bool, None)
370-
- Converting any other type to its string representation
371-
372-
Args:
373-
value: Any value that needs to be made hashable.
374-
375-
Returns:
376-
A hashable version of the value.
377-
"""
378-
if isinstance(value, dict):
379-
# Convert dict to tuple of tuples with sorted keys
380-
items = []
381-
for k in sorted(value.keys()): # pyright: ignore
382-
v = value[k] # pyright: ignore
383-
items.append((str(k), _make_hashable(v))) # pyright: ignore
384-
return tuple(items) # pyright: ignore
385-
if isinstance(value, (list, set)):
386-
hashable_items = [_make_hashable(item) for item in value] # pyright: ignore
387-
filtered_items = [item for item in hashable_items if item is not None] # pyright: ignore
388-
return tuple(sorted(filtered_items, key=str))
389-
if isinstance(value, (str, int, float, bool, type(None))):
390-
return value
391-
return str(value)
392-
393-
394320
def _create_statement_filters( # noqa: C901
395321
config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS
396322
) -> dict[str, Provide]:
@@ -490,7 +416,7 @@ def provide_order_by(
490416
title="Order by field",
491417
name="orderBy",
492418
),
493-
] = sort_field,
419+
] = sort_field, # type: ignore[assignment]
494420
sort_order: Annotated[
495421
SortOrderOrNone,
496422
QueryParameter(

0 commit comments

Comments
 (0)