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 +389 -210
- fastapi_fsp/models.py +20 -0
- {fastapi_fsp-0.1.3.dist-info → fastapi_fsp-0.2.1.dist-info}/METADATA +2 -1
- fastapi_fsp-0.2.1.dist-info/RECORD +7 -0
- {fastapi_fsp-0.1.3.dist-info → fastapi_fsp-0.2.1.dist-info}/WHEEL +1 -1
- fastapi_fsp-0.1.3.dist-info/RECORD +0 -7
- {fastapi_fsp-0.1.3.dist-info → fastapi_fsp-0.2.1.dist-info}/licenses/LICENSE +0 -0
fastapi_fsp/fsp.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
208
|
-
|
|
252
|
+
Args:
|
|
253
|
+
query: Base SQLAlchemy Select query
|
|
254
|
+
session: Database session
|
|
209
255
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
292
|
+
Args:
|
|
293
|
+
total_items: Total number of items matching filters
|
|
294
|
+
data_page: Current page of data
|
|
317
295
|
|
|
318
|
-
|
|
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,
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
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,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,,
|
|
File without changes
|