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/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, func
11
- from sqlmodel import Session, SQLModel, not_, select
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
- Handles parsing query parameters and applying them to SQLModel queries.
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
- self.strict_mode = strict_mode
216
- self._type_cache: dict[int, Optional[type]] = {}
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
- col_id = id(column)
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
- This enables filtering/sorting on computed fields like hybrid_property
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
- try:
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 session.exec(
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
- result = await session.exec(
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._count_total(query, session)
331
- data_page = self.paginate(query, session)
332
- return self._generate_response(total_items=total_items, data_page=data_page)
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._count_total_async(query, session)
352
- data_page = await self.paginate_async(query, session)
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
- self_url = str(url.include_query_params(page=current_page, per_page=per_page))
385
-
386
- return PaginatedResponse(
387
- data=data_page,
388
- meta=Meta(
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
- # Try to coerce raw (str) to the column's python type for proper comparisons
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 [item.strip() for item in raw.split(",")]
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 hasattr(col, "ilike")
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
- op = f.operator # type: FilterOperator
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
- # Count the total rows of the given query (with filters/sort applied) ignoring pagination
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
- count_query = select(func.count()).select_from(query.subquery())
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
- if not filters:
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
- if sorting and sorting.sort_by:
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
  """