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 +459 -210
- fastapi_fsp/models.py +20 -0
- {fastapi_fsp-0.1.3.dist-info → fastapi_fsp-0.2.2.dist-info}/METADATA +2 -1
- fastapi_fsp-0.2.2.dist-info/RECORD +7 -0
- {fastapi_fsp-0.1.3.dist-info → fastapi_fsp-0.2.2.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.2.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,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
|
|
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
|
|
270
|
+
def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
|
|
271
|
+
"""
|
|
272
|
+
Generate a complete paginated response.
|
|
174
273
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
Args:
|
|
315
|
+
total_items: Total number of items matching filters
|
|
316
|
+
data_page: Current page of data
|
|
317
317
|
|
|
318
|
-
|
|
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,
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
535
|
+
@staticmethod
|
|
536
|
+
def _count_total(query: Select, session: Session) -> int:
|
|
537
|
+
"""
|
|
538
|
+
Count total items matching the query.
|
|
372
539
|
|
|
373
|
-
|
|
540
|
+
Args:
|
|
541
|
+
query: SQLAlchemy Select query with filters applied
|
|
542
|
+
session: Database session
|
|
374
543
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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.
|
|
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,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
|