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 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