fastapi-fsp 0.2.1__py3-none-any.whl → 0.2.2__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/fsp.py +131 -61
- {fastapi_fsp-0.2.1.dist-info → fastapi_fsp-0.2.2.dist-info}/METADATA +1 -1
- fastapi_fsp-0.2.2.dist-info/RECORD +7 -0
- fastapi_fsp-0.2.1.dist-info/RECORD +0 -7
- {fastapi_fsp-0.2.1.dist-info → fastapi_fsp-0.2.2.dist-info}/WHEEL +0 -0
- {fastapi_fsp-0.2.1.dist-info → fastapi_fsp-0.2.2.dist-info}/licenses/LICENSE +0 -0
fastapi_fsp/fsp.py
CHANGED
|
@@ -195,6 +195,7 @@ class FSPManager:
|
|
|
195
195
|
filters: Annotated[Optional[List[Filter]], Depends(_parse_filters)],
|
|
196
196
|
sorting: Annotated[Optional[SortingQuery], Depends(_parse_sort)],
|
|
197
197
|
pagination: Annotated[PaginationQuery, Depends(_parse_pagination)],
|
|
198
|
+
strict_mode: bool = False,
|
|
198
199
|
):
|
|
199
200
|
"""
|
|
200
201
|
Initialize FSPManager.
|
|
@@ -204,11 +205,32 @@ class FSPManager:
|
|
|
204
205
|
filters: Parsed filters
|
|
205
206
|
sorting: Sorting configuration
|
|
206
207
|
pagination: Pagination configuration
|
|
208
|
+
strict_mode: If True, raise errors for unknown fields instead of silently skipping
|
|
207
209
|
"""
|
|
208
210
|
self.request = request
|
|
209
211
|
self.filters = filters
|
|
210
212
|
self.sorting = sorting
|
|
211
213
|
self.pagination = pagination
|
|
214
|
+
self.strict_mode = strict_mode
|
|
215
|
+
self._type_cache: dict[int, Optional[type]] = {}
|
|
216
|
+
|
|
217
|
+
def _get_column_type(self, column: ColumnElement[Any]) -> Optional[type]:
|
|
218
|
+
"""
|
|
219
|
+
Get the Python type of a column with caching.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
column: SQLAlchemy column element
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Optional[type]: Python type of the column or None
|
|
226
|
+
"""
|
|
227
|
+
col_id = id(column)
|
|
228
|
+
if col_id not in self._type_cache:
|
|
229
|
+
try:
|
|
230
|
+
self._type_cache[col_id] = getattr(column.type, "python_type", None)
|
|
231
|
+
except Exception:
|
|
232
|
+
self._type_cache[col_id] = None
|
|
233
|
+
return self._type_cache[col_id]
|
|
212
234
|
|
|
213
235
|
def paginate(self, query: Select, session: Session) -> Any:
|
|
214
236
|
"""
|
|
@@ -257,8 +279,8 @@ class FSPManager:
|
|
|
257
279
|
PaginatedResponse: Complete paginated response
|
|
258
280
|
"""
|
|
259
281
|
columns_map = query.selected_columns
|
|
260
|
-
query =
|
|
261
|
-
query =
|
|
282
|
+
query = self._apply_filters(query, columns_map, self.filters)
|
|
283
|
+
query = self._apply_sort(query, columns_map, self.sorting)
|
|
262
284
|
|
|
263
285
|
total_items = self._count_total(query, session)
|
|
264
286
|
data_page = self.paginate(query, session)
|
|
@@ -278,8 +300,8 @@ class FSPManager:
|
|
|
278
300
|
PaginatedResponse: Complete paginated response
|
|
279
301
|
"""
|
|
280
302
|
columns_map = query.selected_columns
|
|
281
|
-
query =
|
|
282
|
-
query =
|
|
303
|
+
query = self._apply_filters(query, columns_map, self.filters)
|
|
304
|
+
query = self._apply_sort(query, columns_map, self.sorting)
|
|
283
305
|
|
|
284
306
|
total_items = await self._count_total_async(query, session)
|
|
285
307
|
data_page = await self.paginate_async(query, session)
|
|
@@ -338,22 +360,24 @@ class FSPManager:
|
|
|
338
360
|
)
|
|
339
361
|
|
|
340
362
|
@staticmethod
|
|
341
|
-
def _coerce_value(column: ColumnElement[Any], raw: str) -> Any:
|
|
363
|
+
def _coerce_value(column: ColumnElement[Any], raw: str, pytype: Optional[type] = None) -> Any:
|
|
342
364
|
"""
|
|
343
365
|
Coerce raw string value to column's Python type.
|
|
344
366
|
|
|
345
367
|
Args:
|
|
346
368
|
column: SQLAlchemy column element
|
|
347
369
|
raw: Raw string value
|
|
370
|
+
pytype: Optional pre-fetched python type (for performance)
|
|
348
371
|
|
|
349
372
|
Returns:
|
|
350
373
|
Any: Coerced value
|
|
351
374
|
"""
|
|
352
375
|
# Try to coerce raw (str) to the column's python type for proper comparisons
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
376
|
+
if pytype is None:
|
|
377
|
+
try:
|
|
378
|
+
pytype = getattr(column.type, "python_type", None)
|
|
379
|
+
except Exception:
|
|
380
|
+
pytype = None
|
|
357
381
|
if pytype is None or isinstance(raw, pytype):
|
|
358
382
|
return raw
|
|
359
383
|
# Handle booleans represented as strings
|
|
@@ -373,12 +397,17 @@ class FSPManager:
|
|
|
373
397
|
return int(float(raw))
|
|
374
398
|
except ValueError:
|
|
375
399
|
return raw
|
|
376
|
-
# Handle dates represented as strings
|
|
400
|
+
# Handle dates represented as strings - optimized with fast path for ISO 8601
|
|
377
401
|
if pytype is datetime:
|
|
378
402
|
try:
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
403
|
+
# Fast path for ISO 8601 format (most common)
|
|
404
|
+
return datetime.fromisoformat(raw)
|
|
405
|
+
except (ValueError, AttributeError):
|
|
406
|
+
# Fallback to flexible dateutil parser for other formats
|
|
407
|
+
try:
|
|
408
|
+
return parse(raw)
|
|
409
|
+
except ValueError:
|
|
410
|
+
return raw
|
|
382
411
|
# Generic cast with fallback
|
|
383
412
|
try:
|
|
384
413
|
return pytype(raw)
|
|
@@ -412,91 +441,96 @@ class FSPManager:
|
|
|
412
441
|
return hasattr(col, "ilike")
|
|
413
442
|
|
|
414
443
|
@staticmethod
|
|
415
|
-
def
|
|
444
|
+
def _build_filter_condition(
|
|
445
|
+
column: ColumnElement[Any], f: Filter, pytype: Optional[type] = None
|
|
446
|
+
) -> Optional[Any]:
|
|
416
447
|
"""
|
|
417
|
-
|
|
448
|
+
Build a filter condition for a query.
|
|
418
449
|
|
|
419
450
|
Args:
|
|
420
|
-
query: Base SQLAlchemy Select query
|
|
421
451
|
column: Column to apply filter to
|
|
422
452
|
f: Filter to apply
|
|
453
|
+
pytype: Optional pre-fetched python type (for performance)
|
|
423
454
|
|
|
424
455
|
Returns:
|
|
425
|
-
|
|
456
|
+
Optional[Any]: SQLAlchemy condition or None if invalid
|
|
426
457
|
"""
|
|
427
458
|
op = f.operator # type: FilterOperator
|
|
428
459
|
raw_value = f.value # type: str
|
|
429
460
|
|
|
430
461
|
# Build conditions based on operator
|
|
431
462
|
if op == FilterOperator.EQ:
|
|
432
|
-
|
|
463
|
+
return column == FSPManager._coerce_value(column, raw_value, pytype)
|
|
433
464
|
elif op == FilterOperator.NE:
|
|
434
|
-
|
|
465
|
+
return column != FSPManager._coerce_value(column, raw_value, pytype)
|
|
435
466
|
elif op == FilterOperator.GT:
|
|
436
|
-
|
|
467
|
+
return column > FSPManager._coerce_value(column, raw_value, pytype)
|
|
437
468
|
elif op == FilterOperator.GTE:
|
|
438
|
-
|
|
469
|
+
return column >= FSPManager._coerce_value(column, raw_value, pytype)
|
|
439
470
|
elif op == FilterOperator.LT:
|
|
440
|
-
|
|
471
|
+
return column < FSPManager._coerce_value(column, raw_value, pytype)
|
|
441
472
|
elif op == FilterOperator.LTE:
|
|
442
|
-
|
|
473
|
+
return column <= FSPManager._coerce_value(column, raw_value, pytype)
|
|
443
474
|
elif op == FilterOperator.LIKE:
|
|
444
|
-
|
|
475
|
+
return column.like(raw_value)
|
|
445
476
|
elif op == FilterOperator.NOT_LIKE:
|
|
446
|
-
|
|
477
|
+
return not_(column.like(raw_value))
|
|
447
478
|
elif op == FilterOperator.ILIKE:
|
|
448
479
|
pattern = raw_value
|
|
449
480
|
if FSPManager._ilike_supported(column):
|
|
450
|
-
|
|
481
|
+
return column.ilike(pattern)
|
|
451
482
|
else:
|
|
452
|
-
|
|
483
|
+
return func.lower(column).like(pattern.lower())
|
|
453
484
|
elif op == FilterOperator.NOT_ILIKE:
|
|
454
485
|
pattern = raw_value
|
|
455
486
|
if FSPManager._ilike_supported(column):
|
|
456
|
-
|
|
487
|
+
return not_(column.ilike(pattern))
|
|
457
488
|
else:
|
|
458
|
-
|
|
489
|
+
return not_(func.lower(column).like(pattern.lower()))
|
|
459
490
|
elif op == FilterOperator.IN:
|
|
460
491
|
vals = [
|
|
461
|
-
FSPManager._coerce_value(column, v
|
|
492
|
+
FSPManager._coerce_value(column, v, pytype)
|
|
493
|
+
for v in FSPManager._split_values(raw_value)
|
|
462
494
|
]
|
|
463
|
-
|
|
495
|
+
return column.in_(vals)
|
|
464
496
|
elif op == FilterOperator.NOT_IN:
|
|
465
497
|
vals = [
|
|
466
|
-
FSPManager._coerce_value(column, v
|
|
498
|
+
FSPManager._coerce_value(column, v, pytype)
|
|
499
|
+
for v in FSPManager._split_values(raw_value)
|
|
467
500
|
]
|
|
468
|
-
|
|
501
|
+
return not_(column.in_(vals))
|
|
469
502
|
elif op == FilterOperator.BETWEEN:
|
|
470
503
|
vals = FSPManager._split_values(raw_value)
|
|
471
504
|
if len(vals) == 2:
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
505
|
+
low = FSPManager._coerce_value(column, vals[0], pytype)
|
|
506
|
+
high = FSPManager._coerce_value(column, vals[1], pytype)
|
|
507
|
+
return column.between(low, high)
|
|
508
|
+
# Ignore malformed between
|
|
509
|
+
return None
|
|
476
510
|
elif op == FilterOperator.IS_NULL:
|
|
477
|
-
|
|
511
|
+
return column.is_(None)
|
|
478
512
|
elif op == FilterOperator.IS_NOT_NULL:
|
|
479
|
-
|
|
513
|
+
return column.is_not(None)
|
|
480
514
|
elif op == FilterOperator.STARTS_WITH:
|
|
481
515
|
pattern = f"{raw_value}%"
|
|
482
516
|
if FSPManager._ilike_supported(column):
|
|
483
|
-
|
|
517
|
+
return column.ilike(pattern)
|
|
484
518
|
else:
|
|
485
|
-
|
|
519
|
+
return func.lower(column).like(pattern.lower())
|
|
486
520
|
elif op == FilterOperator.ENDS_WITH:
|
|
487
521
|
pattern = f"%{raw_value}"
|
|
488
522
|
if FSPManager._ilike_supported(column):
|
|
489
|
-
|
|
523
|
+
return column.ilike(pattern)
|
|
490
524
|
else:
|
|
491
|
-
|
|
525
|
+
return func.lower(column).like(pattern.lower())
|
|
492
526
|
elif op == FilterOperator.CONTAINS:
|
|
493
527
|
pattern = f"%{raw_value}%"
|
|
494
528
|
if FSPManager._ilike_supported(column):
|
|
495
|
-
|
|
529
|
+
return column.ilike(pattern)
|
|
496
530
|
else:
|
|
497
|
-
|
|
498
|
-
# Unknown operator
|
|
499
|
-
return
|
|
531
|
+
return func.lower(column).like(pattern.lower())
|
|
532
|
+
# Unknown operator
|
|
533
|
+
return None
|
|
500
534
|
|
|
501
535
|
@staticmethod
|
|
502
536
|
def _count_total(query: Select, session: Session) -> int:
|
|
@@ -530,8 +564,8 @@ class FSPManager:
|
|
|
530
564
|
result = await session.exec(count_query)
|
|
531
565
|
return result.one()
|
|
532
566
|
|
|
533
|
-
@staticmethod
|
|
534
567
|
def _apply_filters(
|
|
568
|
+
self,
|
|
535
569
|
query: Select,
|
|
536
570
|
columns_map: ColumnCollection[str, ColumnElement[Any]],
|
|
537
571
|
filters: Optional[List[Filter]],
|
|
@@ -546,19 +580,41 @@ class FSPManager:
|
|
|
546
580
|
|
|
547
581
|
Returns:
|
|
548
582
|
Select: Query with filters applied
|
|
583
|
+
|
|
584
|
+
Raises:
|
|
585
|
+
HTTPException: If strict_mode is True and unknown field is encountered
|
|
549
586
|
"""
|
|
550
|
-
if filters:
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
587
|
+
if not filters:
|
|
588
|
+
return query
|
|
589
|
+
|
|
590
|
+
conditions = []
|
|
591
|
+
for f in filters:
|
|
592
|
+
# filter of `filters` has been validated in the `_parse_filters`
|
|
593
|
+
column = columns_map.get(f.field)
|
|
594
|
+
if column is None:
|
|
595
|
+
if self.strict_mode:
|
|
596
|
+
available = ", ".join(sorted(columns_map.keys()))
|
|
597
|
+
raise HTTPException(
|
|
598
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
599
|
+
detail=f"Unknown field '{f.field}'. Available fields: {available}",
|
|
600
|
+
)
|
|
601
|
+
# Skip unknown fields silently in non-strict mode
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
# Get column type from cache for better performance
|
|
605
|
+
pytype = self._get_column_type(column)
|
|
606
|
+
condition = FSPManager._build_filter_condition(column, f, pytype)
|
|
607
|
+
if condition is not None:
|
|
608
|
+
conditions.append(condition)
|
|
609
|
+
|
|
610
|
+
# Apply all conditions in a single where() call for better SQL generation
|
|
611
|
+
if conditions:
|
|
612
|
+
query = query.where(*conditions)
|
|
557
613
|
|
|
558
614
|
return query
|
|
559
615
|
|
|
560
|
-
@staticmethod
|
|
561
616
|
def _apply_sort(
|
|
617
|
+
self,
|
|
562
618
|
query: Select,
|
|
563
619
|
columns_map: ColumnCollection[str, ColumnElement[Any]],
|
|
564
620
|
sorting: Optional[SortingQuery],
|
|
@@ -573,6 +629,9 @@ class FSPManager:
|
|
|
573
629
|
|
|
574
630
|
Returns:
|
|
575
631
|
Select: Query with sorting applied
|
|
632
|
+
|
|
633
|
+
Raises:
|
|
634
|
+
HTTPException: If strict_mode is True and unknown sort field is encountered
|
|
576
635
|
"""
|
|
577
636
|
if sorting and sorting.sort_by:
|
|
578
637
|
column = columns_map.get(sorting.sort_by)
|
|
@@ -581,9 +640,20 @@ class FSPManager:
|
|
|
581
640
|
column = getattr(query.column_descriptions[0]["entity"], sorting.sort_by, None)
|
|
582
641
|
except Exception:
|
|
583
642
|
pass
|
|
584
|
-
|
|
585
|
-
if column is
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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=(
|
|
650
|
+
f"Unknown sort field '{sorting.sort_by}'. Available fields: {available}"
|
|
651
|
+
),
|
|
652
|
+
)
|
|
653
|
+
# Unknown sort column; skip sorting in non-strict mode
|
|
654
|
+
return query
|
|
655
|
+
|
|
656
|
+
query = query.order_by(
|
|
657
|
+
column.desc() if sorting.order == SortingOrder.DESC else column.asc()
|
|
658
|
+
)
|
|
589
659
|
return query
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fsp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
|
|
5
5
|
Project-URL: Homepage, https://github.com/fromej-dev/fastapi-fsp
|
|
6
6
|
Project-URL: Repository, https://github.com/fromej-dev/fastapi-fsp
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
fastapi_fsp/__init__.py,sha256=0I_XN_ptNu1NyNRpjdyVm6nwvrilAB8FyT2EfgVF_QA,215
|
|
2
|
+
fastapi_fsp/fsp.py,sha256=nPubvF7IStCWG0hdCeZX-1JgcHDyGkZa_XoAM7lq1Xw,22166
|
|
3
|
+
fastapi_fsp/models.py,sha256=1MwLBQFmUP8OwO3Gqby1u7s9ruimCR2XGfUzAqF4Tj4,2034
|
|
4
|
+
fastapi_fsp-0.2.2.dist-info/METADATA,sha256=zrje4Au8BOM4ids9JnX61jrP52ierjYuJxLGqvtCxFU,6235
|
|
5
|
+
fastapi_fsp-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
fastapi_fsp-0.2.2.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
7
|
+
fastapi_fsp-0.2.2.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
fastapi_fsp/__init__.py,sha256=0I_XN_ptNu1NyNRpjdyVm6nwvrilAB8FyT2EfgVF_QA,215
|
|
2
|
-
fastapi_fsp/fsp.py,sha256=p7sGNRkYbiRtFPta0OvfZ12th69BjtD6h2GzSjbPoaU,19646
|
|
3
|
-
fastapi_fsp/models.py,sha256=1MwLBQFmUP8OwO3Gqby1u7s9ruimCR2XGfUzAqF4Tj4,2034
|
|
4
|
-
fastapi_fsp-0.2.1.dist-info/METADATA,sha256=LcnA3C98yai-Cw54cqw1t8XfDgb8YqiPhZTc1h0V3qk,6235
|
|
5
|
-
fastapi_fsp-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
-
fastapi_fsp-0.2.1.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
7
|
-
fastapi_fsp-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|