fastapi-fsp 0.2.1__py3-none-any.whl → 0.2.3__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,76 @@ 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 (AttributeError, NotImplementedError):
232
+ # For computed fields (hybrid_property, etc.), type inference may fail
233
+ self._type_cache[col_id] = None
234
+ return self._type_cache[col_id]
235
+
236
+ @staticmethod
237
+ def _get_entity_attribute(query: Select, field: str) -> Optional[ColumnElement[Any]]:
238
+ """
239
+ Try to get a column-like attribute from the query's entity.
240
+
241
+ This enables filtering/sorting on computed fields like hybrid_property
242
+ that have SQL expressions defined.
243
+
244
+ Args:
245
+ query: SQLAlchemy Select query
246
+ field: Name of the field/attribute to get
247
+
248
+ Returns:
249
+ Optional[ColumnElement]: The SQL expression if available, None otherwise
250
+ """
251
+ try:
252
+ # Get the entity class from the query
253
+ column_descriptions = query.column_descriptions
254
+ if not column_descriptions:
255
+ return None
256
+
257
+ entity = column_descriptions[0].get("entity")
258
+ if entity is None:
259
+ return None
260
+
261
+ # Get the attribute from the entity class
262
+ attr = getattr(entity, field, None)
263
+ if attr is None:
264
+ return None
265
+
266
+ # Check if it's directly usable as a ColumnElement (hybrid_property with expression)
267
+ # When accessing a hybrid_property on the class, it returns the SQL expression
268
+ if isinstance(attr, ColumnElement):
269
+ return attr
270
+
271
+ # Some expressions may need to call __clause_element__
272
+ if hasattr(attr, "__clause_element__"):
273
+ return attr.__clause_element__()
274
+
275
+ return None
276
+ except Exception:
277
+ return None
212
278
 
213
279
  def paginate(self, query: Select, session: Session) -> Any:
214
280
  """
@@ -257,8 +323,8 @@ class FSPManager:
257
323
  PaginatedResponse: Complete paginated response
258
324
  """
259
325
  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)
326
+ query = self._apply_filters(query, columns_map, self.filters)
327
+ query = self._apply_sort(query, columns_map, self.sorting)
262
328
 
263
329
  total_items = self._count_total(query, session)
264
330
  data_page = self.paginate(query, session)
@@ -278,8 +344,8 @@ class FSPManager:
278
344
  PaginatedResponse: Complete paginated response
279
345
  """
280
346
  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)
347
+ query = self._apply_filters(query, columns_map, self.filters)
348
+ query = self._apply_sort(query, columns_map, self.sorting)
283
349
 
284
350
  total_items = await self._count_total_async(query, session)
285
351
  data_page = await self.paginate_async(query, session)
@@ -338,22 +404,24 @@ class FSPManager:
338
404
  )
339
405
 
340
406
  @staticmethod
341
- def _coerce_value(column: ColumnElement[Any], raw: str) -> Any:
407
+ def _coerce_value(column: ColumnElement[Any], raw: str, pytype: Optional[type] = None) -> Any:
342
408
  """
343
409
  Coerce raw string value to column's Python type.
344
410
 
345
411
  Args:
346
412
  column: SQLAlchemy column element
347
413
  raw: Raw string value
414
+ pytype: Optional pre-fetched python type (for performance)
348
415
 
349
416
  Returns:
350
417
  Any: Coerced value
351
418
  """
352
419
  # 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
420
+ if pytype is None:
421
+ try:
422
+ pytype = getattr(column.type, "python_type", None)
423
+ except Exception:
424
+ pytype = None
357
425
  if pytype is None or isinstance(raw, pytype):
358
426
  return raw
359
427
  # Handle booleans represented as strings
@@ -373,12 +441,17 @@ class FSPManager:
373
441
  return int(float(raw))
374
442
  except ValueError:
375
443
  return raw
376
- # Handle dates represented as strings
444
+ # Handle dates represented as strings - optimized with fast path for ISO 8601
377
445
  if pytype is datetime:
378
446
  try:
379
- return parse(raw)
380
- except ValueError:
381
- return raw
447
+ # Fast path for ISO 8601 format (most common)
448
+ return datetime.fromisoformat(raw)
449
+ except (ValueError, AttributeError):
450
+ # Fallback to flexible dateutil parser for other formats
451
+ try:
452
+ return parse(raw)
453
+ except ValueError:
454
+ return raw
382
455
  # Generic cast with fallback
383
456
  try:
384
457
  return pytype(raw)
@@ -412,91 +485,96 @@ class FSPManager:
412
485
  return hasattr(col, "ilike")
413
486
 
414
487
  @staticmethod
415
- def _apply_filter(query: Select, column: ColumnElement[Any], f: Filter):
488
+ def _build_filter_condition(
489
+ column: ColumnElement[Any], f: Filter, pytype: Optional[type] = None
490
+ ) -> Optional[Any]:
416
491
  """
417
- Apply a single filter to a query.
492
+ Build a filter condition for a query.
418
493
 
419
494
  Args:
420
- query: Base SQLAlchemy Select query
421
495
  column: Column to apply filter to
422
496
  f: Filter to apply
497
+ pytype: Optional pre-fetched python type (for performance)
423
498
 
424
499
  Returns:
425
- Select: Query with filter applied
500
+ Optional[Any]: SQLAlchemy condition or None if invalid
426
501
  """
427
502
  op = f.operator # type: FilterOperator
428
503
  raw_value = f.value # type: str
429
504
 
430
505
  # Build conditions based on operator
431
506
  if op == FilterOperator.EQ:
432
- query = query.where(column == FSPManager._coerce_value(column, raw_value))
507
+ return column == FSPManager._coerce_value(column, raw_value, pytype)
433
508
  elif op == FilterOperator.NE:
434
- query = query.where(column != FSPManager._coerce_value(column, raw_value))
509
+ return column != FSPManager._coerce_value(column, raw_value, pytype)
435
510
  elif op == FilterOperator.GT:
436
- query = query.where(column > FSPManager._coerce_value(column, raw_value))
511
+ return column > FSPManager._coerce_value(column, raw_value, pytype)
437
512
  elif op == FilterOperator.GTE:
438
- query = query.where(column >= FSPManager._coerce_value(column, raw_value))
513
+ return column >= FSPManager._coerce_value(column, raw_value, pytype)
439
514
  elif op == FilterOperator.LT:
440
- query = query.where(column < FSPManager._coerce_value(column, raw_value))
515
+ return column < FSPManager._coerce_value(column, raw_value, pytype)
441
516
  elif op == FilterOperator.LTE:
442
- query = query.where(column <= FSPManager._coerce_value(column, raw_value))
517
+ return column <= FSPManager._coerce_value(column, raw_value, pytype)
443
518
  elif op == FilterOperator.LIKE:
444
- query = query.where(column.like(raw_value))
519
+ return column.like(raw_value)
445
520
  elif op == FilterOperator.NOT_LIKE:
446
- query = query.where(not_(column.like(raw_value)))
521
+ return not_(column.like(raw_value))
447
522
  elif op == FilterOperator.ILIKE:
448
523
  pattern = raw_value
449
524
  if FSPManager._ilike_supported(column):
450
- query = query.where(column.ilike(pattern))
525
+ return column.ilike(pattern)
451
526
  else:
452
- query = query.where(func.lower(column).like(pattern.lower()))
527
+ return func.lower(column).like(pattern.lower())
453
528
  elif op == FilterOperator.NOT_ILIKE:
454
529
  pattern = raw_value
455
530
  if FSPManager._ilike_supported(column):
456
- query = query.where(not_(column.ilike(pattern)))
531
+ return not_(column.ilike(pattern))
457
532
  else:
458
- query = query.where(not_(func.lower(column).like(pattern.lower())))
533
+ return not_(func.lower(column).like(pattern.lower()))
459
534
  elif op == FilterOperator.IN:
460
535
  vals = [
461
- FSPManager._coerce_value(column, v) for v in FSPManager._split_values(raw_value)
536
+ FSPManager._coerce_value(column, v, pytype)
537
+ for v in FSPManager._split_values(raw_value)
462
538
  ]
463
- query = query.where(column.in_(vals))
539
+ return column.in_(vals)
464
540
  elif op == FilterOperator.NOT_IN:
465
541
  vals = [
466
- FSPManager._coerce_value(column, v) for v in FSPManager._split_values(raw_value)
542
+ FSPManager._coerce_value(column, v, pytype)
543
+ for v in FSPManager._split_values(raw_value)
467
544
  ]
468
- query = query.where(not_(column.in_(vals)))
545
+ return not_(column.in_(vals))
469
546
  elif op == FilterOperator.BETWEEN:
470
547
  vals = FSPManager._split_values(raw_value)
471
548
  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))
549
+ low = FSPManager._coerce_value(column, vals[0], pytype)
550
+ high = FSPManager._coerce_value(column, vals[1], pytype)
551
+ return column.between(low, high)
552
+ # Ignore malformed between
553
+ return None
476
554
  elif op == FilterOperator.IS_NULL:
477
- query = query.where(column.is_(None))
555
+ return column.is_(None)
478
556
  elif op == FilterOperator.IS_NOT_NULL:
479
- query = query.where(column.is_not(None))
557
+ return column.is_not(None)
480
558
  elif op == FilterOperator.STARTS_WITH:
481
559
  pattern = f"{raw_value}%"
482
560
  if FSPManager._ilike_supported(column):
483
- query = query.where(column.ilike(pattern))
561
+ return column.ilike(pattern)
484
562
  else:
485
- query = query.where(func.lower(column).like(pattern.lower()))
563
+ return func.lower(column).like(pattern.lower())
486
564
  elif op == FilterOperator.ENDS_WITH:
487
565
  pattern = f"%{raw_value}"
488
566
  if FSPManager._ilike_supported(column):
489
- query = query.where(column.ilike(pattern))
567
+ return column.ilike(pattern)
490
568
  else:
491
- query = query.where(func.lower(column).like(pattern.lower()))
569
+ return func.lower(column).like(pattern.lower())
492
570
  elif op == FilterOperator.CONTAINS:
493
571
  pattern = f"%{raw_value}%"
494
572
  if FSPManager._ilike_supported(column):
495
- query = query.where(column.ilike(pattern))
573
+ return column.ilike(pattern)
496
574
  else:
497
- query = query.where(func.lower(column).like(pattern.lower()))
498
- # Unknown operator: skip
499
- return query
575
+ return func.lower(column).like(pattern.lower())
576
+ # Unknown operator
577
+ return None
500
578
 
501
579
  @staticmethod
502
580
  def _count_total(query: Select, session: Session) -> int:
@@ -530,8 +608,8 @@ class FSPManager:
530
608
  result = await session.exec(count_query)
531
609
  return result.one()
532
610
 
533
- @staticmethod
534
611
  def _apply_filters(
612
+ self,
535
613
  query: Select,
536
614
  columns_map: ColumnCollection[str, ColumnElement[Any]],
537
615
  filters: Optional[List[Filter]],
@@ -546,19 +624,46 @@ class FSPManager:
546
624
 
547
625
  Returns:
548
626
  Select: Query with filters applied
627
+
628
+ Raises:
629
+ HTTPException: If strict_mode is True and unknown field is encountered
549
630
  """
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)
631
+ if not filters:
632
+ return query
633
+
634
+ conditions = []
635
+ for f in filters:
636
+ # filter of `filters` has been validated in the `_parse_filters`
637
+ column = columns_map.get(f.field)
638
+
639
+ # Fall back to computed fields (hybrid_property, etc.) if not in columns_map
640
+ if column is None:
641
+ column = FSPManager._get_entity_attribute(query, f.field)
642
+
643
+ if column is None:
644
+ if self.strict_mode:
645
+ available = ", ".join(sorted(columns_map.keys()))
646
+ raise HTTPException(
647
+ status_code=status.HTTP_400_BAD_REQUEST,
648
+ detail=f"Unknown field '{f.field}'. Available fields: {available}",
649
+ )
650
+ # Skip unknown fields silently in non-strict mode
651
+ continue
652
+
653
+ # Get column type from cache for better performance
654
+ pytype = self._get_column_type(column)
655
+ condition = FSPManager._build_filter_condition(column, f, pytype)
656
+ if condition is not None:
657
+ conditions.append(condition)
658
+
659
+ # Apply all conditions in a single where() call for better SQL generation
660
+ if conditions:
661
+ query = query.where(*conditions)
557
662
 
558
663
  return query
559
664
 
560
- @staticmethod
561
665
  def _apply_sort(
666
+ self,
562
667
  query: Select,
563
668
  columns_map: ColumnCollection[str, ColumnElement[Any]],
564
669
  sorting: Optional[SortingQuery],
@@ -573,17 +678,30 @@ class FSPManager:
573
678
 
574
679
  Returns:
575
680
  Select: Query with sorting applied
681
+
682
+ Raises:
683
+ HTTPException: If strict_mode is True and unknown sort field is encountered
576
684
  """
577
685
  if sorting and sorting.sort_by:
578
686
  column = columns_map.get(sorting.sort_by)
687
+
688
+ # Fall back to computed fields (hybrid_property, etc.) if not in columns_map
579
689
  if column is None:
580
- try:
581
- column = getattr(query.column_descriptions[0]["entity"], sorting.sort_by, None)
582
- except Exception:
583
- 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
- )
690
+ column = FSPManager._get_entity_attribute(query, sorting.sort_by)
691
+
692
+ if column is None:
693
+ if self.strict_mode:
694
+ available = ", ".join(sorted(columns_map.keys()))
695
+ raise HTTPException(
696
+ status_code=status.HTTP_400_BAD_REQUEST,
697
+ detail=(
698
+ f"Unknown sort field '{sorting.sort_by}'. Available fields: {available}"
699
+ ),
700
+ )
701
+ # Unknown sort column; skip sorting in non-strict mode
702
+ return query
703
+
704
+ query = query.order_by(
705
+ column.desc() if sorting.order == SortingOrder.DESC else column.asc()
706
+ )
589
707
  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.3
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
@@ -146,6 +146,65 @@ Notes:
146
146
  - Both formats are equivalent; the indexed format takes precedence if present.
147
147
  - If any filter is incomplete (missing operator or value in the indexed form, or mismatched counts of simple triplets), the API responds with HTTP 400.
148
148
 
149
+ ## Filtering on Computed Fields
150
+
151
+ You can filter (and sort) on SQLAlchemy `hybrid_property` fields that have a SQL expression defined. This enables filtering on calculated or derived values at the database level.
152
+
153
+ ### Defining a Computed Field
154
+
155
+ ```python
156
+ from typing import ClassVar, Optional
157
+ from sqlalchemy import func
158
+ from sqlalchemy.ext.hybrid import hybrid_property
159
+ from sqlmodel import Field, SQLModel
160
+
161
+ class HeroBase(SQLModel):
162
+ name: str = Field(index=True)
163
+ secret_name: str
164
+ age: Optional[int] = Field(default=None)
165
+ full_name: ClassVar[str] # Required: declare as ClassVar for Pydantic
166
+
167
+ @hybrid_property
168
+ def full_name(self) -> str:
169
+ """Python-level implementation (used on instances)."""
170
+ return f"{self.name}-{self.secret_name}"
171
+
172
+ @full_name.expression
173
+ def full_name(cls):
174
+ """SQL-level implementation (used in queries)."""
175
+ return func.concat(cls.name, "-", cls.secret_name)
176
+
177
+ class Hero(HeroBase, table=True):
178
+ id: Optional[int] = Field(default=None, primary_key=True)
179
+
180
+ class HeroPublic(HeroBase):
181
+ id: int
182
+ full_name: str # Include in response model
183
+ ```
184
+
185
+ ### Querying Computed Fields
186
+
187
+ Once defined, you can filter and sort on the computed field like any regular field:
188
+
189
+ ```
190
+ # Filter by computed field
191
+ GET /heroes/?field=full_name&operator=eq&value=Spider-Man
192
+ GET /heroes/?field=full_name&operator=ilike&value=%man
193
+ GET /heroes/?field=full_name&operator=contains&value=Spider
194
+
195
+ # Sort by computed field
196
+ GET /heroes/?sort_by=full_name&order=asc
197
+
198
+ # Combine with other filters
199
+ GET /heroes/?field=full_name&operator=starts_with&value=Spider&field=age&operator=gte&value=21
200
+ ```
201
+
202
+ ### Requirements
203
+
204
+ - The `hybrid_property` must have an `.expression` decorator that returns a valid SQL expression
205
+ - The field should be declared as `ClassVar[type]` in the SQLModel base class to work with Pydantic
206
+ - Only computed fields with SQL expressions are supported; Python-only properties cannot be filtered at the database level
207
+
149
208
  ## Response model
150
209
 
151
210
  ```
@@ -0,0 +1,7 @@
1
+ fastapi_fsp/__init__.py,sha256=0I_XN_ptNu1NyNRpjdyVm6nwvrilAB8FyT2EfgVF_QA,215
2
+ fastapi_fsp/fsp.py,sha256=b6OuHgbTpWrFtxGKm7fcPpjHtD3Cdjrs22m3IYOBhp4,23986
3
+ fastapi_fsp/models.py,sha256=1MwLBQFmUP8OwO3Gqby1u7s9ruimCR2XGfUzAqF4Tj4,2034
4
+ fastapi_fsp-0.2.3.dist-info/METADATA,sha256=w4wfXs3hye-UI0-h2a81OjkG3yzzZWUq91n1HhmyUi4,8224
5
+ fastapi_fsp-0.2.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ fastapi_fsp-0.2.3.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
7
+ fastapi_fsp-0.2.3.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,,