Skip to content

Commit 7b8e299

Browse files
authored
refactor: extract provider dependency utilities (#721)
## Summary - Extract shared provider dependency primitives into `advanced_alchemy.utils.dependencies` - Normalize multi-field `sort_field` defaults before creating `OrderBy` filters for Litestar and FastAPI - Keep Litestar individual filter injection working while preserving typed OpenAPI sort parameters - Scope cache keys per framework so the shared singleton cache cannot cross-return Litestar/FastAPI dependency objects ## Linked Issues - Refs #602
1 parent 83ec117 commit 7b8e299

8 files changed

Lines changed: 513 additions & 222 deletions

File tree

advanced_alchemy/extensions/fastapi/providers.py

Lines changed: 35 additions & 100 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,13 @@
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+
)
5558
from advanced_alchemy.utils.text import camelize
5659

5760
logger = logging.getLogger("advanced_alchemy.extensions.fastapi")
@@ -67,25 +70,21 @@
6770
BooleanOrNone = Optional[bool]
6871
SortOrder = Literal["asc", "desc"]
6972
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
7373
AsyncServiceT_co = TypeVar("AsyncServiceT_co", bound=SQLAlchemyAsyncRepositoryService[Any, Any], covariant=True)
7474
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, ...]]
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

9190
class DependencyDefaults:
@@ -110,51 +109,9 @@ class DependencyDefaults:
110109
DEPENDENCY_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-
126112
dep_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-
158115
def _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
178145
def 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-
446382
def _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

Comments
 (0)