Skip to content

Commit 8d65e56

Browse files
committed
refactor: extract provider dependency utilities
1 parent d06e8c4 commit 8d65e56

6 files changed

Lines changed: 323 additions & 192 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: 18 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
TYPE_CHECKING,
1414
Any,
1515
Literal,
16-
NamedTuple,
1716
Optional,
18-
TypedDict,
1917
TypeVar,
2018
Union,
2119
cast,
@@ -25,7 +23,6 @@
2523

2624
from litestar.di import Provide
2725
from litestar.params import Dependency, Parameter
28-
from typing_extensions import NotRequired
2926

3027
from advanced_alchemy.filters import (
3128
BeforeAfter,
@@ -45,6 +42,7 @@
4542
SQLAlchemyAsyncRepositoryService,
4643
SQLAlchemySyncRepositoryService,
4744
)
45+
from advanced_alchemy.utils.dependencies import DependencyCache, FieldNameType, FilterConfig, make_hashable
4846
from advanced_alchemy.utils.singleton import SingletonMeta
4947
from advanced_alchemy.utils.text import camelize
5048

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

7182

7283
class DependencyDefaults:
@@ -91,60 +102,6 @@ class DependencyDefaults:
91102
DEPENDENCY_DEFAULTS = DependencyDefaults()
92103

93104

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

150107

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

361318

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

0 commit comments

Comments
 (0)