fastapi-fsp 0.1.3__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
@@ -1,9 +1,13 @@
1
- import math
1
+ """FastAPI-SQLModel-Pagination module"""
2
+
3
+ from datetime import datetime
4
+ from math import ceil
2
5
  from typing import Annotated, Any, List, Optional
3
6
 
7
+ from dateutil.parser import parse
4
8
  from fastapi import Depends, HTTPException, Query, Request, status
5
9
  from pydantic import ValidationError
6
- from sqlalchemy import Select, func
10
+ from sqlalchemy import ColumnCollection, ColumnElement, Select, func
7
11
  from sqlmodel import Session, not_, select
8
12
  from sqlmodel.ext.asyncio.session import AsyncSession
9
13
 
@@ -21,6 +25,21 @@ from fastapi_fsp.models import (
21
25
 
22
26
 
23
27
  def _parse_one_filter_at(i: int, field: str, operator: str, value: str) -> Filter:
28
+ """
29
+ Parse a single filter with comprehensive validation.
30
+
31
+ Args:
32
+ i: Index of the filter
33
+ field: Field name to filter on
34
+ operator: Filter operator
35
+ value: Filter value
36
+
37
+ Returns:
38
+ Filter: Parsed filter object
39
+
40
+ Raises:
41
+ HTTPException: If filter parameters are invalid
42
+ """
24
43
  try:
25
44
  filter_ = Filter(field=field, operator=FilterOperator(operator), value=value)
26
45
  except ValidationError as e:
@@ -39,6 +58,20 @@ def _parse_one_filter_at(i: int, field: str, operator: str, value: str) -> Filte
39
58
  def _parse_array_of_filters(
40
59
  fields: List[str], operators: List[str], values: List[str]
41
60
  ) -> List[Filter]:
61
+ """
62
+ Parse filters from array format parameters.
63
+
64
+ Args:
65
+ fields: List of field names
66
+ operators: List of operators
67
+ values: List of values
68
+
69
+ Returns:
70
+ List[Filter]: List of parsed filters
71
+
72
+ Raises:
73
+ HTTPException: If parameters are mismatched or invalid
74
+ """
42
75
  # Validate that we have matching lengths
43
76
  if not (len(fields) == len(operators) == len(values)):
44
77
  raise HTTPException(
@@ -57,9 +90,16 @@ def _parse_filters(
57
90
  """
58
91
  Parse filters from query parameters supporting two formats:
59
92
  1. Indexed format:
60
- ?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
93
+ ?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18
94
+ &filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
61
95
  2. Simple format:
62
96
  ?field=age&operator=gte&value=18&field=name&operator=ilike&value=joy
97
+
98
+ Args:
99
+ request: FastAPI Request object containing query parameters
100
+
101
+ Returns:
102
+ Optional[List[Filter]]: List of parsed filters or None if no filters
63
103
  """
64
104
  query_params = request.query_params
65
105
  filters = []
@@ -109,7 +149,17 @@ def _parse_filters(
109
149
  def _parse_sort(
110
150
  sort_by: Optional[str] = Query(None, alias="sort_by"),
111
151
  order: Optional[SortingOrder] = Query(SortingOrder.ASC, alias="order"),
112
- ):
152
+ ) -> Optional[SortingQuery]:
153
+ """
154
+ Parse sorting parameters from query parameters.
155
+
156
+ Args:
157
+ sort_by: Field to sort by
158
+ order: Sorting order (ASC or DESC)
159
+
160
+ Returns:
161
+ Optional[SortingQuery]: Parsed sorting query or None if no sorting
162
+ """
113
163
  if not sort_by:
114
164
  return None
115
165
  return SortingQuery(sort_by=sort_by, order=order)
@@ -119,23 +169,80 @@ def _parse_pagination(
119
169
  page: Optional[int] = Query(1, ge=1, description="Page number"),
120
170
  per_page: Optional[int] = Query(10, ge=1, le=100, description="Items per page"),
121
171
  ) -> PaginationQuery:
172
+ """
173
+ Parse pagination parameters from query parameters.
174
+
175
+ Args:
176
+ page: Page number (>= 1)
177
+ per_page: Number of items per page (1-100)
178
+
179
+ Returns:
180
+ PaginationQuery: Parsed pagination query
181
+ """
122
182
  return PaginationQuery(page=page, per_page=per_page)
123
183
 
124
184
 
125
185
  class FSPManager:
186
+ """
187
+ FastAPI Filtering, Sorting, and Pagination Manager.
188
+
189
+ Handles parsing query parameters and applying them to SQLModel queries.
190
+ """
191
+
126
192
  def __init__(
127
193
  self,
128
194
  request: Request,
129
- filters: Annotated[List[Filter], Depends(_parse_filters)],
130
- sorting: Annotated[SortingQuery, Depends(_parse_sort)],
195
+ filters: Annotated[Optional[List[Filter]], Depends(_parse_filters)],
196
+ sorting: Annotated[Optional[SortingQuery], Depends(_parse_sort)],
131
197
  pagination: Annotated[PaginationQuery, Depends(_parse_pagination)],
198
+ strict_mode: bool = False,
132
199
  ):
200
+ """
201
+ Initialize FSPManager.
202
+
203
+ Args:
204
+ request: FastAPI Request object
205
+ filters: Parsed filters
206
+ sorting: Sorting configuration
207
+ pagination: Pagination configuration
208
+ strict_mode: If True, raise errors for unknown fields instead of silently skipping
209
+ """
133
210
  self.request = request
134
211
  self.filters = filters
135
212
  self.sorting = sorting
136
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]
137
234
 
138
235
  def paginate(self, query: Select, session: Session) -> Any:
236
+ """
237
+ Execute pagination on a query.
238
+
239
+ Args:
240
+ query: SQLAlchemy Select query
241
+ session: Database session
242
+
243
+ Returns:
244
+ Any: Query results
245
+ """
139
246
  return session.exec(
140
247
  query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
141
248
  self.pagination.per_page
@@ -143,6 +250,16 @@ class FSPManager:
143
250
  ).all()
144
251
 
145
252
  async def paginate_async(self, query: Select, session: AsyncSession) -> Any:
253
+ """
254
+ Execute pagination on a query asynchronously.
255
+
256
+ Args:
257
+ query: SQLAlchemy Select query
258
+ session: Async database session
259
+
260
+ Returns:
261
+ Any: Query results
262
+ """
146
263
  result = await session.exec(
147
264
  query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
148
265
  self.pagination.per_page
@@ -150,177 +267,60 @@ class FSPManager:
150
267
  )
151
268
  return result.all()
152
269
 
153
- def _count_total(self, query: Select, session: Session) -> int:
154
- # Count the total rows of the given query (with filters/sort applied) ignoring pagination
155
- count_query = select(func.count()).select_from(query.subquery())
156
- return session.exec(count_query).one()
157
-
158
- async def _count_total_async(self, query: Select, session: AsyncSession) -> int:
159
- count_query = select(func.count()).select_from(query.subquery())
160
- result = await session.exec(count_query)
161
- return result.one()
162
-
163
- def _apply_filters(self, query: Select) -> Select:
164
- # Helper: build a map of column name -> column object from the select statement
165
- try:
166
- columns_map = {
167
- col.key: col for col in query.selected_columns
168
- } # SQLAlchemy 1.4+ ColumnCollection is iterable
169
- except Exception:
170
- columns_map = {}
171
-
172
- if not self.filters:
173
- return query
270
+ def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
271
+ """
272
+ Generate a complete paginated response.
174
273
 
175
- def coerce_value(column, raw):
176
- # Try to coerce raw (str or other) to the column's python type for proper comparisons
177
- try:
178
- pytype = getattr(column.type, "python_type", None)
179
- except Exception:
180
- pytype = None
181
- if pytype is None or raw is None:
182
- return raw
183
- if isinstance(raw, pytype):
184
- return raw
185
- # Handle booleans represented as strings
186
- if pytype is bool and isinstance(raw, str):
187
- val = raw.strip().lower()
188
- if val in {"true", "1", "t", "yes", "y"}:
189
- return True
190
- if val in {"false", "0", "f", "no", "n"}:
191
- return False
192
- # Generic cast with fallback
193
- try:
194
- return pytype(raw)
195
- except Exception:
196
- return raw
197
-
198
- def split_values(raw):
199
- if raw is None:
200
- return []
201
- if isinstance(raw, (list, tuple)):
202
- return list(raw)
203
- if isinstance(raw, str):
204
- return [item.strip() for item in raw.split(",")]
205
- return [raw]
206
-
207
- def ilike_supported(col):
208
- return hasattr(col, "ilike")
209
-
210
- for f in self.filters:
211
- if not f or not f.field:
212
- continue
274
+ Args:
275
+ query: Base SQLAlchemy Select query
276
+ session: Database session
213
277
 
214
- column = columns_map.get(f.field)
215
- if column is None:
216
- # Skip unknown fields silently
217
- continue
278
+ Returns:
279
+ PaginatedResponse: Complete paginated response
280
+ """
281
+ columns_map = query.selected_columns
282
+ query = self._apply_filters(query, columns_map, self.filters)
283
+ query = self._apply_sort(query, columns_map, self.sorting)
218
284
 
219
- op = str(f.operator).lower() if f.operator is not None else "eq"
220
- raw_value = f.value
221
-
222
- # Build conditions based on operator
223
- if op == "eq":
224
- query = query.where(column == coerce_value(column, raw_value))
225
- elif op == "ne":
226
- query = query.where(column != coerce_value(column, raw_value))
227
- elif op == "gt":
228
- query = query.where(column > coerce_value(column, raw_value))
229
- elif op == "gte":
230
- query = query.where(column >= coerce_value(column, raw_value))
231
- elif op == "lt":
232
- query = query.where(column < coerce_value(column, raw_value))
233
- elif op == "lte":
234
- query = query.where(column <= coerce_value(column, raw_value))
235
- elif op == "like":
236
- query = query.where(column.like(str(raw_value)))
237
- elif op == "not_like":
238
- query = query.where(not_(column.like(str(raw_value))))
239
- elif op == "ilike":
240
- pattern = str(raw_value)
241
- if ilike_supported(column):
242
- query = query.where(column.ilike(pattern))
243
- else:
244
- query = query.where(func.lower(column).like(pattern.lower()))
245
- elif op == "not_ilike":
246
- pattern = str(raw_value)
247
- if ilike_supported(column):
248
- query = query.where(not_(column.ilike(pattern)))
249
- else:
250
- query = query.where(not_(func.lower(column).like(pattern.lower())))
251
- elif op == "in":
252
- vals = [coerce_value(column, v) for v in split_values(raw_value)]
253
- query = query.where(column.in_(vals))
254
- elif op == "not_in":
255
- vals = [coerce_value(column, v) for v in split_values(raw_value)]
256
- query = query.where(not_(column.in_(vals)))
257
- elif op == "between":
258
- vals = split_values(raw_value)
259
- if len(vals) != 2:
260
- # Ignore malformed between; alternatively raise 400
261
- continue
262
- low = coerce_value(column, vals[0])
263
- high = coerce_value(column, vals[1])
264
- query = query.where(column.between(low, high))
265
- elif op == "is_null":
266
- query = query.where(column.is_(None))
267
- elif op == "is_not_null":
268
- query = query.where(column.is_not(None))
269
- elif op == "starts_with":
270
- pattern = f"{str(raw_value)}%"
271
- if ilike_supported(column):
272
- query = query.where(column.ilike(pattern))
273
- else:
274
- query = query.where(func.lower(column).like(pattern.lower()))
275
- elif op == "ends_with":
276
- pattern = f"%{str(raw_value)}"
277
- if ilike_supported(column):
278
- query = query.where(column.ilike(pattern))
279
- else:
280
- query = query.where(func.lower(column).like(pattern.lower()))
281
- elif op == "contains":
282
- pattern = f"%{str(raw_value)}%"
283
- if ilike_supported(column):
284
- query = query.where(column.ilike(pattern))
285
- else:
286
- query = query.where(func.lower(column).like(pattern.lower()))
287
- else:
288
- # Unknown operator: skip
289
- continue
285
+ total_items = self._count_total(query, session)
286
+ data_page = self.paginate(query, session)
287
+ return self._generate_response(total_items=total_items, data_page=data_page)
290
288
 
291
- return query
289
+ async def generate_response_async(
290
+ self, query: Select, session: AsyncSession
291
+ ) -> PaginatedResponse[Any]:
292
+ """
293
+ Generate a complete paginated response asynchronously.
292
294
 
293
- def _apply_sort(self, query: Select) -> Select:
294
- # Build a map of column name -> column object from the select statement
295
- try:
296
- columns_map = {col.key: col for col in query.selected_columns}
297
- except Exception:
298
- columns_map = {}
295
+ Args:
296
+ query: Base SQLAlchemy Select query
297
+ session: Async database session
299
298
 
300
- if not self.sorting or not self.sorting.sort_by:
301
- return query
299
+ Returns:
300
+ PaginatedResponse: Complete paginated response
301
+ """
302
+ columns_map = query.selected_columns
303
+ query = self._apply_filters(query, columns_map, self.filters)
304
+ query = self._apply_sort(query, columns_map, self.sorting)
302
305
 
303
- column = columns_map.get(self.sorting.sort_by)
304
- if column is None:
305
- # Unknown sort column; skip sorting
306
- return query
306
+ total_items = await self._count_total_async(query, session)
307
+ data_page = await self.paginate_async(query, session)
308
+ return self._generate_response(total_items=total_items, data_page=data_page)
307
309
 
308
- order = str(self.sorting.order).lower() if self.sorting.order else "asc"
309
- if order == "desc":
310
- return query.order_by(column.desc())
311
- else:
312
- return query.order_by(column.asc())
310
+ def _generate_response(self, total_items: int, data_page: Any) -> PaginatedResponse[Any]:
311
+ """
312
+ Generate the final paginated response object.
313
313
 
314
- def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
315
- query = self._apply_filters(query)
316
- query = self._apply_sort(query)
314
+ Args:
315
+ total_items: Total number of items matching filters
316
+ data_page: Current page of data
317
317
 
318
- total_items = self._count_total(query, session)
318
+ Returns:
319
+ PaginatedResponse: Final response object
320
+ """
319
321
  per_page = self.pagination.per_page
320
322
  current_page = self.pagination.page
321
- total_pages = max(1, math.ceil(total_items / per_page)) if total_items is not None else 1
322
-
323
- data_page = self.paginate(query, session)
323
+ total_pages = max(1, ceil(total_items / per_page)) if total_items is not None else 1
324
324
 
325
325
  # Build links based on current URL, replacing/adding page and per_page parameters
326
326
  url = self.request.url
@@ -359,52 +359,301 @@ class FSPManager:
359
359
  ),
360
360
  )
361
361
 
362
- async def generate_response_async(
363
- self, query: Select, session: AsyncSession
364
- ) -> PaginatedResponse[Any]:
365
- query = self._apply_filters(query)
366
- query = self._apply_sort(query)
362
+ @staticmethod
363
+ def _coerce_value(column: ColumnElement[Any], raw: str, pytype: Optional[type] = None) -> Any:
364
+ """
365
+ Coerce raw string value to column's Python type.
366
+
367
+ Args:
368
+ column: SQLAlchemy column element
369
+ raw: Raw string value
370
+ pytype: Optional pre-fetched python type (for performance)
371
+
372
+ Returns:
373
+ Any: Coerced value
374
+ """
375
+ # Try to coerce raw (str) to the column's python type for proper comparisons
376
+ if pytype is None:
377
+ try:
378
+ pytype = getattr(column.type, "python_type", None)
379
+ except Exception:
380
+ pytype = None
381
+ if pytype is None or isinstance(raw, pytype):
382
+ return raw
383
+ # Handle booleans represented as strings
384
+ if pytype is bool:
385
+ val = raw.strip().lower()
386
+ if val in {"true", "1", "t", "yes", "y"}:
387
+ return True
388
+ if val in {"false", "0", "f", "no", "n"}:
389
+ return False
390
+ # Handle integers represented as strings
391
+ if pytype is int:
392
+ try:
393
+ return int(raw)
394
+ except ValueError:
395
+ # Handle common cases like "1.0"
396
+ try:
397
+ return int(float(raw))
398
+ except ValueError:
399
+ return raw
400
+ # Handle dates represented as strings - optimized with fast path for ISO 8601
401
+ if pytype is datetime:
402
+ try:
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
411
+ # Generic cast with fallback
412
+ try:
413
+ return pytype(raw)
414
+ except Exception:
415
+ return raw
416
+
417
+ @staticmethod
418
+ def _split_values(raw: str) -> List[str]:
419
+ """
420
+ Split comma-separated values.
421
+
422
+ Args:
423
+ raw: Raw string of comma-separated values
424
+
425
+ Returns:
426
+ List[str]: List of stripped values
427
+ """
428
+ return [item.strip() for item in raw.split(",")]
429
+
430
+ @staticmethod
431
+ def _ilike_supported(col: ColumnElement[Any]) -> bool:
432
+ """
433
+ Check if ILIKE is supported for this column.
434
+
435
+ Args:
436
+ col: SQLAlchemy column element
437
+
438
+ Returns:
439
+ bool: True if ILIKE is supported
440
+ """
441
+ return hasattr(col, "ilike")
442
+
443
+ @staticmethod
444
+ def _build_filter_condition(
445
+ column: ColumnElement[Any], f: Filter, pytype: Optional[type] = None
446
+ ) -> Optional[Any]:
447
+ """
448
+ Build a filter condition for a query.
449
+
450
+ Args:
451
+ column: Column to apply filter to
452
+ f: Filter to apply
453
+ pytype: Optional pre-fetched python type (for performance)
454
+
455
+ Returns:
456
+ Optional[Any]: SQLAlchemy condition or None if invalid
457
+ """
458
+ op = f.operator # type: FilterOperator
459
+ raw_value = f.value # type: str
460
+
461
+ # Build conditions based on operator
462
+ if op == FilterOperator.EQ:
463
+ return column == FSPManager._coerce_value(column, raw_value, pytype)
464
+ elif op == FilterOperator.NE:
465
+ return column != FSPManager._coerce_value(column, raw_value, pytype)
466
+ elif op == FilterOperator.GT:
467
+ return column > FSPManager._coerce_value(column, raw_value, pytype)
468
+ elif op == FilterOperator.GTE:
469
+ return column >= FSPManager._coerce_value(column, raw_value, pytype)
470
+ elif op == FilterOperator.LT:
471
+ return column < FSPManager._coerce_value(column, raw_value, pytype)
472
+ elif op == FilterOperator.LTE:
473
+ return column <= FSPManager._coerce_value(column, raw_value, pytype)
474
+ elif op == FilterOperator.LIKE:
475
+ return column.like(raw_value)
476
+ elif op == FilterOperator.NOT_LIKE:
477
+ return not_(column.like(raw_value))
478
+ elif op == FilterOperator.ILIKE:
479
+ pattern = raw_value
480
+ if FSPManager._ilike_supported(column):
481
+ return column.ilike(pattern)
482
+ else:
483
+ return func.lower(column).like(pattern.lower())
484
+ elif op == FilterOperator.NOT_ILIKE:
485
+ pattern = raw_value
486
+ if FSPManager._ilike_supported(column):
487
+ return not_(column.ilike(pattern))
488
+ else:
489
+ return not_(func.lower(column).like(pattern.lower()))
490
+ elif op == FilterOperator.IN:
491
+ vals = [
492
+ FSPManager._coerce_value(column, v, pytype)
493
+ for v in FSPManager._split_values(raw_value)
494
+ ]
495
+ return column.in_(vals)
496
+ elif op == FilterOperator.NOT_IN:
497
+ vals = [
498
+ FSPManager._coerce_value(column, v, pytype)
499
+ for v in FSPManager._split_values(raw_value)
500
+ ]
501
+ return not_(column.in_(vals))
502
+ elif op == FilterOperator.BETWEEN:
503
+ vals = FSPManager._split_values(raw_value)
504
+ if len(vals) == 2:
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
510
+ elif op == FilterOperator.IS_NULL:
511
+ return column.is_(None)
512
+ elif op == FilterOperator.IS_NOT_NULL:
513
+ return column.is_not(None)
514
+ elif op == FilterOperator.STARTS_WITH:
515
+ pattern = f"{raw_value}%"
516
+ if FSPManager._ilike_supported(column):
517
+ return column.ilike(pattern)
518
+ else:
519
+ return func.lower(column).like(pattern.lower())
520
+ elif op == FilterOperator.ENDS_WITH:
521
+ pattern = f"%{raw_value}"
522
+ if FSPManager._ilike_supported(column):
523
+ return column.ilike(pattern)
524
+ else:
525
+ return func.lower(column).like(pattern.lower())
526
+ elif op == FilterOperator.CONTAINS:
527
+ pattern = f"%{raw_value}%"
528
+ if FSPManager._ilike_supported(column):
529
+ return column.ilike(pattern)
530
+ else:
531
+ return func.lower(column).like(pattern.lower())
532
+ # Unknown operator
533
+ return None
367
534
 
368
- total_items = await self._count_total_async(query, session)
369
- per_page = self.pagination.per_page
370
- current_page = self.pagination.page
371
- total_pages = max(1, math.ceil(total_items / per_page)) if total_items is not None else 1
535
+ @staticmethod
536
+ def _count_total(query: Select, session: Session) -> int:
537
+ """
538
+ Count total items matching the query.
372
539
 
373
- data_page = await self.paginate_async(query, session)
540
+ Args:
541
+ query: SQLAlchemy Select query with filters applied
542
+ session: Database session
374
543
 
375
- # Build links based on current URL, replacing/adding page and per_page parameters
376
- url = self.request.url
377
- first_url = str(url.include_query_params(page=1, per_page=per_page))
378
- last_url = str(url.include_query_params(page=total_pages, per_page=per_page))
379
- next_url = (
380
- str(url.include_query_params(page=current_page + 1, per_page=per_page))
381
- if current_page < total_pages
382
- else None
383
- )
384
- prev_url = (
385
- str(url.include_query_params(page=current_page - 1, per_page=per_page))
386
- if current_page > 1
387
- else None
388
- )
389
- self_url = str(url.include_query_params(page=current_page, per_page=per_page))
544
+ Returns:
545
+ int: Total count of items
546
+ """
547
+ # Count the total rows of the given query (with filters/sort applied) ignoring pagination
548
+ count_query = select(func.count()).select_from(query.subquery())
549
+ return session.exec(count_query).one()
390
550
 
391
- return PaginatedResponse(
392
- data=data_page,
393
- meta=Meta(
394
- pagination=Pagination(
395
- total_items=total_items,
396
- per_page=per_page,
397
- current_page=current_page,
398
- total_pages=total_pages,
399
- ),
400
- filters=self.filters,
401
- sort=self.sorting,
402
- ),
403
- links=Links(
404
- self=self_url,
405
- first=first_url,
406
- last=last_url,
407
- next=next_url,
408
- prev=prev_url,
409
- ),
410
- )
551
+ @staticmethod
552
+ async def _count_total_async(query: Select, session: AsyncSession) -> int:
553
+ """
554
+ Count total items matching the query asynchronously.
555
+
556
+ Args:
557
+ query: SQLAlchemy Select query with filters applied
558
+ session: Async database session
559
+
560
+ Returns:
561
+ int: Total count of items
562
+ """
563
+ count_query = select(func.count()).select_from(query.subquery())
564
+ result = await session.exec(count_query)
565
+ return result.one()
566
+
567
+ def _apply_filters(
568
+ self,
569
+ query: Select,
570
+ columns_map: ColumnCollection[str, ColumnElement[Any]],
571
+ filters: Optional[List[Filter]],
572
+ ) -> Select:
573
+ """
574
+ Apply filters to a query.
575
+
576
+ Args:
577
+ query: Base SQLAlchemy Select query
578
+ columns_map: Map of column names to column elements
579
+ filters: List of filters to apply
580
+
581
+ Returns:
582
+ Select: Query with filters applied
583
+
584
+ Raises:
585
+ HTTPException: If strict_mode is True and unknown field is encountered
586
+ """
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)
613
+
614
+ return query
615
+
616
+ def _apply_sort(
617
+ self,
618
+ query: Select,
619
+ columns_map: ColumnCollection[str, ColumnElement[Any]],
620
+ sorting: Optional[SortingQuery],
621
+ ) -> Select:
622
+ """
623
+ Apply sorting to a query.
624
+
625
+ Args:
626
+ query: Base SQLAlchemy Select query
627
+ columns_map: Map of column names to column elements
628
+ sorting: Sorting configuration
629
+
630
+ Returns:
631
+ Select: Query with sorting applied
632
+
633
+ Raises:
634
+ HTTPException: If strict_mode is True and unknown sort field is encountered
635
+ """
636
+ if sorting and sorting.sort_by:
637
+ column = columns_map.get(sorting.sort_by)
638
+ if column is None:
639
+ try:
640
+ column = getattr(query.column_descriptions[0]["entity"], sorting.sort_by, None)
641
+ except Exception:
642
+ pass
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
+ )
659
+ return query
fastapi_fsp/models.py CHANGED
@@ -1,3 +1,5 @@
1
+ """FastAPI-SQLModel-Pagination models"""
2
+
1
3
  from enum import StrEnum
2
4
  from typing import Generic, List, Optional, TypeVar
3
5
 
@@ -5,6 +7,8 @@ from pydantic import BaseModel
5
7
 
6
8
 
7
9
  class FilterOperator(StrEnum):
10
+ """Filter operators"""
11
+
8
12
  EQ = "eq" # equals (=)
9
13
  NE = "ne" # not equals (!=)
10
14
  GT = "gt" # greater than (>)
@@ -31,6 +35,8 @@ class FilterOperator(StrEnum):
31
35
 
32
36
 
33
37
  class SortingOrder(StrEnum):
38
+ """Sorting orders"""
39
+
34
40
  ASC = "asc" # ascending order
35
41
  DESC = "desc" # descending order
36
42
 
@@ -39,22 +45,30 @@ T = TypeVar("T")
39
45
 
40
46
 
41
47
  class Filter(BaseModel):
48
+ """Filter model"""
49
+
42
50
  field: str
43
51
  operator: FilterOperator
44
52
  value: str
45
53
 
46
54
 
47
55
  class PaginationQuery(BaseModel):
56
+ """Pagination query model"""
57
+
48
58
  page: int
49
59
  per_page: int
50
60
 
51
61
 
52
62
  class SortingQuery(BaseModel):
63
+ """Sorting query model"""
64
+
53
65
  sort_by: str
54
66
  order: SortingOrder
55
67
 
56
68
 
57
69
  class Pagination(BaseModel):
70
+ """Pagination model"""
71
+
58
72
  total_items: Optional[int] = None
59
73
  per_page: int
60
74
  current_page: int
@@ -62,12 +76,16 @@ class Pagination(BaseModel):
62
76
 
63
77
 
64
78
  class Meta(BaseModel):
79
+ """Meta model"""
80
+
65
81
  pagination: Pagination
66
82
  filters: Optional[List[Filter]] = None
67
83
  sort: Optional[SortingQuery] = None
68
84
 
69
85
 
70
86
  class Links(BaseModel):
87
+ """Links model"""
88
+
71
89
  self: str
72
90
  first: str
73
91
  next: Optional[str] = None
@@ -76,6 +94,8 @@ class Links(BaseModel):
76
94
 
77
95
 
78
96
  class PaginatedResponse(BaseModel, Generic[T]):
97
+ """Paginated response model"""
98
+
79
99
  data: List[T]
80
100
  meta: Meta
81
101
  links: Links
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-fsp
3
- Version: 0.1.3
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
@@ -22,6 +22,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: fastapi>=0.121.1
25
+ Requires-Dist: python-dateutil>=2.9.0.post0
25
26
  Requires-Dist: sqlmodel>=0.0.27
26
27
  Description-Content-Type: text/markdown
27
28
 
@@ -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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,7 +0,0 @@
1
- fastapi_fsp/__init__.py,sha256=0I_XN_ptNu1NyNRpjdyVm6nwvrilAB8FyT2EfgVF_QA,215
2
- fastapi_fsp/fsp.py,sha256=vCmaKbUw-UeLLklUcVNbbEicsxlEnSGO9Sd9jLq3I3Y,15174
3
- fastapi_fsp/models.py,sha256=_P72r_kgVh_ZvoKQqU359vnbJTxeWnvnT6yV5_M558Q,1740
4
- fastapi_fsp-0.1.3.dist-info/METADATA,sha256=_VtkWuJkOTdK-ZjUw070ogh-LrTTFa0W54ayiXMydqw,6191
5
- fastapi_fsp-0.1.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
- fastapi_fsp-0.1.3.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
7
- fastapi_fsp-0.1.3.dist-info/RECORD,,