fastapi-fsp 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_fsp/__init__.py +9 -0
- fastapi_fsp/filters.py +372 -0
- fastapi_fsp/fsp.py +87 -297
- fastapi_fsp/pagination.py +324 -0
- fastapi_fsp/sorting.py +71 -0
- {fastapi_fsp-0.3.0.dist-info → fastapi_fsp-0.4.0.dist-info}/METADATA +1 -1
- fastapi_fsp-0.4.0.dist-info/RECORD +13 -0
- fastapi_fsp-0.3.0.dist-info/RECORD +0 -10
- {fastapi_fsp-0.3.0.dist-info → fastapi_fsp-0.4.0.dist-info}/WHEEL +0 -0
- {fastapi_fsp-0.3.0.dist-info → fastapi_fsp-0.4.0.dist-info}/licenses/LICENSE +0 -0
fastapi_fsp/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from . import models as models # noqa: F401
|
|
4
4
|
from .builder import FieldBuilder, FilterBuilder # noqa: F401
|
|
5
5
|
from .config import FSPConfig, FSPPresets # noqa: F401
|
|
6
|
+
from .filters import FILTER_STRATEGIES, FilterEngine # noqa: F401
|
|
6
7
|
from .fsp import FSPManager # noqa: F401
|
|
7
8
|
from .models import ( # noqa: F401
|
|
8
9
|
Filter,
|
|
@@ -15,11 +16,19 @@ from .models import ( # noqa: F401
|
|
|
15
16
|
SortingOrder,
|
|
16
17
|
SortingQuery,
|
|
17
18
|
)
|
|
19
|
+
from .pagination import PaginationEngine # noqa: F401
|
|
18
20
|
from .presets import CommonFilters # noqa: F401
|
|
21
|
+
from .sorting import SortEngine # noqa: F401
|
|
19
22
|
|
|
20
23
|
__all__ = [
|
|
21
24
|
# Main class
|
|
22
25
|
"FSPManager",
|
|
26
|
+
# Engines
|
|
27
|
+
"FilterEngine",
|
|
28
|
+
"SortEngine",
|
|
29
|
+
"PaginationEngine",
|
|
30
|
+
# Strategy registry
|
|
31
|
+
"FILTER_STRATEGIES",
|
|
23
32
|
# Builder
|
|
24
33
|
"FilterBuilder",
|
|
25
34
|
"FieldBuilder",
|
fastapi_fsp/filters.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Filter engine with strategy pattern for operator handling."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from dateutil.parser import parse
|
|
7
|
+
from fastapi import HTTPException, status
|
|
8
|
+
from sqlalchemy import ColumnCollection, ColumnElement, Select, func
|
|
9
|
+
from sqlmodel import not_
|
|
10
|
+
|
|
11
|
+
from fastapi_fsp.models import Filter, FilterOperator
|
|
12
|
+
|
|
13
|
+
# Type alias for filter strategy functions
|
|
14
|
+
FilterStrategyFn = Callable[[ColumnElement[Any], str, Optional[type]], Optional[Any]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _coerce_value(column: ColumnElement[Any], raw: str, pytype: Optional[type] = None) -> Any:
|
|
18
|
+
"""
|
|
19
|
+
Coerce raw string value to column's Python type.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
column: SQLAlchemy column element
|
|
23
|
+
raw: Raw string value
|
|
24
|
+
pytype: Optional pre-fetched python type (for performance)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Any: Coerced value
|
|
28
|
+
"""
|
|
29
|
+
if pytype is None:
|
|
30
|
+
try:
|
|
31
|
+
pytype = getattr(column.type, "python_type", None)
|
|
32
|
+
except Exception:
|
|
33
|
+
pytype = None
|
|
34
|
+
if pytype is None or isinstance(raw, pytype):
|
|
35
|
+
return raw
|
|
36
|
+
if pytype is bool:
|
|
37
|
+
val = raw.strip().lower()
|
|
38
|
+
if val in {"true", "1", "t", "yes", "y"}:
|
|
39
|
+
return True
|
|
40
|
+
if val in {"false", "0", "f", "no", "n"}:
|
|
41
|
+
return False
|
|
42
|
+
if pytype is int:
|
|
43
|
+
try:
|
|
44
|
+
return int(raw)
|
|
45
|
+
except ValueError:
|
|
46
|
+
try:
|
|
47
|
+
return int(float(raw))
|
|
48
|
+
except ValueError:
|
|
49
|
+
return raw
|
|
50
|
+
if pytype is datetime:
|
|
51
|
+
try:
|
|
52
|
+
return datetime.fromisoformat(raw)
|
|
53
|
+
except (ValueError, AttributeError):
|
|
54
|
+
try:
|
|
55
|
+
return parse(raw)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return raw
|
|
58
|
+
try:
|
|
59
|
+
return pytype(raw)
|
|
60
|
+
except Exception:
|
|
61
|
+
return raw
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split_values(raw: str) -> List[str]:
|
|
65
|
+
"""
|
|
66
|
+
Split comma-separated values.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
raw: Raw string of comma-separated values
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
List[str]: List of stripped values
|
|
73
|
+
"""
|
|
74
|
+
return [item.strip() for item in raw.split(",")]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ilike_supported(col: ColumnElement[Any]) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Check if ILIKE is supported for this column.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
col: SQLAlchemy column element
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
bool: True if ILIKE is supported
|
|
86
|
+
"""
|
|
87
|
+
return hasattr(col, "ilike")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- Strategy functions for each filter operator ---
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _strategy_eq(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
94
|
+
return column == _coerce_value(column, raw, pytype)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _strategy_ne(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
98
|
+
return column != _coerce_value(column, raw, pytype)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _strategy_gt(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
102
|
+
return column > _coerce_value(column, raw, pytype)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _strategy_gte(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
106
|
+
return column >= _coerce_value(column, raw, pytype)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _strategy_lt(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
110
|
+
return column < _coerce_value(column, raw, pytype)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _strategy_lte(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
114
|
+
return column <= _coerce_value(column, raw, pytype)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _strategy_like(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
118
|
+
return column.like(raw)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _strategy_not_like(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
122
|
+
return not_(column.like(raw))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _strategy_ilike(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
126
|
+
if _ilike_supported(column):
|
|
127
|
+
return column.ilike(raw)
|
|
128
|
+
return func.lower(column).like(raw.lower())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _strategy_not_ilike(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
132
|
+
if _ilike_supported(column):
|
|
133
|
+
return not_(column.ilike(raw))
|
|
134
|
+
return not_(func.lower(column).like(raw.lower()))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _strategy_in(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
138
|
+
vals = [_coerce_value(column, v, pytype) for v in _split_values(raw)]
|
|
139
|
+
return column.in_(vals)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _strategy_not_in(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
143
|
+
vals = [_coerce_value(column, v, pytype) for v in _split_values(raw)]
|
|
144
|
+
return not_(column.in_(vals))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _strategy_between(
|
|
148
|
+
column: ColumnElement[Any], raw: str, pytype: Optional[type]
|
|
149
|
+
) -> Optional[Any]:
|
|
150
|
+
vals = _split_values(raw)
|
|
151
|
+
if len(vals) == 2:
|
|
152
|
+
low = _coerce_value(column, vals[0], pytype)
|
|
153
|
+
high = _coerce_value(column, vals[1], pytype)
|
|
154
|
+
return column.between(low, high)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _strategy_is_null(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
159
|
+
return column.is_(None)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _strategy_is_not_null(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
163
|
+
return column.is_not(None)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _strategy_starts_with(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
167
|
+
pattern = f"{raw}%"
|
|
168
|
+
if _ilike_supported(column):
|
|
169
|
+
return column.ilike(pattern)
|
|
170
|
+
return func.lower(column).like(pattern.lower())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _strategy_ends_with(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
174
|
+
pattern = f"%{raw}"
|
|
175
|
+
if _ilike_supported(column):
|
|
176
|
+
return column.ilike(pattern)
|
|
177
|
+
return func.lower(column).like(pattern.lower())
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _strategy_contains(column: ColumnElement[Any], raw: str, pytype: Optional[type]) -> Any:
|
|
181
|
+
pattern = f"%{raw}%"
|
|
182
|
+
if _ilike_supported(column):
|
|
183
|
+
return column.ilike(pattern)
|
|
184
|
+
return func.lower(column).like(pattern.lower())
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Strategy registry: maps FilterOperator -> handler function
|
|
188
|
+
FILTER_STRATEGIES: Dict[FilterOperator, FilterStrategyFn] = {
|
|
189
|
+
FilterOperator.EQ: _strategy_eq,
|
|
190
|
+
FilterOperator.NE: _strategy_ne,
|
|
191
|
+
FilterOperator.GT: _strategy_gt,
|
|
192
|
+
FilterOperator.GTE: _strategy_gte,
|
|
193
|
+
FilterOperator.LT: _strategy_lt,
|
|
194
|
+
FilterOperator.LTE: _strategy_lte,
|
|
195
|
+
FilterOperator.LIKE: _strategy_like,
|
|
196
|
+
FilterOperator.NOT_LIKE: _strategy_not_like,
|
|
197
|
+
FilterOperator.ILIKE: _strategy_ilike,
|
|
198
|
+
FilterOperator.NOT_ILIKE: _strategy_not_ilike,
|
|
199
|
+
FilterOperator.IN: _strategy_in,
|
|
200
|
+
FilterOperator.NOT_IN: _strategy_not_in,
|
|
201
|
+
FilterOperator.BETWEEN: _strategy_between,
|
|
202
|
+
FilterOperator.IS_NULL: _strategy_is_null,
|
|
203
|
+
FilterOperator.IS_NOT_NULL: _strategy_is_not_null,
|
|
204
|
+
FilterOperator.STARTS_WITH: _strategy_starts_with,
|
|
205
|
+
FilterOperator.ENDS_WITH: _strategy_ends_with,
|
|
206
|
+
FilterOperator.CONTAINS: _strategy_contains,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class FilterEngine:
|
|
211
|
+
"""
|
|
212
|
+
Engine for building and applying SQL filter conditions.
|
|
213
|
+
|
|
214
|
+
Uses the strategy pattern to dispatch filter operations by operator type,
|
|
215
|
+
replacing monolithic if/elif chains with a registry of handler functions.
|
|
216
|
+
|
|
217
|
+
Custom strategies can be registered to extend or override operators.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, strict_mode: bool = False):
|
|
221
|
+
"""
|
|
222
|
+
Initialize FilterEngine.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
strict_mode: If True, raise errors for unknown fields
|
|
226
|
+
"""
|
|
227
|
+
self.strict_mode = strict_mode
|
|
228
|
+
self._type_cache: dict[int, Optional[type]] = {}
|
|
229
|
+
|
|
230
|
+
def get_column_type(self, column: ColumnElement[Any]) -> Optional[type]:
|
|
231
|
+
"""
|
|
232
|
+
Get the Python type of a column with caching.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
column: SQLAlchemy column element
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Optional[type]: Python type of the column or None
|
|
239
|
+
"""
|
|
240
|
+
col_id = id(column)
|
|
241
|
+
if col_id not in self._type_cache:
|
|
242
|
+
try:
|
|
243
|
+
self._type_cache[col_id] = getattr(column.type, "python_type", None)
|
|
244
|
+
except (AttributeError, NotImplementedError):
|
|
245
|
+
self._type_cache[col_id] = None
|
|
246
|
+
return self._type_cache[col_id]
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def get_entity_attribute(query: Select, field: str) -> Optional[ColumnElement[Any]]:
|
|
250
|
+
"""
|
|
251
|
+
Try to get a column-like attribute from the query's entity.
|
|
252
|
+
|
|
253
|
+
This enables filtering/sorting on computed fields like hybrid_property
|
|
254
|
+
that have SQL expressions defined.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
query: SQLAlchemy Select query
|
|
258
|
+
field: Name of the field/attribute to get
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Optional[ColumnElement]: The SQL expression if available, None otherwise
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
column_descriptions = query.column_descriptions
|
|
265
|
+
if not column_descriptions:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
entity = column_descriptions[0].get("entity")
|
|
269
|
+
if entity is None:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
attr = getattr(entity, field, None)
|
|
273
|
+
if attr is None:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
if isinstance(attr, ColumnElement):
|
|
277
|
+
return attr
|
|
278
|
+
|
|
279
|
+
if hasattr(attr, "__clause_element__"):
|
|
280
|
+
return attr.__clause_element__()
|
|
281
|
+
|
|
282
|
+
return None
|
|
283
|
+
except Exception:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def build_filter_condition(
|
|
288
|
+
column: ColumnElement[Any], f: Filter, pytype: Optional[type] = None
|
|
289
|
+
) -> Optional[Any]:
|
|
290
|
+
"""
|
|
291
|
+
Build a filter condition using strategy pattern dispatch.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
column: Column to apply filter to
|
|
295
|
+
f: Filter to apply
|
|
296
|
+
pytype: Optional pre-fetched python type (for performance)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Optional[Any]: SQLAlchemy condition or None if invalid/unknown operator
|
|
300
|
+
"""
|
|
301
|
+
strategy = FILTER_STRATEGIES.get(f.operator)
|
|
302
|
+
if strategy is None:
|
|
303
|
+
return None
|
|
304
|
+
return strategy(column, f.value, pytype)
|
|
305
|
+
|
|
306
|
+
def apply_filters(
|
|
307
|
+
self,
|
|
308
|
+
query: Select,
|
|
309
|
+
columns_map: ColumnCollection[str, ColumnElement[Any]],
|
|
310
|
+
filters: Optional[List[Filter]],
|
|
311
|
+
) -> Select:
|
|
312
|
+
"""
|
|
313
|
+
Apply filters to a query.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
query: Base SQLAlchemy Select query
|
|
317
|
+
columns_map: Map of column names to column elements
|
|
318
|
+
filters: List of filters to apply
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Select: Query with filters applied
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
HTTPException: If strict_mode is True and unknown field is encountered
|
|
325
|
+
"""
|
|
326
|
+
if not filters:
|
|
327
|
+
return query
|
|
328
|
+
|
|
329
|
+
conditions = []
|
|
330
|
+
for f in filters:
|
|
331
|
+
column = columns_map.get(f.field)
|
|
332
|
+
|
|
333
|
+
if column is None:
|
|
334
|
+
column = self.get_entity_attribute(query, f.field)
|
|
335
|
+
|
|
336
|
+
if column is None:
|
|
337
|
+
if self.strict_mode:
|
|
338
|
+
available = ", ".join(sorted(columns_map.keys()))
|
|
339
|
+
raise HTTPException(
|
|
340
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
341
|
+
detail=f"Unknown field '{f.field}'. Available fields: {available}",
|
|
342
|
+
)
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
pytype = self.get_column_type(column)
|
|
346
|
+
condition = self.build_filter_condition(column, f, pytype)
|
|
347
|
+
if condition is not None:
|
|
348
|
+
conditions.append(condition)
|
|
349
|
+
|
|
350
|
+
if conditions:
|
|
351
|
+
query = query.where(*conditions)
|
|
352
|
+
|
|
353
|
+
return query
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def register_strategy(operator: FilterOperator, strategy: FilterStrategyFn) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Register a custom filter strategy for an operator.
|
|
359
|
+
|
|
360
|
+
This allows extending or overriding the built-in filter strategies.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
operator: The FilterOperator to register for
|
|
364
|
+
strategy: A callable with signature (column, raw_value, pytype) -> condition
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
def my_custom_eq(column, raw, pytype):
|
|
368
|
+
return column == raw # Skip type coercion
|
|
369
|
+
|
|
370
|
+
FilterEngine.register_strategy(FilterOperator.EQ, my_custom_eq)
|
|
371
|
+
"""
|
|
372
|
+
FILTER_STRATEGIES[operator] = strategy
|