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 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 _parse_filters(
22
- fields: Optional[List[str]] = Query(None, alias="field"),
23
- operators: Optional[List[FilterOperator]] = Query(None, alias="operator"),
24
- values: Optional[List[str]] = Query(None, alias="value"),
25
- ) -> List[Filter] | None:
26
- if not fields:
27
- return None
28
- filters: List[Filter] = []
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
- for field, operator, value in zip(fields, operators, values):
35
- filters.append(Filter(field=field, operator=operator, value=value))
36
- return filters
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(~column.like(str(raw_value)))
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(~column.ilike(pattern))
248
+ query = query.where(not_(column.ilike(pattern)))
166
249
  else:
167
- query = query.where(~func.lower(column).like(pattern.lower()))
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(~column.in_(vals))
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
- query = query.where(column.like(pattern))
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
- query = query.where(column.like(pattern))
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
- query = query.where(column.like(pattern))
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.0
3
+ Version: 0.1.2
4
4
  Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
5
- Project-URL: Homepage, https://github.com/your-org/fastapi-fsp
6
- Project-URL: Repository, https://github.com/your-org/fastapi-fsp
7
- Project-URL: Issues, https://github.com/your-org/fastapi-fsp/issues
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 (repeatable sets; arrays are supported by sending multiple parameters):
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
- You can chain multiple filters by repeating the triplet:
137
+ Example (indexed format):
132
138
  ```
133
- ?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust
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,,