fastapi-fsp 0.2.2__tar.gz → 0.2.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.2.2 → fastapi_fsp-0.2.3}/PKG-INFO +60 -1
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/README.md +59 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/fastapi_fsp/fsp.py +53 -5
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/pyproject.toml +1 -1
- fastapi_fsp-0.2.3/tests/test_fsp_computed_fields.py +221 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/uv.lock +1 -4
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.github/workflows/ci.yml +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.github/workflows/release.yml +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.gitignore +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/.pre-commit-config.yaml +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/LICENSE +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/OPTIMIZATION_ANALYSIS.md +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/PROJECT.md +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/RECOMMENDATIONS.md +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmark_results_optimized.txt +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmarks/__init__.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmarks/benchmark_internals.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/benchmarks/benchmark_suite.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/fastapi_fsp/__init__.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/fastapi_fsp/models.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/main.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/pytest.ini +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/__init__.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/conftest.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/conftest_async.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/main.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/main_async.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_async.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_filters_indexed_sync.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_filters_sync.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_optimizations.py +0 -0
- {fastapi_fsp-0.2.2 → fastapi_fsp-0.2.3}/tests/test_fsp_strict_mode.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fsp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -146,6 +146,65 @@ Notes:
|
|
|
146
146
|
- Both formats are equivalent; the indexed format takes precedence if present.
|
|
147
147
|
- 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.
|
|
148
148
|
|
|
149
|
+
## Filtering on Computed Fields
|
|
150
|
+
|
|
151
|
+
You can filter (and sort) on SQLAlchemy `hybrid_property` fields that have a SQL expression defined. This enables filtering on calculated or derived values at the database level.
|
|
152
|
+
|
|
153
|
+
### Defining a Computed Field
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from typing import ClassVar, Optional
|
|
157
|
+
from sqlalchemy import func
|
|
158
|
+
from sqlalchemy.ext.hybrid import hybrid_property
|
|
159
|
+
from sqlmodel import Field, SQLModel
|
|
160
|
+
|
|
161
|
+
class HeroBase(SQLModel):
|
|
162
|
+
name: str = Field(index=True)
|
|
163
|
+
secret_name: str
|
|
164
|
+
age: Optional[int] = Field(default=None)
|
|
165
|
+
full_name: ClassVar[str] # Required: declare as ClassVar for Pydantic
|
|
166
|
+
|
|
167
|
+
@hybrid_property
|
|
168
|
+
def full_name(self) -> str:
|
|
169
|
+
"""Python-level implementation (used on instances)."""
|
|
170
|
+
return f"{self.name}-{self.secret_name}"
|
|
171
|
+
|
|
172
|
+
@full_name.expression
|
|
173
|
+
def full_name(cls):
|
|
174
|
+
"""SQL-level implementation (used in queries)."""
|
|
175
|
+
return func.concat(cls.name, "-", cls.secret_name)
|
|
176
|
+
|
|
177
|
+
class Hero(HeroBase, table=True):
|
|
178
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
179
|
+
|
|
180
|
+
class HeroPublic(HeroBase):
|
|
181
|
+
id: int
|
|
182
|
+
full_name: str # Include in response model
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Querying Computed Fields
|
|
186
|
+
|
|
187
|
+
Once defined, you can filter and sort on the computed field like any regular field:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
# Filter by computed field
|
|
191
|
+
GET /heroes/?field=full_name&operator=eq&value=Spider-Man
|
|
192
|
+
GET /heroes/?field=full_name&operator=ilike&value=%man
|
|
193
|
+
GET /heroes/?field=full_name&operator=contains&value=Spider
|
|
194
|
+
|
|
195
|
+
# Sort by computed field
|
|
196
|
+
GET /heroes/?sort_by=full_name&order=asc
|
|
197
|
+
|
|
198
|
+
# Combine with other filters
|
|
199
|
+
GET /heroes/?field=full_name&operator=starts_with&value=Spider&field=age&operator=gte&value=21
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Requirements
|
|
203
|
+
|
|
204
|
+
- The `hybrid_property` must have an `.expression` decorator that returns a valid SQL expression
|
|
205
|
+
- The field should be declared as `ClassVar[type]` in the SQLModel base class to work with Pydantic
|
|
206
|
+
- Only computed fields with SQL expressions are supported; Python-only properties cannot be filtered at the database level
|
|
207
|
+
|
|
149
208
|
## Response model
|
|
150
209
|
|
|
151
210
|
```
|
|
@@ -118,6 +118,65 @@ Notes:
|
|
|
118
118
|
- Both formats are equivalent; the indexed format takes precedence if present.
|
|
119
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
120
|
|
|
121
|
+
## Filtering on Computed Fields
|
|
122
|
+
|
|
123
|
+
You can filter (and sort) on SQLAlchemy `hybrid_property` fields that have a SQL expression defined. This enables filtering on calculated or derived values at the database level.
|
|
124
|
+
|
|
125
|
+
### Defining a Computed Field
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from typing import ClassVar, Optional
|
|
129
|
+
from sqlalchemy import func
|
|
130
|
+
from sqlalchemy.ext.hybrid import hybrid_property
|
|
131
|
+
from sqlmodel import Field, SQLModel
|
|
132
|
+
|
|
133
|
+
class HeroBase(SQLModel):
|
|
134
|
+
name: str = Field(index=True)
|
|
135
|
+
secret_name: str
|
|
136
|
+
age: Optional[int] = Field(default=None)
|
|
137
|
+
full_name: ClassVar[str] # Required: declare as ClassVar for Pydantic
|
|
138
|
+
|
|
139
|
+
@hybrid_property
|
|
140
|
+
def full_name(self) -> str:
|
|
141
|
+
"""Python-level implementation (used on instances)."""
|
|
142
|
+
return f"{self.name}-{self.secret_name}"
|
|
143
|
+
|
|
144
|
+
@full_name.expression
|
|
145
|
+
def full_name(cls):
|
|
146
|
+
"""SQL-level implementation (used in queries)."""
|
|
147
|
+
return func.concat(cls.name, "-", cls.secret_name)
|
|
148
|
+
|
|
149
|
+
class Hero(HeroBase, table=True):
|
|
150
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
151
|
+
|
|
152
|
+
class HeroPublic(HeroBase):
|
|
153
|
+
id: int
|
|
154
|
+
full_name: str # Include in response model
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Querying Computed Fields
|
|
158
|
+
|
|
159
|
+
Once defined, you can filter and sort on the computed field like any regular field:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
# Filter by computed field
|
|
163
|
+
GET /heroes/?field=full_name&operator=eq&value=Spider-Man
|
|
164
|
+
GET /heroes/?field=full_name&operator=ilike&value=%man
|
|
165
|
+
GET /heroes/?field=full_name&operator=contains&value=Spider
|
|
166
|
+
|
|
167
|
+
# Sort by computed field
|
|
168
|
+
GET /heroes/?sort_by=full_name&order=asc
|
|
169
|
+
|
|
170
|
+
# Combine with other filters
|
|
171
|
+
GET /heroes/?field=full_name&operator=starts_with&value=Spider&field=age&operator=gte&value=21
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Requirements
|
|
175
|
+
|
|
176
|
+
- The `hybrid_property` must have an `.expression` decorator that returns a valid SQL expression
|
|
177
|
+
- The field should be declared as `ClassVar[type]` in the SQLModel base class to work with Pydantic
|
|
178
|
+
- Only computed fields with SQL expressions are supported; Python-only properties cannot be filtered at the database level
|
|
179
|
+
|
|
121
180
|
## Response model
|
|
122
181
|
|
|
123
182
|
```
|
|
@@ -228,10 +228,54 @@ class FSPManager:
|
|
|
228
228
|
if col_id not in self._type_cache:
|
|
229
229
|
try:
|
|
230
230
|
self._type_cache[col_id] = getattr(column.type, "python_type", None)
|
|
231
|
-
except
|
|
231
|
+
except (AttributeError, NotImplementedError):
|
|
232
|
+
# For computed fields (hybrid_property, etc.), type inference may fail
|
|
232
233
|
self._type_cache[col_id] = None
|
|
233
234
|
return self._type_cache[col_id]
|
|
234
235
|
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _get_entity_attribute(query: Select, field: str) -> Optional[ColumnElement[Any]]:
|
|
238
|
+
"""
|
|
239
|
+
Try to get a column-like attribute from the query's entity.
|
|
240
|
+
|
|
241
|
+
This enables filtering/sorting on computed fields like hybrid_property
|
|
242
|
+
that have SQL expressions defined.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
query: SQLAlchemy Select query
|
|
246
|
+
field: Name of the field/attribute to get
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Optional[ColumnElement]: The SQL expression if available, None otherwise
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
# Get the entity class from the query
|
|
253
|
+
column_descriptions = query.column_descriptions
|
|
254
|
+
if not column_descriptions:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
entity = column_descriptions[0].get("entity")
|
|
258
|
+
if entity is None:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
# Get the attribute from the entity class
|
|
262
|
+
attr = getattr(entity, field, None)
|
|
263
|
+
if attr is None:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
# Check if it's directly usable as a ColumnElement (hybrid_property with expression)
|
|
267
|
+
# When accessing a hybrid_property on the class, it returns the SQL expression
|
|
268
|
+
if isinstance(attr, ColumnElement):
|
|
269
|
+
return attr
|
|
270
|
+
|
|
271
|
+
# Some expressions may need to call __clause_element__
|
|
272
|
+
if hasattr(attr, "__clause_element__"):
|
|
273
|
+
return attr.__clause_element__()
|
|
274
|
+
|
|
275
|
+
return None
|
|
276
|
+
except Exception:
|
|
277
|
+
return None
|
|
278
|
+
|
|
235
279
|
def paginate(self, query: Select, session: Session) -> Any:
|
|
236
280
|
"""
|
|
237
281
|
Execute pagination on a query.
|
|
@@ -591,6 +635,11 @@ class FSPManager:
|
|
|
591
635
|
for f in filters:
|
|
592
636
|
# filter of `filters` has been validated in the `_parse_filters`
|
|
593
637
|
column = columns_map.get(f.field)
|
|
638
|
+
|
|
639
|
+
# Fall back to computed fields (hybrid_property, etc.) if not in columns_map
|
|
640
|
+
if column is None:
|
|
641
|
+
column = FSPManager._get_entity_attribute(query, f.field)
|
|
642
|
+
|
|
594
643
|
if column is None:
|
|
595
644
|
if self.strict_mode:
|
|
596
645
|
available = ", ".join(sorted(columns_map.keys()))
|
|
@@ -635,11 +684,10 @@ class FSPManager:
|
|
|
635
684
|
"""
|
|
636
685
|
if sorting and sorting.sort_by:
|
|
637
686
|
column = columns_map.get(sorting.sort_by)
|
|
687
|
+
|
|
688
|
+
# Fall back to computed fields (hybrid_property, etc.) if not in columns_map
|
|
638
689
|
if column is None:
|
|
639
|
-
|
|
640
|
-
column = getattr(query.column_descriptions[0]["entity"], sorting.sort_by, None)
|
|
641
|
-
except Exception:
|
|
642
|
-
pass
|
|
690
|
+
column = FSPManager._get_entity_attribute(query, sorting.sort_by)
|
|
643
691
|
|
|
644
692
|
if column is None:
|
|
645
693
|
if self.strict_mode:
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Tests for filtering on computed fields (hybrid_property, etc.)."""
|
|
2
|
+
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
from sqlmodel import Session
|
|
5
|
+
|
|
6
|
+
from tests.main import Hero
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestComputedFieldFiltering:
|
|
10
|
+
"""Test suite for filtering on computed fields like hybrid_property."""
|
|
11
|
+
|
|
12
|
+
def test_filter_hybrid_property_eq(self, session: Session, client: TestClient):
|
|
13
|
+
"""Test filtering hybrid_property with equality operator."""
|
|
14
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
15
|
+
hero_2 = Hero(name="Bat", secret_name="Man")
|
|
16
|
+
session.add(hero_1)
|
|
17
|
+
session.add(hero_2)
|
|
18
|
+
session.commit()
|
|
19
|
+
|
|
20
|
+
# Filter by full_name (hybrid_property) with exact match
|
|
21
|
+
response = client.get("/heroes/?field=full_name&operator=eq&value=Spider-Man")
|
|
22
|
+
assert response.status_code == 200
|
|
23
|
+
data = response.json()["data"]
|
|
24
|
+
assert len(data) == 1
|
|
25
|
+
assert data[0]["full_name"] == "Spider-Man"
|
|
26
|
+
assert data[0]["name"] == "Spider"
|
|
27
|
+
|
|
28
|
+
def test_filter_hybrid_property_ilike(self, session: Session, client: TestClient):
|
|
29
|
+
"""Test filtering hybrid_property with ILIKE operator."""
|
|
30
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
31
|
+
hero_2 = Hero(name="Bat", secret_name="Girl")
|
|
32
|
+
hero_3 = Hero(name="Iron", secret_name="Man")
|
|
33
|
+
session.add_all([hero_1, hero_2, hero_3])
|
|
34
|
+
session.commit()
|
|
35
|
+
|
|
36
|
+
# Filter by full_name with ILIKE pattern (case-insensitive)
|
|
37
|
+
response = client.get("/heroes/?field=full_name&operator=ilike&value=%man")
|
|
38
|
+
assert response.status_code == 200
|
|
39
|
+
data = response.json()["data"]
|
|
40
|
+
assert len(data) == 2
|
|
41
|
+
full_names = {d["full_name"] for d in data}
|
|
42
|
+
assert full_names == {"Spider-Man", "Iron-Man"}
|
|
43
|
+
|
|
44
|
+
def test_filter_hybrid_property_contains(self, session: Session, client: TestClient):
|
|
45
|
+
"""Test filtering hybrid_property with contains operator."""
|
|
46
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
47
|
+
hero_2 = Hero(name="Bat", secret_name="Woman")
|
|
48
|
+
session.add_all([hero_1, hero_2])
|
|
49
|
+
session.commit()
|
|
50
|
+
|
|
51
|
+
# Filter by full_name containing "ider"
|
|
52
|
+
response = client.get("/heroes/?field=full_name&operator=contains&value=ider")
|
|
53
|
+
assert response.status_code == 200
|
|
54
|
+
data = response.json()["data"]
|
|
55
|
+
assert len(data) == 1
|
|
56
|
+
assert data[0]["full_name"] == "Spider-Man"
|
|
57
|
+
|
|
58
|
+
def test_filter_hybrid_property_starts_with(self, session: Session, client: TestClient):
|
|
59
|
+
"""Test filtering hybrid_property with starts_with operator."""
|
|
60
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
61
|
+
hero_2 = Hero(name="Bat", secret_name="Man")
|
|
62
|
+
session.add_all([hero_1, hero_2])
|
|
63
|
+
session.commit()
|
|
64
|
+
|
|
65
|
+
# Filter by full_name starting with "Bat"
|
|
66
|
+
response = client.get("/heroes/?field=full_name&operator=starts_with&value=Bat")
|
|
67
|
+
assert response.status_code == 200
|
|
68
|
+
data = response.json()["data"]
|
|
69
|
+
assert len(data) == 1
|
|
70
|
+
assert data[0]["full_name"] == "Bat-Man"
|
|
71
|
+
|
|
72
|
+
def test_filter_hybrid_property_ends_with(self, session: Session, client: TestClient):
|
|
73
|
+
"""Test filtering hybrid_property with ends_with operator."""
|
|
74
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
75
|
+
hero_2 = Hero(name="Bat", secret_name="Woman")
|
|
76
|
+
session.add_all([hero_1, hero_2])
|
|
77
|
+
session.commit()
|
|
78
|
+
|
|
79
|
+
# Filter by full_name ending with "Woman"
|
|
80
|
+
response = client.get("/heroes/?field=full_name&operator=ends_with&value=Woman")
|
|
81
|
+
assert response.status_code == 200
|
|
82
|
+
data = response.json()["data"]
|
|
83
|
+
assert len(data) == 1
|
|
84
|
+
assert data[0]["full_name"] == "Bat-Woman"
|
|
85
|
+
|
|
86
|
+
def test_filter_hybrid_property_ne(self, session: Session, client: TestClient):
|
|
87
|
+
"""Test filtering hybrid_property with not-equals operator."""
|
|
88
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
89
|
+
hero_2 = Hero(name="Bat", secret_name="Man")
|
|
90
|
+
session.add_all([hero_1, hero_2])
|
|
91
|
+
session.commit()
|
|
92
|
+
|
|
93
|
+
# Filter by full_name not equal to "Spider-Man"
|
|
94
|
+
response = client.get("/heroes/?field=full_name&operator=ne&value=Spider-Man")
|
|
95
|
+
assert response.status_code == 200
|
|
96
|
+
data = response.json()["data"]
|
|
97
|
+
assert len(data) == 1
|
|
98
|
+
assert data[0]["full_name"] == "Bat-Man"
|
|
99
|
+
|
|
100
|
+
def test_filter_hybrid_property_in(self, session: Session, client: TestClient):
|
|
101
|
+
"""Test filtering hybrid_property with IN operator."""
|
|
102
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
103
|
+
hero_2 = Hero(name="Bat", secret_name="Man")
|
|
104
|
+
hero_3 = Hero(name="Iron", secret_name="Man")
|
|
105
|
+
session.add_all([hero_1, hero_2, hero_3])
|
|
106
|
+
session.commit()
|
|
107
|
+
|
|
108
|
+
# Filter by full_name in a list
|
|
109
|
+
response = client.get("/heroes/?field=full_name&operator=in&value=Spider-Man,Iron-Man")
|
|
110
|
+
assert response.status_code == 200
|
|
111
|
+
data = response.json()["data"]
|
|
112
|
+
assert len(data) == 2
|
|
113
|
+
full_names = {d["full_name"] for d in data}
|
|
114
|
+
assert full_names == {"Spider-Man", "Iron-Man"}
|
|
115
|
+
|
|
116
|
+
def test_filter_hybrid_property_not_in(self, session: Session, client: TestClient):
|
|
117
|
+
"""Test filtering hybrid_property with NOT IN operator."""
|
|
118
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
119
|
+
hero_2 = Hero(name="Bat", secret_name="Man")
|
|
120
|
+
hero_3 = Hero(name="Iron", secret_name="Man")
|
|
121
|
+
session.add_all([hero_1, hero_2, hero_3])
|
|
122
|
+
session.commit()
|
|
123
|
+
|
|
124
|
+
# Filter by full_name not in a list
|
|
125
|
+
response = client.get("/heroes/?field=full_name&operator=not_in&value=Spider-Man,Iron-Man")
|
|
126
|
+
assert response.status_code == 200
|
|
127
|
+
data = response.json()["data"]
|
|
128
|
+
assert len(data) == 1
|
|
129
|
+
assert data[0]["full_name"] == "Bat-Man"
|
|
130
|
+
|
|
131
|
+
def test_filter_hybrid_property_combined_with_regular_field(
|
|
132
|
+
self, session: Session, client: TestClient
|
|
133
|
+
):
|
|
134
|
+
"""Test filtering with both hybrid_property and regular fields."""
|
|
135
|
+
hero_1 = Hero(name="Spider", secret_name="Man", age=25)
|
|
136
|
+
hero_2 = Hero(name="Bat", secret_name="Man", age=35)
|
|
137
|
+
hero_3 = Hero(name="Spider", secret_name="Woman", age=28)
|
|
138
|
+
session.add_all([hero_1, hero_2, hero_3])
|
|
139
|
+
session.commit()
|
|
140
|
+
|
|
141
|
+
# Filter by hybrid_property AND regular field
|
|
142
|
+
response = client.get(
|
|
143
|
+
"/heroes/?field=full_name&operator=starts_with&value=Spider"
|
|
144
|
+
"&field=age&operator=lt&value=30"
|
|
145
|
+
)
|
|
146
|
+
assert response.status_code == 200
|
|
147
|
+
data = response.json()["data"]
|
|
148
|
+
assert len(data) == 2
|
|
149
|
+
full_names = {d["full_name"] for d in data}
|
|
150
|
+
assert full_names == {"Spider-Man", "Spider-Woman"}
|
|
151
|
+
|
|
152
|
+
def test_filter_hybrid_property_with_sort_and_pagination(
|
|
153
|
+
self, session: Session, client: TestClient
|
|
154
|
+
):
|
|
155
|
+
"""Test filtering hybrid_property combined with sorting and pagination."""
|
|
156
|
+
heroes = [
|
|
157
|
+
Hero(name="A", secret_name="Hero"),
|
|
158
|
+
Hero(name="B", secret_name="Hero"),
|
|
159
|
+
Hero(name="C", secret_name="Hero"),
|
|
160
|
+
Hero(name="D", secret_name="Villain"),
|
|
161
|
+
]
|
|
162
|
+
session.add_all(heroes)
|
|
163
|
+
session.commit()
|
|
164
|
+
|
|
165
|
+
# Filter + sort + paginate
|
|
166
|
+
response = client.get(
|
|
167
|
+
"/heroes/?field=full_name&operator=ends_with&value=Hero"
|
|
168
|
+
"&sort_by=full_name&order=desc"
|
|
169
|
+
"&page=1&per_page=2"
|
|
170
|
+
)
|
|
171
|
+
assert response.status_code == 200
|
|
172
|
+
data = response.json()
|
|
173
|
+
assert len(data["data"]) == 2
|
|
174
|
+
assert data["data"][0]["full_name"] == "C-Hero"
|
|
175
|
+
assert data["data"][1]["full_name"] == "B-Hero"
|
|
176
|
+
assert data["meta"]["pagination"]["total_items"] == 3
|
|
177
|
+
|
|
178
|
+
def test_filter_hybrid_property_indexed_format(self, session: Session, client: TestClient):
|
|
179
|
+
"""Test filtering hybrid_property with indexed filter format."""
|
|
180
|
+
hero_1 = Hero(name="Spider", secret_name="Man", age=25)
|
|
181
|
+
hero_2 = Hero(name="Bat", secret_name="Man", age=35)
|
|
182
|
+
session.add_all([hero_1, hero_2])
|
|
183
|
+
session.commit()
|
|
184
|
+
|
|
185
|
+
# Use indexed filter format
|
|
186
|
+
response = client.get(
|
|
187
|
+
"/heroes/?"
|
|
188
|
+
"filters[0][field]=full_name&filters[0][operator]=eq&filters[0][value]=Spider-Man"
|
|
189
|
+
)
|
|
190
|
+
assert response.status_code == 200
|
|
191
|
+
data = response.json()["data"]
|
|
192
|
+
assert len(data) == 1
|
|
193
|
+
assert data[0]["full_name"] == "Spider-Man"
|
|
194
|
+
|
|
195
|
+
def test_filter_unknown_field_non_strict_mode(self, session: Session, client: TestClient):
|
|
196
|
+
"""Test that unknown fields are silently skipped in non-strict mode."""
|
|
197
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
198
|
+
session.add(hero_1)
|
|
199
|
+
session.commit()
|
|
200
|
+
|
|
201
|
+
# Filter by non-existent field - should be silently skipped
|
|
202
|
+
response = client.get("/heroes/?field=nonexistent_field&operator=eq&value=test")
|
|
203
|
+
assert response.status_code == 200
|
|
204
|
+
data = response.json()["data"]
|
|
205
|
+
# All heroes should be returned since filter is skipped
|
|
206
|
+
assert len(data) == 1
|
|
207
|
+
|
|
208
|
+
def test_meta_includes_computed_field_filters(self, session: Session, client: TestClient):
|
|
209
|
+
"""Test that response meta includes the computed field filter info."""
|
|
210
|
+
hero_1 = Hero(name="Spider", secret_name="Man")
|
|
211
|
+
session.add(hero_1)
|
|
212
|
+
session.commit()
|
|
213
|
+
|
|
214
|
+
response = client.get("/heroes/?field=full_name&operator=eq&value=Spider-Man")
|
|
215
|
+
assert response.status_code == 200
|
|
216
|
+
meta = response.json()["meta"]
|
|
217
|
+
assert meta["filters"] is not None
|
|
218
|
+
assert len(meta["filters"]) == 1
|
|
219
|
+
assert meta["filters"][0]["field"] == "full_name"
|
|
220
|
+
assert meta["filters"][0]["operator"] == "eq"
|
|
221
|
+
assert meta["filters"][0]["value"] == "Spider-Man"
|
|
@@ -187,7 +187,7 @@ wheels = [
|
|
|
187
187
|
|
|
188
188
|
[[package]]
|
|
189
189
|
name = "fastapi-fsp"
|
|
190
|
-
version = "0.2.
|
|
190
|
+
version = "0.2.3"
|
|
191
191
|
source = { editable = "." }
|
|
192
192
|
dependencies = [
|
|
193
193
|
{ name = "fastapi" },
|
|
@@ -246,7 +246,6 @@ wheels = [
|
|
|
246
246
|
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
|
|
247
247
|
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
|
|
248
248
|
{ url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
|
|
249
|
-
{ url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
|
|
250
249
|
{ url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
|
|
251
250
|
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
|
|
252
251
|
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
|
|
@@ -257,7 +256,6 @@ wheels = [
|
|
|
257
256
|
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
|
258
257
|
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
|
259
258
|
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
|
|
260
|
-
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
|
|
261
259
|
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
|
|
262
260
|
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
|
263
261
|
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
|
@@ -268,7 +266,6 @@ wheels = [
|
|
|
268
266
|
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
|
269
267
|
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
|
270
268
|
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
|
|
271
|
-
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
|
272
269
|
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
|
273
270
|
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
|
274
271
|
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|