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.
Files changed (25) hide show
  1. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.github/workflows/ci.yml +5 -2
  2. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/PKG-INFO +19 -7
  3. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/README.md +14 -4
  4. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/fastapi_fsp/fsp.py +82 -13
  5. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/pyproject.toml +5 -3
  6. fastapi_fsp-0.1.3/tests/test_fsp_filters_indexed_sync.py +46 -0
  7. fastapi_fsp-0.1.3/uv.lock +721 -0
  8. fastapi_fsp-0.1.1/uv.lock +0 -645
  9. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.github/workflows/release.yml +0 -0
  10. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.gitignore +0 -0
  11. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/.pre-commit-config.yaml +0 -0
  12. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/LICENSE +0 -0
  13. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/PROJECT.md +0 -0
  14. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/fastapi_fsp/__init__.py +0 -0
  15. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/fastapi_fsp/models.py +0 -0
  16. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/main.py +0 -0
  17. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/pytest.ini +0 -0
  18. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/__init__.py +0 -0
  19. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/conftest.py +0 -0
  20. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/conftest_async.py +0 -0
  21. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/main.py +0 -0
  22. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/main_async.py +0 -0
  23. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/test_fsp.py +0 -0
  24. {fastapi_fsp-0.1.1 → fastapi_fsp-0.1.3}/tests/test_fsp_async.py +0 -0
  25. {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: '3.12'
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.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.111
23
- Requires-Dist: sqlmodel>=0.0.24
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 (repeatable sets; arrays are supported by sending multiple parameters):
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
- You can chain multiple filters by repeating the triplet:
139
+ Example (indexed format):
132
140
  ```
133
- ?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust
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 (repeatable sets; arrays are supported by sending multiple parameters):
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
- You can chain multiple filters by repeating the triplet:
112
+ Example (indexed format):
107
113
  ```
108
- ?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust
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 _parse_filters(
23
- fields: Optional[List[str]] = Query(None, alias="field"),
24
- operators: Optional[List[FilterOperator]] = Query(None, alias="operator"),
25
- values: Optional[List[str]] = Query(None, alias="value"),
26
- ) -> List[Filter] | None:
27
- if not fields:
28
- return None
29
- filters: List[Filter] = []
30
- if operators is None or values is None or not (len(fields) == len(operators) == len(values)):
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
- for field, operator, value in zip(fields, operators, values):
36
- filters.append(Filter(field=field, operator=operator, value=value))
37
- 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
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.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.111",
29
- "sqlmodel>=0.0.24",
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