fastapi-fsp 0.1.3__py3-none-any.whl → 0.2.1__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,58 @@ 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)],
132
198
  ):
199
+ """
200
+ Initialize FSPManager.
201
+
202
+ Args:
203
+ request: FastAPI Request object
204
+ filters: Parsed filters
205
+ sorting: Sorting configuration
206
+ pagination: Pagination configuration
207
+ """
133
208
  self.request = request
134
209
  self.filters = filters
135
210
  self.sorting = sorting
136
211
  self.pagination = pagination
137
212
 
138
213
  def paginate(self, query: Select, session: Session) -> Any:
214
+ """
215
+ Execute pagination on a query.
216
+
217
+ Args:
218
+ query: SQLAlchemy Select query
219
+ session: Database session
220
+
221
+ Returns:
222
+ Any: Query results
223
+ """
139
224
  return session.exec(
140
225
  query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
141
226
  self.pagination.per_page
@@ -143,6 +228,16 @@ class FSPManager:
143
228
  ).all()
144
229
 
145
230
  async def paginate_async(self, query: Select, session: AsyncSession) -> Any:
231
+ """
232
+ Execute pagination on a query asynchronously.
233
+
234
+ Args:
235
+ query: SQLAlchemy Select query
236
+ session: Async database session
237
+
238
+ Returns:
239
+ Any: Query results
240
+ """
146
241
  result = await session.exec(
147
242
  query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
148
243
  self.pagination.per_page
@@ -150,177 +245,60 @@ class FSPManager:
150
245
  )
151
246
  return result.all()
152
247
 
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
174
-
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]
248
+ def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
249
+ """
250
+ Generate a complete paginated response.
206
251
 
207
- def ilike_supported(col):
208
- return hasattr(col, "ilike")
252
+ Args:
253
+ query: Base SQLAlchemy Select query
254
+ session: Database session
209
255
 
210
- for f in self.filters:
211
- if not f or not f.field:
212
- continue
256
+ Returns:
257
+ PaginatedResponse: Complete paginated response
258
+ """
259
+ 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)
213
262
 
214
- column = columns_map.get(f.field)
215
- if column is None:
216
- # Skip unknown fields silently
217
- continue
218
-
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
263
+ total_items = self._count_total(query, session)
264
+ data_page = self.paginate(query, session)
265
+ return self._generate_response(total_items=total_items, data_page=data_page)
290
266
 
291
- return query
267
+ async def generate_response_async(
268
+ self, query: Select, session: AsyncSession
269
+ ) -> PaginatedResponse[Any]:
270
+ """
271
+ Generate a complete paginated response asynchronously.
292
272
 
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 = {}
273
+ Args:
274
+ query: Base SQLAlchemy Select query
275
+ session: Async database session
299
276
 
300
- if not self.sorting or not self.sorting.sort_by:
301
- return query
277
+ Returns:
278
+ PaginatedResponse: Complete paginated response
279
+ """
280
+ 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)
302
283
 
303
- column = columns_map.get(self.sorting.sort_by)
304
- if column is None:
305
- # Unknown sort column; skip sorting
306
- return query
284
+ total_items = await self._count_total_async(query, session)
285
+ data_page = await self.paginate_async(query, session)
286
+ return self._generate_response(total_items=total_items, data_page=data_page)
307
287
 
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())
288
+ def _generate_response(self, total_items: int, data_page: Any) -> PaginatedResponse[Any]:
289
+ """
290
+ Generate the final paginated response object.
313
291
 
314
- def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
315
- query = self._apply_filters(query)
316
- query = self._apply_sort(query)
292
+ Args:
293
+ total_items: Total number of items matching filters
294
+ data_page: Current page of data
317
295
 
318
- total_items = self._count_total(query, session)
296
+ Returns:
297
+ PaginatedResponse: Final response object
298
+ """
319
299
  per_page = self.pagination.per_page
320
300
  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)
301
+ total_pages = max(1, ceil(total_items / per_page)) if total_items is not None else 1
324
302
 
325
303
  # Build links based on current URL, replacing/adding page and per_page parameters
326
304
  url = self.request.url
@@ -359,52 +337,253 @@ class FSPManager:
359
337
  ),
360
338
  )
361
339
 
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)
340
+ @staticmethod
341
+ def _coerce_value(column: ColumnElement[Any], raw: str) -> Any:
342
+ """
343
+ Coerce raw string value to column's Python type.
367
344
 
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
345
+ Args:
346
+ column: SQLAlchemy column element
347
+ raw: Raw string value
372
348
 
373
- data_page = await self.paginate_async(query, session)
349
+ Returns:
350
+ Any: Coerced value
351
+ """
352
+ # 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
357
+ if pytype is None or isinstance(raw, pytype):
358
+ return raw
359
+ # Handle booleans represented as strings
360
+ if pytype is bool:
361
+ val = raw.strip().lower()
362
+ if val in {"true", "1", "t", "yes", "y"}:
363
+ return True
364
+ if val in {"false", "0", "f", "no", "n"}:
365
+ return False
366
+ # Handle integers represented as strings
367
+ if pytype is int:
368
+ try:
369
+ return int(raw)
370
+ except ValueError:
371
+ # Handle common cases like "1.0"
372
+ try:
373
+ return int(float(raw))
374
+ except ValueError:
375
+ return raw
376
+ # Handle dates represented as strings
377
+ if pytype is datetime:
378
+ try:
379
+ return parse(raw)
380
+ except ValueError:
381
+ return raw
382
+ # Generic cast with fallback
383
+ try:
384
+ return pytype(raw)
385
+ except Exception:
386
+ return raw
387
+
388
+ @staticmethod
389
+ def _split_values(raw: str) -> List[str]:
390
+ """
391
+ Split comma-separated values.
392
+
393
+ Args:
394
+ raw: Raw string of comma-separated values
395
+
396
+ Returns:
397
+ List[str]: List of stripped values
398
+ """
399
+ return [item.strip() for item in raw.split(",")]
400
+
401
+ @staticmethod
402
+ def _ilike_supported(col: ColumnElement[Any]) -> bool:
403
+ """
404
+ Check if ILIKE is supported for this column.
405
+
406
+ Args:
407
+ col: SQLAlchemy column element
408
+
409
+ Returns:
410
+ bool: True if ILIKE is supported
411
+ """
412
+ return hasattr(col, "ilike")
413
+
414
+ @staticmethod
415
+ def _apply_filter(query: Select, column: ColumnElement[Any], f: Filter):
416
+ """
417
+ Apply a single filter to a query.
418
+
419
+ Args:
420
+ query: Base SQLAlchemy Select query
421
+ column: Column to apply filter to
422
+ f: Filter to apply
423
+
424
+ Returns:
425
+ Select: Query with filter applied
426
+ """
427
+ op = f.operator # type: FilterOperator
428
+ raw_value = f.value # type: str
429
+
430
+ # Build conditions based on operator
431
+ if op == FilterOperator.EQ:
432
+ query = query.where(column == FSPManager._coerce_value(column, raw_value))
433
+ elif op == FilterOperator.NE:
434
+ query = query.where(column != FSPManager._coerce_value(column, raw_value))
435
+ elif op == FilterOperator.GT:
436
+ query = query.where(column > FSPManager._coerce_value(column, raw_value))
437
+ elif op == FilterOperator.GTE:
438
+ query = query.where(column >= FSPManager._coerce_value(column, raw_value))
439
+ elif op == FilterOperator.LT:
440
+ query = query.where(column < FSPManager._coerce_value(column, raw_value))
441
+ elif op == FilterOperator.LTE:
442
+ query = query.where(column <= FSPManager._coerce_value(column, raw_value))
443
+ elif op == FilterOperator.LIKE:
444
+ query = query.where(column.like(raw_value))
445
+ elif op == FilterOperator.NOT_LIKE:
446
+ query = query.where(not_(column.like(raw_value)))
447
+ elif op == FilterOperator.ILIKE:
448
+ pattern = raw_value
449
+ if FSPManager._ilike_supported(column):
450
+ query = query.where(column.ilike(pattern))
451
+ else:
452
+ query = query.where(func.lower(column).like(pattern.lower()))
453
+ elif op == FilterOperator.NOT_ILIKE:
454
+ pattern = raw_value
455
+ if FSPManager._ilike_supported(column):
456
+ query = query.where(not_(column.ilike(pattern)))
457
+ else:
458
+ query = query.where(not_(func.lower(column).like(pattern.lower())))
459
+ elif op == FilterOperator.IN:
460
+ vals = [
461
+ FSPManager._coerce_value(column, v) for v in FSPManager._split_values(raw_value)
462
+ ]
463
+ query = query.where(column.in_(vals))
464
+ elif op == FilterOperator.NOT_IN:
465
+ vals = [
466
+ FSPManager._coerce_value(column, v) for v in FSPManager._split_values(raw_value)
467
+ ]
468
+ query = query.where(not_(column.in_(vals)))
469
+ elif op == FilterOperator.BETWEEN:
470
+ vals = FSPManager._split_values(raw_value)
471
+ 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))
476
+ elif op == FilterOperator.IS_NULL:
477
+ query = query.where(column.is_(None))
478
+ elif op == FilterOperator.IS_NOT_NULL:
479
+ query = query.where(column.is_not(None))
480
+ elif op == FilterOperator.STARTS_WITH:
481
+ pattern = f"{raw_value}%"
482
+ if FSPManager._ilike_supported(column):
483
+ query = query.where(column.ilike(pattern))
484
+ else:
485
+ query = query.where(func.lower(column).like(pattern.lower()))
486
+ elif op == FilterOperator.ENDS_WITH:
487
+ pattern = f"%{raw_value}"
488
+ if FSPManager._ilike_supported(column):
489
+ query = query.where(column.ilike(pattern))
490
+ else:
491
+ query = query.where(func.lower(column).like(pattern.lower()))
492
+ elif op == FilterOperator.CONTAINS:
493
+ pattern = f"%{raw_value}%"
494
+ if FSPManager._ilike_supported(column):
495
+ query = query.where(column.ilike(pattern))
496
+ else:
497
+ query = query.where(func.lower(column).like(pattern.lower()))
498
+ # Unknown operator: skip
499
+ return query
374
500
 
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))
501
+ @staticmethod
502
+ def _count_total(query: Select, session: Session) -> int:
503
+ """
504
+ Count total items matching the query.
390
505
 
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
- )
506
+ Args:
507
+ query: SQLAlchemy Select query with filters applied
508
+ session: Database session
509
+
510
+ Returns:
511
+ int: Total count of items
512
+ """
513
+ # Count the total rows of the given query (with filters/sort applied) ignoring pagination
514
+ count_query = select(func.count()).select_from(query.subquery())
515
+ return session.exec(count_query).one()
516
+
517
+ @staticmethod
518
+ async def _count_total_async(query: Select, session: AsyncSession) -> int:
519
+ """
520
+ Count total items matching the query asynchronously.
521
+
522
+ Args:
523
+ query: SQLAlchemy Select query with filters applied
524
+ session: Async database session
525
+
526
+ Returns:
527
+ int: Total count of items
528
+ """
529
+ count_query = select(func.count()).select_from(query.subquery())
530
+ result = await session.exec(count_query)
531
+ return result.one()
532
+
533
+ @staticmethod
534
+ def _apply_filters(
535
+ query: Select,
536
+ columns_map: ColumnCollection[str, ColumnElement[Any]],
537
+ filters: Optional[List[Filter]],
538
+ ) -> Select:
539
+ """
540
+ Apply filters to a query.
541
+
542
+ Args:
543
+ query: Base SQLAlchemy Select query
544
+ columns_map: Map of column names to column elements
545
+ filters: List of filters to apply
546
+
547
+ Returns:
548
+ Select: Query with filters applied
549
+ """
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)
557
+
558
+ return query
559
+
560
+ @staticmethod
561
+ def _apply_sort(
562
+ query: Select,
563
+ columns_map: ColumnCollection[str, ColumnElement[Any]],
564
+ sorting: Optional[SortingQuery],
565
+ ) -> Select:
566
+ """
567
+ Apply sorting to a query.
568
+
569
+ Args:
570
+ query: Base SQLAlchemy Select query
571
+ columns_map: Map of column names to column elements
572
+ sorting: Sorting configuration
573
+
574
+ Returns:
575
+ Select: Query with sorting applied
576
+ """
577
+ if sorting and sorting.sort_by:
578
+ column = columns_map.get(sorting.sort_by)
579
+ 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
+ )
589
+ 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.1
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=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,,
@@ -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,,