fastapi-fsp 0.1.0__py3-none-any.whl → 0.1.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 +162 -21
- {fastapi_fsp-0.1.0.dist-info → fastapi_fsp-0.1.2.dist-info}/METADATA +18 -8
- fastapi_fsp-0.1.2.dist-info/RECORD +7 -0
- fastapi_fsp-0.1.0.dist-info/RECORD +0 -7
- {fastapi_fsp-0.1.0.dist-info → fastapi_fsp-0.1.2.dist-info}/WHEEL +0 -0
- {fastapi_fsp-0.1.0.dist-info → fastapi_fsp-0.1.2.dist-info}/licenses/LICENSE +0 -0
fastapi_fsp/fsp.py
CHANGED
|
@@ -2,8 +2,10 @@ import math
|
|
|
2
2
|
from typing import Annotated, Any, List, Optional
|
|
3
3
|
|
|
4
4
|
from fastapi import Depends, HTTPException, Query, Request, status
|
|
5
|
+
from pydantic import ValidationError
|
|
5
6
|
from sqlalchemy import Select, func
|
|
6
|
-
from sqlmodel import Session, select
|
|
7
|
+
from sqlmodel import Session, not_, select
|
|
8
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
7
9
|
|
|
8
10
|
from fastapi_fsp.models import (
|
|
9
11
|
Filter,
|
|
@@ -18,22 +20,90 @@ from fastapi_fsp.models import (
|
|
|
18
20
|
)
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
def _parse_one_filter_at(i: int, field: str, operator: str, value: str) -> Filter:
|
|
24
|
+
try:
|
|
25
|
+
filter_ = Filter(field=field, operator=FilterOperator(operator), value=value)
|
|
26
|
+
except ValidationError as e:
|
|
27
|
+
raise HTTPException(
|
|
28
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
29
|
+
detail=f"Invalid filter at index {i}: {str(e)}",
|
|
30
|
+
) from e
|
|
31
|
+
except ValueError as e:
|
|
32
|
+
raise HTTPException(
|
|
33
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
34
|
+
detail=f"Invalid operator '{operator}' at index {i}.",
|
|
35
|
+
) from e
|
|
36
|
+
return filter_
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_array_of_filters(
|
|
40
|
+
fields: List[str], operators: List[str], values: List[str]
|
|
41
|
+
) -> List[Filter]:
|
|
42
|
+
# Validate that we have matching lengths
|
|
29
43
|
if not (len(fields) == len(operators) == len(values)):
|
|
30
44
|
raise HTTPException(
|
|
31
45
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
32
|
-
detail="Mismatched filter parameters.",
|
|
46
|
+
detail="Mismatched filter parameters in array format.",
|
|
33
47
|
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
return [
|
|
49
|
+
_parse_one_filter_at(i, field, operator, value)
|
|
50
|
+
for i, (field, operator, value) in enumerate(zip(fields, operators, values))
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_filters(
|
|
55
|
+
request: Request,
|
|
56
|
+
) -> Optional[List[Filter]]:
|
|
57
|
+
"""
|
|
58
|
+
Parse filters from query parameters supporting two formats:
|
|
59
|
+
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
|
|
61
|
+
2. Simple format:
|
|
62
|
+
?field=age&operator=gte&value=18&field=name&operator=ilike&value=joy
|
|
63
|
+
"""
|
|
64
|
+
query_params = request.query_params
|
|
65
|
+
filters = []
|
|
66
|
+
|
|
67
|
+
# Try indexed format first: filters[0][field], filters[0][operator], etc.
|
|
68
|
+
i = 0
|
|
69
|
+
while True:
|
|
70
|
+
field_key = f"filters[{i}][field]"
|
|
71
|
+
operator_key = f"filters[{i}][operator]"
|
|
72
|
+
value_key = f"filters[{i}][value]"
|
|
73
|
+
|
|
74
|
+
field = query_params.get(field_key)
|
|
75
|
+
operator = query_params.get(operator_key)
|
|
76
|
+
value = query_params.get(value_key)
|
|
77
|
+
|
|
78
|
+
# If we don't have a field at this index, break the loop
|
|
79
|
+
if field is None:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
# Validate that we have all required parts
|
|
83
|
+
if operator is None or value is None:
|
|
84
|
+
raise HTTPException(
|
|
85
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
86
|
+
detail=f"Incomplete filter at index {i}. Missing operator or value.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
filters.append(_parse_one_filter_at(i, field, operator, value))
|
|
90
|
+
i += 1
|
|
91
|
+
|
|
92
|
+
# If we found indexed filters, return them
|
|
93
|
+
if filters:
|
|
94
|
+
return filters
|
|
95
|
+
|
|
96
|
+
# Fall back to simple format: field, operator, value
|
|
97
|
+
filters = _parse_array_of_filters(
|
|
98
|
+
query_params.getlist("field"),
|
|
99
|
+
query_params.getlist("operator"),
|
|
100
|
+
query_params.getlist("value"),
|
|
101
|
+
)
|
|
102
|
+
if filters:
|
|
103
|
+
return filters
|
|
104
|
+
|
|
105
|
+
# No filters found
|
|
106
|
+
return None
|
|
37
107
|
|
|
38
108
|
|
|
39
109
|
def _parse_sort(
|
|
@@ -72,11 +142,24 @@ class FSPManager:
|
|
|
72
142
|
)
|
|
73
143
|
).all()
|
|
74
144
|
|
|
145
|
+
async def paginate_async(self, query: Select, session: AsyncSession) -> Any:
|
|
146
|
+
result = await session.exec(
|
|
147
|
+
query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
|
|
148
|
+
self.pagination.per_page
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
return result.all()
|
|
152
|
+
|
|
75
153
|
def _count_total(self, query: Select, session: Session) -> int:
|
|
76
154
|
# Count the total rows of the given query (with filters/sort applied) ignoring pagination
|
|
77
155
|
count_query = select(func.count()).select_from(query.subquery())
|
|
78
156
|
return session.exec(count_query).one()
|
|
79
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
|
+
|
|
80
163
|
def _apply_filters(self, query: Select) -> Select:
|
|
81
164
|
# Helper: build a map of column name -> column object from the select statement
|
|
82
165
|
try:
|
|
@@ -152,7 +235,7 @@ class FSPManager:
|
|
|
152
235
|
elif op == "like":
|
|
153
236
|
query = query.where(column.like(str(raw_value)))
|
|
154
237
|
elif op == "not_like":
|
|
155
|
-
query = query.where(
|
|
238
|
+
query = query.where(not_(column.like(str(raw_value))))
|
|
156
239
|
elif op == "ilike":
|
|
157
240
|
pattern = str(raw_value)
|
|
158
241
|
if ilike_supported(column):
|
|
@@ -162,15 +245,15 @@ class FSPManager:
|
|
|
162
245
|
elif op == "not_ilike":
|
|
163
246
|
pattern = str(raw_value)
|
|
164
247
|
if ilike_supported(column):
|
|
165
|
-
query = query.where(
|
|
248
|
+
query = query.where(not_(column.ilike(pattern)))
|
|
166
249
|
else:
|
|
167
|
-
query = query.where(
|
|
250
|
+
query = query.where(not_(func.lower(column).like(pattern.lower())))
|
|
168
251
|
elif op == "in":
|
|
169
252
|
vals = [coerce_value(column, v) for v in split_values(raw_value)]
|
|
170
253
|
query = query.where(column.in_(vals))
|
|
171
254
|
elif op == "not_in":
|
|
172
255
|
vals = [coerce_value(column, v) for v in split_values(raw_value)]
|
|
173
|
-
query = query.where(
|
|
256
|
+
query = query.where(not_(column.in_(vals)))
|
|
174
257
|
elif op == "between":
|
|
175
258
|
vals = split_values(raw_value)
|
|
176
259
|
if len(vals) != 2:
|
|
@@ -185,13 +268,22 @@ class FSPManager:
|
|
|
185
268
|
query = query.where(column.is_not(None))
|
|
186
269
|
elif op == "starts_with":
|
|
187
270
|
pattern = f"{str(raw_value)}%"
|
|
188
|
-
|
|
271
|
+
if ilike_supported(column):
|
|
272
|
+
query = query.where(column.ilike(pattern))
|
|
273
|
+
else:
|
|
274
|
+
query = query.where(func.lower(column).like(pattern.lower()))
|
|
189
275
|
elif op == "ends_with":
|
|
190
276
|
pattern = f"%{str(raw_value)}"
|
|
191
|
-
|
|
277
|
+
if ilike_supported(column):
|
|
278
|
+
query = query.where(column.ilike(pattern))
|
|
279
|
+
else:
|
|
280
|
+
query = query.where(func.lower(column).like(pattern.lower()))
|
|
192
281
|
elif op == "contains":
|
|
193
282
|
pattern = f"%{str(raw_value)}%"
|
|
194
|
-
|
|
283
|
+
if ilike_supported(column):
|
|
284
|
+
query = query.where(column.ilike(pattern))
|
|
285
|
+
else:
|
|
286
|
+
query = query.where(func.lower(column).like(pattern.lower()))
|
|
195
287
|
else:
|
|
196
288
|
# Unknown operator: skip
|
|
197
289
|
continue
|
|
@@ -221,7 +313,6 @@ class FSPManager:
|
|
|
221
313
|
|
|
222
314
|
def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
|
|
223
315
|
query = self._apply_filters(query)
|
|
224
|
-
|
|
225
316
|
query = self._apply_sort(query)
|
|
226
317
|
|
|
227
318
|
total_items = self._count_total(query, session)
|
|
@@ -267,3 +358,53 @@ class FSPManager:
|
|
|
267
358
|
prev=prev_url,
|
|
268
359
|
),
|
|
269
360
|
)
|
|
361
|
+
|
|
362
|
+
async def generate_response_async(
|
|
363
|
+
self, query: Select, session: AsyncSession
|
|
364
|
+
) -> PaginatedResponse[Any]:
|
|
365
|
+
query = self._apply_filters(query)
|
|
366
|
+
query = self._apply_sort(query)
|
|
367
|
+
|
|
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
|
|
372
|
+
|
|
373
|
+
data_page = await self.paginate_async(query, session)
|
|
374
|
+
|
|
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))
|
|
390
|
+
|
|
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
|
+
)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fsp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/fromej-dev/fastapi-fsp
|
|
6
|
+
Project-URL: Repository, https://github.com/fromej-dev/fastapi-fsp
|
|
7
|
+
Project-URL: Issues, https://github.com/fromej-dev/fastapi-fsp/issues
|
|
8
8
|
Author-email: Evert Jan Stamhuis <ej@fromejdevelopment.nl>
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
@@ -109,7 +109,9 @@ Sorting:
|
|
|
109
109
|
- sort_by: the field name, e.g., `name`
|
|
110
110
|
- order: `asc` or `desc`
|
|
111
111
|
|
|
112
|
-
Filtering (
|
|
112
|
+
Filtering (two supported formats):
|
|
113
|
+
|
|
114
|
+
1) Simple (triplets repeated in the query string):
|
|
113
115
|
- field: the field/column name, e.g., `name`
|
|
114
116
|
- operator: one of
|
|
115
117
|
- eq, ne
|
|
@@ -122,17 +124,25 @@ Filtering (repeatable sets; arrays are supported by sending multiple parameters)
|
|
|
122
124
|
- contains, starts_with, ends_with (translated to LIKE patterns)
|
|
123
125
|
- value: raw string value (or list-like comma-separated depending on operator)
|
|
124
126
|
|
|
125
|
-
Examples:
|
|
127
|
+
Examples (simple format):
|
|
126
128
|
- `?field=name&operator=eq&value=Deadpond`
|
|
127
129
|
- `?field=age&operator=between&value=18,30`
|
|
128
130
|
- `?field=name&operator=in&value=Deadpond,Rusty-Man`
|
|
129
131
|
- `?field=name&operator=contains&value=man`
|
|
132
|
+
- Chain multiple filters by repeating the triplet: `?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust`
|
|
133
|
+
|
|
134
|
+
2) Indexed format (useful for clients that handle arrays of objects):
|
|
135
|
+
- Use keys like `filters[0][field]`, `filters[0][operator]`, `filters[0][value]`, then increment the index for additional filters (`filters[1][...]`, etc.).
|
|
130
136
|
|
|
131
|
-
|
|
137
|
+
Example (indexed format):
|
|
132
138
|
```
|
|
133
|
-
?field=age&operator=gte&value=18&field=name&operator=ilike&value=
|
|
139
|
+
?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
|
|
134
140
|
```
|
|
135
141
|
|
|
142
|
+
Notes:
|
|
143
|
+
- Both formats are equivalent; the indexed format takes precedence if present.
|
|
144
|
+
- If any filter is incomplete (missing operator or value in the indexed form, or mismatched counts of simple triplets), the API responds with HTTP 400.
|
|
145
|
+
|
|
136
146
|
## Response model
|
|
137
147
|
|
|
138
148
|
```
|
|
@@ -0,0 +1,7 @@
|
|
|
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.2.dist-info/METADATA,sha256=0LL3xqAnDsME26BNsqUUwJ22sgWkPNF6I16OB-K3CJE,6087
|
|
5
|
+
fastapi_fsp-0.1.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
fastapi_fsp-0.1.2.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
7
|
+
fastapi_fsp-0.1.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=lC9CV0T4Zjf4MRaVdtq2HZlpRusxuR-na_AQnZa1w6A,10033
|
|
3
|
-
fastapi_fsp/models.py,sha256=_P72r_kgVh_ZvoKQqU359vnbJTxeWnvnT6yV5_M558Q,1740
|
|
4
|
-
fastapi_fsp-0.1.0.dist-info/METADATA,sha256=qxwzS_WR_pCfJTPSktw-5LDSm6UyU1WulD4UTTIR-T8,5430
|
|
5
|
-
fastapi_fsp-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
-
fastapi_fsp-0.1.0.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
7
|
-
fastapi_fsp-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|