fastapi-fsp 0.1.1__tar.gz → 0.1.3__tar.gz
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-0.1.1 → fastapi_fsp-0.1.3}/.github/workflows/ci.yml +5 -2
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/PKG-INFO +19 -7
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/README.md +14 -4
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/fastapi_fsp/fsp.py +82 -13
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/pyproject.toml +5 -3
- fastapi_fsp-0.1.3/tests/test_fsp_filters_indexed_sync.py +46 -0
- fastapi_fsp-0.1.3/uv.lock +721 -0
- fastapi_fsp-0.1.1/uv.lock +0 -645
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.github/workflows/release.yml +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.gitignore +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.pre-commit-config.yaml +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/LICENSE +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/PROJECT.md +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/fastapi_fsp/__init__.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/fastapi_fsp/models.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/main.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/pytest.ini +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/__init__.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/conftest.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/conftest_async.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/main.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/main_async.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/test_fsp.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/test_fsp_async.py +0 -0
- {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/test_fsp_filters_sync.py +0 -0
|
@@ -8,6 +8,9 @@ on:
|
|
|
8
8
|
jobs:
|
|
9
9
|
build-test-lint:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ['3.12', '3.13', '3.14']
|
|
11
14
|
steps:
|
|
12
15
|
- name: Checkout
|
|
13
16
|
uses: actions/checkout@v4
|
|
@@ -15,7 +18,7 @@ jobs:
|
|
|
15
18
|
- name: Set up Python
|
|
16
19
|
uses: actions/setup-python@v5
|
|
17
20
|
with:
|
|
18
|
-
python-version:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
19
22
|
|
|
20
23
|
- name: Set up uv
|
|
21
24
|
uses: astral-sh/setup-uv@v4
|
|
@@ -38,7 +41,7 @@ jobs:
|
|
|
38
41
|
if: always()
|
|
39
42
|
uses: actions/upload-artifact@v4
|
|
40
43
|
with:
|
|
41
|
-
name: coverage-reports
|
|
44
|
+
name: coverage-reports-${{ matrix.python-version }}
|
|
42
45
|
path: |
|
|
43
46
|
coverage.xml
|
|
44
47
|
htmlcov
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fsp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -16,11 +16,13 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
21
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
23
|
Requires-Python: >=3.12
|
|
22
|
-
Requires-Dist: fastapi>=0.
|
|
23
|
-
Requires-Dist: sqlmodel>=0.0.
|
|
24
|
+
Requires-Dist: fastapi>=0.121.1
|
|
25
|
+
Requires-Dist: sqlmodel>=0.0.27
|
|
24
26
|
Description-Content-Type: text/markdown
|
|
25
27
|
|
|
26
28
|
# fastapi-fsp
|
|
@@ -109,7 +111,9 @@ Sorting:
|
|
|
109
111
|
- sort_by: the field name, e.g., `name`
|
|
110
112
|
- order: `asc` or `desc`
|
|
111
113
|
|
|
112
|
-
Filtering (
|
|
114
|
+
Filtering (two supported formats):
|
|
115
|
+
|
|
116
|
+
1) Simple (triplets repeated in the query string):
|
|
113
117
|
- field: the field/column name, e.g., `name`
|
|
114
118
|
- operator: one of
|
|
115
119
|
- eq, ne
|
|
@@ -122,17 +126,25 @@ Filtering (repeatable sets; arrays are supported by sending multiple parameters)
|
|
|
122
126
|
- contains, starts_with, ends_with (translated to LIKE patterns)
|
|
123
127
|
- value: raw string value (or list-like comma-separated depending on operator)
|
|
124
128
|
|
|
125
|
-
Examples:
|
|
129
|
+
Examples (simple format):
|
|
126
130
|
- `?field=name&operator=eq&value=Deadpond`
|
|
127
131
|
- `?field=age&operator=between&value=18,30`
|
|
128
132
|
- `?field=name&operator=in&value=Deadpond,Rusty-Man`
|
|
129
133
|
- `?field=name&operator=contains&value=man`
|
|
134
|
+
- Chain multiple filters by repeating the triplet: `?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust`
|
|
135
|
+
|
|
136
|
+
2) Indexed format (useful for clients that handle arrays of objects):
|
|
137
|
+
- Use keys like `filters[0][field]`, `filters[0][operator]`, `filters[0][value]`, then increment the index for additional filters (`filters[1][...]`, etc.).
|
|
130
138
|
|
|
131
|
-
|
|
139
|
+
Example (indexed format):
|
|
132
140
|
```
|
|
133
|
-
?field=age&operator=gte&value=18&field=name&operator=ilike&value=
|
|
141
|
+
?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
142
|
```
|
|
135
143
|
|
|
144
|
+
Notes:
|
|
145
|
+
- Both formats are equivalent; the indexed format takes precedence if present.
|
|
146
|
+
- 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.
|
|
147
|
+
|
|
136
148
|
## Response model
|
|
137
149
|
|
|
138
150
|
```
|
|
@@ -84,7 +84,9 @@ Sorting:
|
|
|
84
84
|
- sort_by: the field name, e.g., `name`
|
|
85
85
|
- order: `asc` or `desc`
|
|
86
86
|
|
|
87
|
-
Filtering (
|
|
87
|
+
Filtering (two supported formats):
|
|
88
|
+
|
|
89
|
+
1) Simple (triplets repeated in the query string):
|
|
88
90
|
- field: the field/column name, e.g., `name`
|
|
89
91
|
- operator: one of
|
|
90
92
|
- eq, ne
|
|
@@ -97,17 +99,25 @@ Filtering (repeatable sets; arrays are supported by sending multiple parameters)
|
|
|
97
99
|
- contains, starts_with, ends_with (translated to LIKE patterns)
|
|
98
100
|
- value: raw string value (or list-like comma-separated depending on operator)
|
|
99
101
|
|
|
100
|
-
Examples:
|
|
102
|
+
Examples (simple format):
|
|
101
103
|
- `?field=name&operator=eq&value=Deadpond`
|
|
102
104
|
- `?field=age&operator=between&value=18,30`
|
|
103
105
|
- `?field=name&operator=in&value=Deadpond,Rusty-Man`
|
|
104
106
|
- `?field=name&operator=contains&value=man`
|
|
107
|
+
- Chain multiple filters by repeating the triplet: `?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust`
|
|
108
|
+
|
|
109
|
+
2) Indexed format (useful for clients that handle arrays of objects):
|
|
110
|
+
- Use keys like `filters[0][field]`, `filters[0][operator]`, `filters[0][value]`, then increment the index for additional filters (`filters[1][...]`, etc.).
|
|
105
111
|
|
|
106
|
-
|
|
112
|
+
Example (indexed format):
|
|
107
113
|
```
|
|
108
|
-
?field=age&operator=gte&value=18&field=name&operator=ilike&value=
|
|
114
|
+
?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=joy
|
|
109
115
|
```
|
|
110
116
|
|
|
117
|
+
Notes:
|
|
118
|
+
- Both formats are equivalent; the indexed format takes precedence if present.
|
|
119
|
+
- 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.
|
|
120
|
+
|
|
111
121
|
## Response model
|
|
112
122
|
|
|
113
123
|
```
|
|
@@ -2,6 +2,7 @@ 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
7
|
from sqlmodel import Session, not_, select
|
|
7
8
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
@@ -19,22 +20,90 @@ from fastapi_fsp.models import (
|
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
43
|
+
if not (len(fields) == len(operators) == len(values)):
|
|
31
44
|
raise HTTPException(
|
|
32
45
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
33
|
-
detail="Mismatched filter parameters.",
|
|
46
|
+
detail="Mismatched filter parameters in array format.",
|
|
34
47
|
)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
107
|
|
|
39
108
|
|
|
40
109
|
def _parse_sort(
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-fsp"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -20,13 +20,15 @@ classifiers = [
|
|
|
20
20
|
"Programming Language :: Python",
|
|
21
21
|
"Programming Language :: Python :: 3",
|
|
22
22
|
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
23
25
|
"Framework :: FastAPI",
|
|
24
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
25
27
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
28
|
]
|
|
27
29
|
dependencies = [
|
|
28
|
-
"fastapi>=0.
|
|
29
|
-
"sqlmodel>=0.0.
|
|
30
|
+
"fastapi>=0.121.1",
|
|
31
|
+
"sqlmodel>=0.0.27",
|
|
30
32
|
]
|
|
31
33
|
|
|
32
34
|
[project.urls]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from fastapi.testclient import TestClient
|
|
2
|
+
from sqlmodel import Session
|
|
3
|
+
|
|
4
|
+
from tests.main import Hero
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def seed(session: Session):
|
|
8
|
+
session.add_all(
|
|
9
|
+
[
|
|
10
|
+
Hero(name="Deadpond", secret_name="Dive Wilson", age=None),
|
|
11
|
+
Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48),
|
|
12
|
+
Hero(name="ALPHA", secret_name="Alpha Secret", age=10),
|
|
13
|
+
Hero(name="beta", secret_name="Beta Secret", age=20),
|
|
14
|
+
]
|
|
15
|
+
)
|
|
16
|
+
session.commit()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_indexed_single_filter_eq(session: Session, client: TestClient):
|
|
20
|
+
seed(session)
|
|
21
|
+
r = client.get(
|
|
22
|
+
"/heroes/?filters[0][field]=name&filters[0][operator]=eq&filters[0][value]=Deadpond"
|
|
23
|
+
)
|
|
24
|
+
assert r.status_code == 200
|
|
25
|
+
js = r.json()
|
|
26
|
+
assert len(js["data"]) == 1
|
|
27
|
+
assert js["data"][0]["name"] == "Deadpond"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_indexed_multiple_filters_combined(session: Session, client: TestClient):
|
|
31
|
+
seed(session)
|
|
32
|
+
# age >= 18 AND name ILIKE '%eta'
|
|
33
|
+
r = client.get(
|
|
34
|
+
"/heroes/?filters[0][field]=age&filters[0][operator]=gte&filters[0][value]=18"
|
|
35
|
+
"&filters[1][field]=name&filters[1][operator]=ilike&filters[1][value]=%25eta"
|
|
36
|
+
)
|
|
37
|
+
assert r.status_code == 200
|
|
38
|
+
names = [h["name"] for h in r.json()["data"]]
|
|
39
|
+
# Only 'beta' is age >= 18 and ends with 'eta'
|
|
40
|
+
assert set(names) == {"beta"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_indexed_incomplete_filter_returns_400(session: Session, client: TestClient):
|
|
44
|
+
seed(session)
|
|
45
|
+
r = client.get("/heroes/?filters[0][field]=age&filters[0][operator]=gte")
|
|
46
|
+
assert r.status_code == 400
|