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 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 = FSPManager._apply_filters(query, columns_map, self.filters)
261
- query = FSPManager._apply_sort(query, columns_map, self.sorting)
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 = FSPManager._apply_filters(query, columns_map, self.filters)
282
- query = FSPManager._apply_sort(query, columns_map, self.sorting)
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
- try:
354
- pytype = getattr(column.type, "python_type", None)
355
- except Exception:
356
- pytype = None
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
- return parse(raw)
380
- except ValueError:
381
- return raw
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 _apply_filter(query: Select, column: ColumnElement[Any], f: Filter):
444
+ def _build_filter_condition(
445
+ column: ColumnElement[Any], f: Filter, pytype: Optional[type] = None
446
+ ) -> Optional[Any]:
416
447
  """
417
- Apply a single filter to a query.
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
- Select: Query with filter applied
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
- query = query.where(column == FSPManager._coerce_value(column, raw_value))
463
+ return column == FSPManager._coerce_value(column, raw_value, pytype)
433
464
  elif op == FilterOperator.NE:
434
- query = query.where(column != FSPManager._coerce_value(column, raw_value))
465
+ return column != FSPManager._coerce_value(column, raw_value, pytype)
435
466
  elif op == FilterOperator.GT:
436
- query = query.where(column > FSPManager._coerce_value(column, raw_value))
467
+ return column > FSPManager._coerce_value(column, raw_value, pytype)
437
468
  elif op == FilterOperator.GTE:
438
- query = query.where(column >= FSPManager._coerce_value(column, raw_value))
469
+ return column >= FSPManager._coerce_value(column, raw_value, pytype)
439
470
  elif op == FilterOperator.LT:
440
- query = query.where(column < FSPManager._coerce_value(column, raw_value))
471
+ return column < FSPManager._coerce_value(column, raw_value, pytype)
441
472
  elif op == FilterOperator.LTE:
442
- query = query.where(column <= FSPManager._coerce_value(column, raw_value))
473
+ return column <= FSPManager._coerce_value(column, raw_value, pytype)
443
474
  elif op == FilterOperator.LIKE:
444
- query = query.where(column.like(raw_value))
475
+ return column.like(raw_value)
445
476
  elif op == FilterOperator.NOT_LIKE:
446
- query = query.where(not_(column.like(raw_value)))
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
- query = query.where(column.ilike(pattern))
481
+ return column.ilike(pattern)
451
482
  else:
452
- query = query.where(func.lower(column).like(pattern.lower()))
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
- query = query.where(not_(column.ilike(pattern)))
487
+ return not_(column.ilike(pattern))
457
488
  else:
458
- query = query.where(not_(func.lower(column).like(pattern.lower())))
489
+ return not_(func.lower(column).like(pattern.lower()))
459
490
  elif op == FilterOperator.IN:
460
491
  vals = [
461
- FSPManager._coerce_value(column, v) for v in FSPManager._split_values(raw_value)
492
+ FSPManager._coerce_value(column, v, pytype)
493
+ for v in FSPManager._split_values(raw_value)
462
494
  ]
463
- query = query.where(column.in_(vals))
495
+ return column.in_(vals)
464
496
  elif op == FilterOperator.NOT_IN:
465
497
  vals = [
466
- FSPManager._coerce_value(column, v) for v in FSPManager._split_values(raw_value)
498
+ FSPManager._coerce_value(column, v, pytype)
499
+ for v in FSPManager._split_values(raw_value)
467
500
  ]
468
- query = query.where(not_(column.in_(vals)))
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
- # Ignore malformed between; alternatively raise 400
473
- low = FSPManager._coerce_value(column, vals[0])
474
- high = FSPManager._coerce_value(column, vals[1])
475
- query = query.where(column.between(low, high))
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
- query = query.where(column.is_(None))
511
+ return column.is_(None)
478
512
  elif op == FilterOperator.IS_NOT_NULL:
479
- query = query.where(column.is_not(None))
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
- query = query.where(column.ilike(pattern))
517
+ return column.ilike(pattern)
484
518
  else:
485
- query = query.where(func.lower(column).like(pattern.lower()))
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
- query = query.where(column.ilike(pattern))
523
+ return column.ilike(pattern)
490
524
  else:
491
- query = query.where(func.lower(column).like(pattern.lower()))
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
- query = query.where(column.ilike(pattern))
529
+ return column.ilike(pattern)
496
530
  else:
497
- query = query.where(func.lower(column).like(pattern.lower()))
498
- # Unknown operator: skip
499
- return query
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
- for f in filters:
552
- # filter of `filters` has been validated in the `_parse_filters`
553
- column = columns_map.get(f.field)
554
- # Skip unknown fields silently
555
- if column is not None:
556
- query = FSPManager._apply_filter(query, column, f)
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
- # Unknown sort column; skip sorting
585
- if column is not None:
586
- query = query.order_by(
587
- column.desc() if sorting.order == SortingOrder.DESC else column.asc()
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.1
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,,