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/fsp.py
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
"""FastAPI-SQLModel-Pagination module"""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from math import ceil
|
|
5
3
|
from typing import Annotated, Any, List, Optional, Type
|
|
6
4
|
|
|
7
|
-
from dateutil.parser import parse
|
|
8
5
|
from fastapi import Depends, HTTPException, Query, Request, status
|
|
9
6
|
from pydantic import ValidationError
|
|
10
|
-
from sqlalchemy import ColumnCollection, ColumnElement, Select
|
|
11
|
-
from sqlmodel import Session, SQLModel,
|
|
7
|
+
from sqlalchemy import ColumnCollection, ColumnElement, Select
|
|
8
|
+
from sqlmodel import Session, SQLModel, select
|
|
12
9
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
13
10
|
|
|
14
11
|
from fastapi_fsp.config import FSPConfig
|
|
12
|
+
from fastapi_fsp.filters import FilterEngine, _coerce_value, _ilike_supported, _split_values
|
|
15
13
|
from fastapi_fsp.models import (
|
|
16
14
|
Filter,
|
|
17
15
|
FilterOperator,
|
|
18
|
-
Links,
|
|
19
|
-
Meta,
|
|
20
16
|
PaginatedResponse,
|
|
21
|
-
Pagination,
|
|
22
17
|
PaginationQuery,
|
|
23
18
|
SortingOrder,
|
|
24
19
|
SortingQuery,
|
|
25
20
|
)
|
|
21
|
+
from fastapi_fsp.pagination import PaginationEngine
|
|
22
|
+
from fastapi_fsp.sorting import SortEngine
|
|
26
23
|
|
|
27
24
|
|
|
28
25
|
def _parse_one_filter_at(i: int, field: str, operator: str, value: str) -> Filter:
|
|
@@ -187,7 +184,14 @@ class FSPManager:
|
|
|
187
184
|
"""
|
|
188
185
|
FastAPI Filtering, Sorting, and Pagination Manager.
|
|
189
186
|
|
|
190
|
-
|
|
187
|
+
Orchestrates FilterEngine, SortEngine, and PaginationEngine to handle
|
|
188
|
+
query parameters and apply them to SQLModel queries.
|
|
189
|
+
|
|
190
|
+
The FSP pipeline is split into focused engine classes:
|
|
191
|
+
- FilterEngine: Strategy-pattern filter operator dispatch and application
|
|
192
|
+
- SortEngine: Column resolution and sort direction
|
|
193
|
+
- PaginationEngine: Pagination, counting, and response building
|
|
194
|
+
(with optional PostgreSQL window function optimization)
|
|
191
195
|
"""
|
|
192
196
|
|
|
193
197
|
def __init__(
|
|
@@ -197,6 +201,7 @@ class FSPManager:
|
|
|
197
201
|
sorting: Annotated[Optional[SortingQuery], Depends(_parse_sort)],
|
|
198
202
|
pagination: Annotated[PaginationQuery, Depends(_parse_pagination)],
|
|
199
203
|
strict_mode: bool = False,
|
|
204
|
+
use_window_function: Optional[bool] = None,
|
|
200
205
|
):
|
|
201
206
|
"""
|
|
202
207
|
Initialize FSPManager.
|
|
@@ -207,40 +212,59 @@ class FSPManager:
|
|
|
207
212
|
sorting: Sorting configuration
|
|
208
213
|
pagination: Pagination configuration
|
|
209
214
|
strict_mode: If True, raise errors for unknown fields instead of silently skipping
|
|
215
|
+
use_window_function: Force PostgreSQL window function optimization on/off.
|
|
216
|
+
None = auto-detect (enabled for PostgreSQL, disabled for others).
|
|
210
217
|
"""
|
|
211
218
|
self.request = request
|
|
212
219
|
self.filters = filters
|
|
213
220
|
self.sorting = sorting
|
|
214
221
|
self.pagination = pagination
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
|
|
223
|
+
# Initialize engines
|
|
224
|
+
self._filter_engine = FilterEngine(strict_mode=strict_mode)
|
|
225
|
+
self._sort_engine = SortEngine(strict_mode=strict_mode)
|
|
226
|
+
self._pagination_engine = PaginationEngine(
|
|
227
|
+
pagination=pagination,
|
|
228
|
+
request=request,
|
|
229
|
+
use_window_function=use_window_function,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def strict_mode(self) -> bool:
|
|
234
|
+
"""Get strict mode setting."""
|
|
235
|
+
return self._filter_engine.strict_mode
|
|
236
|
+
|
|
237
|
+
@strict_mode.setter
|
|
238
|
+
def strict_mode(self, value: bool) -> None:
|
|
239
|
+
"""Set strict mode on all engines."""
|
|
240
|
+
self._filter_engine.strict_mode = value
|
|
241
|
+
self._sort_engine.strict_mode = value
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def _type_cache(self) -> dict:
|
|
245
|
+
"""Backward-compatible access to filter engine type cache."""
|
|
246
|
+
return self._filter_engine._type_cache
|
|
217
247
|
|
|
218
248
|
def _get_column_type(self, column: ColumnElement[Any]) -> Optional[type]:
|
|
219
249
|
"""
|
|
220
250
|
Get the Python type of a column with caching.
|
|
221
251
|
|
|
252
|
+
Delegates to FilterEngine.get_column_type().
|
|
253
|
+
|
|
222
254
|
Args:
|
|
223
255
|
column: SQLAlchemy column element
|
|
224
256
|
|
|
225
257
|
Returns:
|
|
226
258
|
Optional[type]: Python type of the column or None
|
|
227
259
|
"""
|
|
228
|
-
|
|
229
|
-
if col_id not in self._type_cache:
|
|
230
|
-
try:
|
|
231
|
-
self._type_cache[col_id] = getattr(column.type, "python_type", None)
|
|
232
|
-
except (AttributeError, NotImplementedError):
|
|
233
|
-
# For computed fields (hybrid_property, etc.), type inference may fail
|
|
234
|
-
self._type_cache[col_id] = None
|
|
235
|
-
return self._type_cache[col_id]
|
|
260
|
+
return self._filter_engine.get_column_type(column)
|
|
236
261
|
|
|
237
262
|
@staticmethod
|
|
238
263
|
def _get_entity_attribute(query: Select, field: str) -> Optional[ColumnElement[Any]]:
|
|
239
264
|
"""
|
|
240
265
|
Try to get a column-like attribute from the query's entity.
|
|
241
266
|
|
|
242
|
-
|
|
243
|
-
that have SQL expressions defined.
|
|
267
|
+
Delegates to FilterEngine.get_entity_attribute().
|
|
244
268
|
|
|
245
269
|
Args:
|
|
246
270
|
query: SQLAlchemy Select query
|
|
@@ -249,38 +273,14 @@ class FSPManager:
|
|
|
249
273
|
Returns:
|
|
250
274
|
Optional[ColumnElement]: The SQL expression if available, None otherwise
|
|
251
275
|
"""
|
|
252
|
-
|
|
253
|
-
# Get the entity class from the query
|
|
254
|
-
column_descriptions = query.column_descriptions
|
|
255
|
-
if not column_descriptions:
|
|
256
|
-
return None
|
|
257
|
-
|
|
258
|
-
entity = column_descriptions[0].get("entity")
|
|
259
|
-
if entity is None:
|
|
260
|
-
return None
|
|
261
|
-
|
|
262
|
-
# Get the attribute from the entity class
|
|
263
|
-
attr = getattr(entity, field, None)
|
|
264
|
-
if attr is None:
|
|
265
|
-
return None
|
|
266
|
-
|
|
267
|
-
# Check if it's directly usable as a ColumnElement (hybrid_property with expression)
|
|
268
|
-
# When accessing a hybrid_property on the class, it returns the SQL expression
|
|
269
|
-
if isinstance(attr, ColumnElement):
|
|
270
|
-
return attr
|
|
271
|
-
|
|
272
|
-
# Some expressions may need to call __clause_element__
|
|
273
|
-
if hasattr(attr, "__clause_element__"):
|
|
274
|
-
return attr.__clause_element__()
|
|
275
|
-
|
|
276
|
-
return None
|
|
277
|
-
except Exception:
|
|
278
|
-
return None
|
|
276
|
+
return FilterEngine.get_entity_attribute(query, field)
|
|
279
277
|
|
|
280
278
|
def paginate(self, query: Select, session: Session) -> Any:
|
|
281
279
|
"""
|
|
282
280
|
Execute pagination on a query.
|
|
283
281
|
|
|
282
|
+
Delegates to PaginationEngine.paginate().
|
|
283
|
+
|
|
284
284
|
Args:
|
|
285
285
|
query: SQLAlchemy Select query
|
|
286
286
|
session: Database session
|
|
@@ -288,16 +288,14 @@ class FSPManager:
|
|
|
288
288
|
Returns:
|
|
289
289
|
Any: Query results
|
|
290
290
|
"""
|
|
291
|
-
return
|
|
292
|
-
query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
|
|
293
|
-
self.pagination.per_page
|
|
294
|
-
)
|
|
295
|
-
).all()
|
|
291
|
+
return self._pagination_engine.paginate(query, session)
|
|
296
292
|
|
|
297
293
|
async def paginate_async(self, query: Select, session: AsyncSession) -> Any:
|
|
298
294
|
"""
|
|
299
295
|
Execute pagination on a query asynchronously.
|
|
300
296
|
|
|
297
|
+
Delegates to PaginationEngine.paginate_async().
|
|
298
|
+
|
|
301
299
|
Args:
|
|
302
300
|
query: SQLAlchemy Select query
|
|
303
301
|
session: Async database session
|
|
@@ -305,12 +303,7 @@ class FSPManager:
|
|
|
305
303
|
Returns:
|
|
306
304
|
Any: Query results
|
|
307
305
|
"""
|
|
308
|
-
|
|
309
|
-
query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
|
|
310
|
-
self.pagination.per_page
|
|
311
|
-
)
|
|
312
|
-
)
|
|
313
|
-
return result.all()
|
|
306
|
+
return await self._pagination_engine.paginate_async(query, session)
|
|
314
307
|
|
|
315
308
|
def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
|
|
316
309
|
"""
|
|
@@ -327,9 +320,13 @@ class FSPManager:
|
|
|
327
320
|
query = self._apply_filters(query, columns_map, self.filters)
|
|
328
321
|
query = self._apply_sort(query, columns_map, self.sorting)
|
|
329
322
|
|
|
330
|
-
total_items = self.
|
|
331
|
-
|
|
332
|
-
|
|
323
|
+
data_page, total_items = self._pagination_engine.paginate_with_count(query, session)
|
|
324
|
+
return self._pagination_engine.build_response(
|
|
325
|
+
total_items=total_items,
|
|
326
|
+
data_page=data_page,
|
|
327
|
+
filters=self.filters,
|
|
328
|
+
sorting=self.sorting,
|
|
329
|
+
)
|
|
333
330
|
|
|
334
331
|
async def generate_response_async(
|
|
335
332
|
self, query: Select, session: AsyncSession
|
|
@@ -348,60 +345,14 @@ class FSPManager:
|
|
|
348
345
|
query = self._apply_filters(query, columns_map, self.filters)
|
|
349
346
|
query = self._apply_sort(query, columns_map, self.sorting)
|
|
350
347
|
|
|
351
|
-
total_items = await self.
|
|
352
|
-
|
|
353
|
-
return self._generate_response(total_items=total_items, data_page=data_page)
|
|
354
|
-
|
|
355
|
-
def _generate_response(self, total_items: int, data_page: Any) -> PaginatedResponse[Any]:
|
|
356
|
-
"""
|
|
357
|
-
Generate the final paginated response object.
|
|
358
|
-
|
|
359
|
-
Args:
|
|
360
|
-
total_items: Total number of items matching filters
|
|
361
|
-
data_page: Current page of data
|
|
362
|
-
|
|
363
|
-
Returns:
|
|
364
|
-
PaginatedResponse: Final response object
|
|
365
|
-
"""
|
|
366
|
-
per_page = self.pagination.per_page
|
|
367
|
-
current_page = self.pagination.page
|
|
368
|
-
total_pages = max(1, ceil(total_items / per_page)) if total_items is not None else 1
|
|
369
|
-
|
|
370
|
-
# Build links based on current URL, replacing/adding page and per_page parameters
|
|
371
|
-
url = self.request.url
|
|
372
|
-
first_url = str(url.include_query_params(page=1, per_page=per_page))
|
|
373
|
-
last_url = str(url.include_query_params(page=total_pages, per_page=per_page))
|
|
374
|
-
next_url = (
|
|
375
|
-
str(url.include_query_params(page=current_page + 1, per_page=per_page))
|
|
376
|
-
if current_page < total_pages
|
|
377
|
-
else None
|
|
378
|
-
)
|
|
379
|
-
prev_url = (
|
|
380
|
-
str(url.include_query_params(page=current_page - 1, per_page=per_page))
|
|
381
|
-
if current_page > 1
|
|
382
|
-
else None
|
|
348
|
+
data_page, total_items = await self._pagination_engine.paginate_with_count_async(
|
|
349
|
+
query, session
|
|
383
350
|
)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
pagination=Pagination(
|
|
390
|
-
total_items=total_items,
|
|
391
|
-
per_page=per_page,
|
|
392
|
-
current_page=current_page,
|
|
393
|
-
total_pages=total_pages,
|
|
394
|
-
),
|
|
395
|
-
filters=self.filters,
|
|
396
|
-
sort=self.sorting,
|
|
397
|
-
),
|
|
398
|
-
links=Links(
|
|
399
|
-
self=self_url,
|
|
400
|
-
first=first_url,
|
|
401
|
-
last=last_url,
|
|
402
|
-
next=next_url,
|
|
403
|
-
prev=prev_url,
|
|
404
|
-
),
|
|
351
|
+
return self._pagination_engine.build_response(
|
|
352
|
+
total_items=total_items,
|
|
353
|
+
data_page=data_page,
|
|
354
|
+
filters=self.filters,
|
|
355
|
+
sorting=self.sorting,
|
|
405
356
|
)
|
|
406
357
|
|
|
407
358
|
@staticmethod
|
|
@@ -409,6 +360,8 @@ class FSPManager:
|
|
|
409
360
|
"""
|
|
410
361
|
Coerce raw string value to column's Python type.
|
|
411
362
|
|
|
363
|
+
Delegates to filters._coerce_value().
|
|
364
|
+
|
|
412
365
|
Args:
|
|
413
366
|
column: SQLAlchemy column element
|
|
414
367
|
raw: Raw string value
|
|
@@ -417,80 +370,46 @@ class FSPManager:
|
|
|
417
370
|
Returns:
|
|
418
371
|
Any: Coerced value
|
|
419
372
|
"""
|
|
420
|
-
|
|
421
|
-
if pytype is None:
|
|
422
|
-
try:
|
|
423
|
-
pytype = getattr(column.type, "python_type", None)
|
|
424
|
-
except Exception:
|
|
425
|
-
pytype = None
|
|
426
|
-
if pytype is None or isinstance(raw, pytype):
|
|
427
|
-
return raw
|
|
428
|
-
# Handle booleans represented as strings
|
|
429
|
-
if pytype is bool:
|
|
430
|
-
val = raw.strip().lower()
|
|
431
|
-
if val in {"true", "1", "t", "yes", "y"}:
|
|
432
|
-
return True
|
|
433
|
-
if val in {"false", "0", "f", "no", "n"}:
|
|
434
|
-
return False
|
|
435
|
-
# Handle integers represented as strings
|
|
436
|
-
if pytype is int:
|
|
437
|
-
try:
|
|
438
|
-
return int(raw)
|
|
439
|
-
except ValueError:
|
|
440
|
-
# Handle common cases like "1.0"
|
|
441
|
-
try:
|
|
442
|
-
return int(float(raw))
|
|
443
|
-
except ValueError:
|
|
444
|
-
return raw
|
|
445
|
-
# Handle dates represented as strings - optimized with fast path for ISO 8601
|
|
446
|
-
if pytype is datetime:
|
|
447
|
-
try:
|
|
448
|
-
# Fast path for ISO 8601 format (most common)
|
|
449
|
-
return datetime.fromisoformat(raw)
|
|
450
|
-
except (ValueError, AttributeError):
|
|
451
|
-
# Fallback to flexible dateutil parser for other formats
|
|
452
|
-
try:
|
|
453
|
-
return parse(raw)
|
|
454
|
-
except ValueError:
|
|
455
|
-
return raw
|
|
456
|
-
# Generic cast with fallback
|
|
457
|
-
try:
|
|
458
|
-
return pytype(raw)
|
|
459
|
-
except Exception:
|
|
460
|
-
return raw
|
|
373
|
+
return _coerce_value(column, raw, pytype)
|
|
461
374
|
|
|
462
375
|
@staticmethod
|
|
463
376
|
def _split_values(raw: str) -> List[str]:
|
|
464
377
|
"""
|
|
465
378
|
Split comma-separated values.
|
|
466
379
|
|
|
380
|
+
Delegates to filters._split_values().
|
|
381
|
+
|
|
467
382
|
Args:
|
|
468
383
|
raw: Raw string of comma-separated values
|
|
469
384
|
|
|
470
385
|
Returns:
|
|
471
386
|
List[str]: List of stripped values
|
|
472
387
|
"""
|
|
473
|
-
return
|
|
388
|
+
return _split_values(raw)
|
|
474
389
|
|
|
475
390
|
@staticmethod
|
|
476
391
|
def _ilike_supported(col: ColumnElement[Any]) -> bool:
|
|
477
392
|
"""
|
|
478
393
|
Check if ILIKE is supported for this column.
|
|
479
394
|
|
|
395
|
+
Delegates to filters._ilike_supported().
|
|
396
|
+
|
|
480
397
|
Args:
|
|
481
398
|
col: SQLAlchemy column element
|
|
482
399
|
|
|
483
400
|
Returns:
|
|
484
401
|
bool: True if ILIKE is supported
|
|
485
402
|
"""
|
|
486
|
-
return
|
|
403
|
+
return _ilike_supported(col)
|
|
487
404
|
|
|
488
405
|
@staticmethod
|
|
489
406
|
def _build_filter_condition(
|
|
490
407
|
column: ColumnElement[Any], f: Filter, pytype: Optional[type] = None
|
|
491
408
|
) -> Optional[Any]:
|
|
492
409
|
"""
|
|
493
|
-
Build a filter condition for a query.
|
|
410
|
+
Build a filter condition for a query using strategy pattern dispatch.
|
|
411
|
+
|
|
412
|
+
Delegates to FilterEngine.build_filter_condition().
|
|
494
413
|
|
|
495
414
|
Args:
|
|
496
415
|
column: Column to apply filter to
|
|
@@ -500,82 +419,7 @@ class FSPManager:
|
|
|
500
419
|
Returns:
|
|
501
420
|
Optional[Any]: SQLAlchemy condition or None if invalid
|
|
502
421
|
"""
|
|
503
|
-
|
|
504
|
-
raw_value = f.value # type: str
|
|
505
|
-
|
|
506
|
-
# Build conditions based on operator
|
|
507
|
-
if op == FilterOperator.EQ:
|
|
508
|
-
return column == FSPManager._coerce_value(column, raw_value, pytype)
|
|
509
|
-
elif op == FilterOperator.NE:
|
|
510
|
-
return column != FSPManager._coerce_value(column, raw_value, pytype)
|
|
511
|
-
elif op == FilterOperator.GT:
|
|
512
|
-
return column > FSPManager._coerce_value(column, raw_value, pytype)
|
|
513
|
-
elif op == FilterOperator.GTE:
|
|
514
|
-
return column >= FSPManager._coerce_value(column, raw_value, pytype)
|
|
515
|
-
elif op == FilterOperator.LT:
|
|
516
|
-
return column < FSPManager._coerce_value(column, raw_value, pytype)
|
|
517
|
-
elif op == FilterOperator.LTE:
|
|
518
|
-
return column <= FSPManager._coerce_value(column, raw_value, pytype)
|
|
519
|
-
elif op == FilterOperator.LIKE:
|
|
520
|
-
return column.like(raw_value)
|
|
521
|
-
elif op == FilterOperator.NOT_LIKE:
|
|
522
|
-
return not_(column.like(raw_value))
|
|
523
|
-
elif op == FilterOperator.ILIKE:
|
|
524
|
-
pattern = raw_value
|
|
525
|
-
if FSPManager._ilike_supported(column):
|
|
526
|
-
return column.ilike(pattern)
|
|
527
|
-
else:
|
|
528
|
-
return func.lower(column).like(pattern.lower())
|
|
529
|
-
elif op == FilterOperator.NOT_ILIKE:
|
|
530
|
-
pattern = raw_value
|
|
531
|
-
if FSPManager._ilike_supported(column):
|
|
532
|
-
return not_(column.ilike(pattern))
|
|
533
|
-
else:
|
|
534
|
-
return not_(func.lower(column).like(pattern.lower()))
|
|
535
|
-
elif op == FilterOperator.IN:
|
|
536
|
-
vals = [
|
|
537
|
-
FSPManager._coerce_value(column, v, pytype)
|
|
538
|
-
for v in FSPManager._split_values(raw_value)
|
|
539
|
-
]
|
|
540
|
-
return column.in_(vals)
|
|
541
|
-
elif op == FilterOperator.NOT_IN:
|
|
542
|
-
vals = [
|
|
543
|
-
FSPManager._coerce_value(column, v, pytype)
|
|
544
|
-
for v in FSPManager._split_values(raw_value)
|
|
545
|
-
]
|
|
546
|
-
return not_(column.in_(vals))
|
|
547
|
-
elif op == FilterOperator.BETWEEN:
|
|
548
|
-
vals = FSPManager._split_values(raw_value)
|
|
549
|
-
if len(vals) == 2:
|
|
550
|
-
low = FSPManager._coerce_value(column, vals[0], pytype)
|
|
551
|
-
high = FSPManager._coerce_value(column, vals[1], pytype)
|
|
552
|
-
return column.between(low, high)
|
|
553
|
-
# Ignore malformed between
|
|
554
|
-
return None
|
|
555
|
-
elif op == FilterOperator.IS_NULL:
|
|
556
|
-
return column.is_(None)
|
|
557
|
-
elif op == FilterOperator.IS_NOT_NULL:
|
|
558
|
-
return column.is_not(None)
|
|
559
|
-
elif op == FilterOperator.STARTS_WITH:
|
|
560
|
-
pattern = f"{raw_value}%"
|
|
561
|
-
if FSPManager._ilike_supported(column):
|
|
562
|
-
return column.ilike(pattern)
|
|
563
|
-
else:
|
|
564
|
-
return func.lower(column).like(pattern.lower())
|
|
565
|
-
elif op == FilterOperator.ENDS_WITH:
|
|
566
|
-
pattern = f"%{raw_value}"
|
|
567
|
-
if FSPManager._ilike_supported(column):
|
|
568
|
-
return column.ilike(pattern)
|
|
569
|
-
else:
|
|
570
|
-
return func.lower(column).like(pattern.lower())
|
|
571
|
-
elif op == FilterOperator.CONTAINS:
|
|
572
|
-
pattern = f"%{raw_value}%"
|
|
573
|
-
if FSPManager._ilike_supported(column):
|
|
574
|
-
return column.ilike(pattern)
|
|
575
|
-
else:
|
|
576
|
-
return func.lower(column).like(pattern.lower())
|
|
577
|
-
# Unknown operator
|
|
578
|
-
return None
|
|
422
|
+
return FilterEngine.build_filter_condition(column, f, pytype)
|
|
579
423
|
|
|
580
424
|
@staticmethod
|
|
581
425
|
def _count_total(query: Select, session: Session) -> int:
|
|
@@ -589,9 +433,7 @@ class FSPManager:
|
|
|
589
433
|
Returns:
|
|
590
434
|
int: Total count of items
|
|
591
435
|
"""
|
|
592
|
-
|
|
593
|
-
count_query = select(func.count()).select_from(query.subquery())
|
|
594
|
-
return session.exec(count_query).one()
|
|
436
|
+
return PaginationEngine._count_total_static(query, session)
|
|
595
437
|
|
|
596
438
|
@staticmethod
|
|
597
439
|
async def _count_total_async(query: Select, session: AsyncSession) -> int:
|
|
@@ -605,9 +447,7 @@ class FSPManager:
|
|
|
605
447
|
Returns:
|
|
606
448
|
int: Total count of items
|
|
607
449
|
"""
|
|
608
|
-
|
|
609
|
-
result = await session.exec(count_query)
|
|
610
|
-
return result.one()
|
|
450
|
+
return await PaginationEngine._count_total_async_static(query, session)
|
|
611
451
|
|
|
612
452
|
def _apply_filters(
|
|
613
453
|
self,
|
|
@@ -618,6 +458,8 @@ class FSPManager:
|
|
|
618
458
|
"""
|
|
619
459
|
Apply filters to a query.
|
|
620
460
|
|
|
461
|
+
Delegates to FilterEngine.apply_filters().
|
|
462
|
+
|
|
621
463
|
Args:
|
|
622
464
|
query: Base SQLAlchemy Select query
|
|
623
465
|
columns_map: Map of column names to column elements
|
|
@@ -629,39 +471,7 @@ class FSPManager:
|
|
|
629
471
|
Raises:
|
|
630
472
|
HTTPException: If strict_mode is True and unknown field is encountered
|
|
631
473
|
"""
|
|
632
|
-
|
|
633
|
-
return query
|
|
634
|
-
|
|
635
|
-
conditions = []
|
|
636
|
-
for f in filters:
|
|
637
|
-
# filter of `filters` has been validated in the `_parse_filters`
|
|
638
|
-
column = columns_map.get(f.field)
|
|
639
|
-
|
|
640
|
-
# Fall back to computed fields (hybrid_property, etc.) if not in columns_map
|
|
641
|
-
if column is None:
|
|
642
|
-
column = FSPManager._get_entity_attribute(query, f.field)
|
|
643
|
-
|
|
644
|
-
if column is None:
|
|
645
|
-
if self.strict_mode:
|
|
646
|
-
available = ", ".join(sorted(columns_map.keys()))
|
|
647
|
-
raise HTTPException(
|
|
648
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
649
|
-
detail=f"Unknown field '{f.field}'. Available fields: {available}",
|
|
650
|
-
)
|
|
651
|
-
# Skip unknown fields silently in non-strict mode
|
|
652
|
-
continue
|
|
653
|
-
|
|
654
|
-
# Get column type from cache for better performance
|
|
655
|
-
pytype = self._get_column_type(column)
|
|
656
|
-
condition = FSPManager._build_filter_condition(column, f, pytype)
|
|
657
|
-
if condition is not None:
|
|
658
|
-
conditions.append(condition)
|
|
659
|
-
|
|
660
|
-
# Apply all conditions in a single where() call for better SQL generation
|
|
661
|
-
if conditions:
|
|
662
|
-
query = query.where(*conditions)
|
|
663
|
-
|
|
664
|
-
return query
|
|
474
|
+
return self._filter_engine.apply_filters(query, columns_map, filters)
|
|
665
475
|
|
|
666
476
|
def _apply_sort(
|
|
667
477
|
self,
|
|
@@ -672,6 +482,8 @@ class FSPManager:
|
|
|
672
482
|
"""
|
|
673
483
|
Apply sorting to a query.
|
|
674
484
|
|
|
485
|
+
Delegates to SortEngine.apply_sort().
|
|
486
|
+
|
|
675
487
|
Args:
|
|
676
488
|
query: Base SQLAlchemy Select query
|
|
677
489
|
columns_map: Map of column names to column elements
|
|
@@ -683,29 +495,7 @@ class FSPManager:
|
|
|
683
495
|
Raises:
|
|
684
496
|
HTTPException: If strict_mode is True and unknown sort field is encountered
|
|
685
497
|
"""
|
|
686
|
-
|
|
687
|
-
column = columns_map.get(sorting.sort_by)
|
|
688
|
-
|
|
689
|
-
# Fall back to computed fields (hybrid_property, etc.) if not in columns_map
|
|
690
|
-
if column is None:
|
|
691
|
-
column = FSPManager._get_entity_attribute(query, sorting.sort_by)
|
|
692
|
-
|
|
693
|
-
if column is None:
|
|
694
|
-
if self.strict_mode:
|
|
695
|
-
available = ", ".join(sorted(columns_map.keys()))
|
|
696
|
-
raise HTTPException(
|
|
697
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
698
|
-
detail=(
|
|
699
|
-
f"Unknown sort field '{sorting.sort_by}'. Available fields: {available}"
|
|
700
|
-
),
|
|
701
|
-
)
|
|
702
|
-
# Unknown sort column; skip sorting in non-strict mode
|
|
703
|
-
return query
|
|
704
|
-
|
|
705
|
-
query = query.order_by(
|
|
706
|
-
column.desc() if sorting.order == SortingOrder.DESC else column.asc()
|
|
707
|
-
)
|
|
708
|
-
return query
|
|
498
|
+
return self._sort_engine.apply_sort(query, columns_map, sorting)
|
|
709
499
|
|
|
710
500
|
def apply_config(self, config: FSPConfig) -> "FSPManager":
|
|
711
501
|
"""
|