fastapi-fsp 0.2.3__py3-none-any.whl → 0.3.0__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/__init__.py +34 -0
- fastapi_fsp/builder.py +339 -0
- fastapi_fsp/config.py +158 -0
- fastapi_fsp/fsp.py +106 -2
- fastapi_fsp/presets.py +267 -0
- {fastapi_fsp-0.2.3.dist-info → fastapi_fsp-0.3.0.dist-info}/METADATA +159 -1
- fastapi_fsp-0.3.0.dist-info/RECORD +10 -0
- fastapi_fsp-0.2.3.dist-info/RECORD +0 -7
- {fastapi_fsp-0.2.3.dist-info → fastapi_fsp-0.3.0.dist-info}/WHEEL +0 -0
- {fastapi_fsp-0.2.3.dist-info → fastapi_fsp-0.3.0.dist-info}/licenses/LICENSE +0 -0
fastapi_fsp/__init__.py
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
"""fastapi-fsp: Filter, Sort, and Paginate utilities for FastAPI + SQLModel."""
|
|
2
2
|
|
|
3
3
|
from . import models as models # noqa: F401
|
|
4
|
+
from .builder import FieldBuilder, FilterBuilder # noqa: F401
|
|
5
|
+
from .config import FSPConfig, FSPPresets # noqa: F401
|
|
4
6
|
from .fsp import FSPManager # noqa: F401
|
|
7
|
+
from .models import ( # noqa: F401
|
|
8
|
+
Filter,
|
|
9
|
+
FilterOperator,
|
|
10
|
+
Links,
|
|
11
|
+
Meta,
|
|
12
|
+
PaginatedResponse,
|
|
13
|
+
Pagination,
|
|
14
|
+
PaginationQuery,
|
|
15
|
+
SortingOrder,
|
|
16
|
+
SortingQuery,
|
|
17
|
+
)
|
|
18
|
+
from .presets import CommonFilters # noqa: F401
|
|
5
19
|
|
|
6
20
|
__all__ = [
|
|
21
|
+
# Main class
|
|
7
22
|
"FSPManager",
|
|
23
|
+
# Builder
|
|
24
|
+
"FilterBuilder",
|
|
25
|
+
"FieldBuilder",
|
|
26
|
+
# Configuration
|
|
27
|
+
"FSPConfig",
|
|
28
|
+
"FSPPresets",
|
|
29
|
+
# Presets
|
|
30
|
+
"CommonFilters",
|
|
31
|
+
# Models
|
|
32
|
+
"Filter",
|
|
33
|
+
"FilterOperator",
|
|
34
|
+
"SortingOrder",
|
|
35
|
+
"SortingQuery",
|
|
36
|
+
"PaginationQuery",
|
|
37
|
+
"Pagination",
|
|
38
|
+
"Meta",
|
|
39
|
+
"Links",
|
|
40
|
+
"PaginatedResponse",
|
|
41
|
+
# Module
|
|
8
42
|
"models",
|
|
9
43
|
]
|
fastapi_fsp/builder.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""FilterBuilder API for creating filters with a fluent interface."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from typing import List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from fastapi_fsp.models import Filter, FilterOperator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FieldBuilder:
|
|
10
|
+
"""
|
|
11
|
+
Builder for a single field's filter conditions.
|
|
12
|
+
|
|
13
|
+
Provides a fluent interface for building filter conditions on a specific field.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, filter_builder: "FilterBuilder", field: str):
|
|
17
|
+
"""
|
|
18
|
+
Initialize FieldBuilder.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
filter_builder: Parent FilterBuilder instance
|
|
22
|
+
field: Field name to build filters for
|
|
23
|
+
"""
|
|
24
|
+
self._filter_builder = filter_builder
|
|
25
|
+
self._field = field
|
|
26
|
+
|
|
27
|
+
def _add_filter(self, operator: FilterOperator, value: str) -> "FilterBuilder":
|
|
28
|
+
"""Add a filter and return the parent builder."""
|
|
29
|
+
self._filter_builder._filters.append(
|
|
30
|
+
Filter(field=self._field, operator=operator, value=value)
|
|
31
|
+
)
|
|
32
|
+
return self._filter_builder
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _to_str(value: Union[str, int, float, bool, date, datetime]) -> str:
|
|
36
|
+
"""Convert a value to string representation."""
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return "true" if value else "false"
|
|
39
|
+
if isinstance(value, datetime):
|
|
40
|
+
return value.isoformat()
|
|
41
|
+
if isinstance(value, date):
|
|
42
|
+
return value.isoformat()
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
def eq(self, value: Union[str, int, float, bool, date, datetime]) -> "FilterBuilder":
|
|
46
|
+
"""
|
|
47
|
+
Equal to (=).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
value: Value to compare against
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
FilterBuilder: Parent builder for chaining
|
|
54
|
+
"""
|
|
55
|
+
return self._add_filter(FilterOperator.EQ, self._to_str(value))
|
|
56
|
+
|
|
57
|
+
def ne(self, value: Union[str, int, float, bool, date, datetime]) -> "FilterBuilder":
|
|
58
|
+
"""
|
|
59
|
+
Not equal to (!=).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
value: Value to compare against
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
FilterBuilder: Parent builder for chaining
|
|
66
|
+
"""
|
|
67
|
+
return self._add_filter(FilterOperator.NE, self._to_str(value))
|
|
68
|
+
|
|
69
|
+
def gt(self, value: Union[str, int, float, date, datetime]) -> "FilterBuilder":
|
|
70
|
+
"""
|
|
71
|
+
Greater than (>).
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
value: Value to compare against
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
FilterBuilder: Parent builder for chaining
|
|
78
|
+
"""
|
|
79
|
+
return self._add_filter(FilterOperator.GT, self._to_str(value))
|
|
80
|
+
|
|
81
|
+
def gte(self, value: Union[str, int, float, date, datetime]) -> "FilterBuilder":
|
|
82
|
+
"""
|
|
83
|
+
Greater than or equal to (>=).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
value: Value to compare against
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
FilterBuilder: Parent builder for chaining
|
|
90
|
+
"""
|
|
91
|
+
return self._add_filter(FilterOperator.GTE, self._to_str(value))
|
|
92
|
+
|
|
93
|
+
def lt(self, value: Union[str, int, float, date, datetime]) -> "FilterBuilder":
|
|
94
|
+
"""
|
|
95
|
+
Less than (<).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
value: Value to compare against
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
FilterBuilder: Parent builder for chaining
|
|
102
|
+
"""
|
|
103
|
+
return self._add_filter(FilterOperator.LT, self._to_str(value))
|
|
104
|
+
|
|
105
|
+
def lte(self, value: Union[str, int, float, date, datetime]) -> "FilterBuilder":
|
|
106
|
+
"""
|
|
107
|
+
Less than or equal to (<=).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
value: Value to compare against
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
FilterBuilder: Parent builder for chaining
|
|
114
|
+
"""
|
|
115
|
+
return self._add_filter(FilterOperator.LTE, self._to_str(value))
|
|
116
|
+
|
|
117
|
+
def like(self, pattern: str) -> "FilterBuilder":
|
|
118
|
+
"""
|
|
119
|
+
Case-sensitive LIKE pattern match.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
pattern: LIKE pattern (use % for wildcards)
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
FilterBuilder: Parent builder for chaining
|
|
126
|
+
"""
|
|
127
|
+
return self._add_filter(FilterOperator.LIKE, pattern)
|
|
128
|
+
|
|
129
|
+
def not_like(self, pattern: str) -> "FilterBuilder":
|
|
130
|
+
"""
|
|
131
|
+
Case-sensitive NOT LIKE pattern match.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
pattern: LIKE pattern (use % for wildcards)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
FilterBuilder: Parent builder for chaining
|
|
138
|
+
"""
|
|
139
|
+
return self._add_filter(FilterOperator.NOT_LIKE, pattern)
|
|
140
|
+
|
|
141
|
+
def ilike(self, pattern: str) -> "FilterBuilder":
|
|
142
|
+
"""
|
|
143
|
+
Case-insensitive LIKE pattern match.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
pattern: LIKE pattern (use % for wildcards)
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
FilterBuilder: Parent builder for chaining
|
|
150
|
+
"""
|
|
151
|
+
return self._add_filter(FilterOperator.ILIKE, pattern)
|
|
152
|
+
|
|
153
|
+
def not_ilike(self, pattern: str) -> "FilterBuilder":
|
|
154
|
+
"""
|
|
155
|
+
Case-insensitive NOT LIKE pattern match.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
pattern: LIKE pattern (use % for wildcards)
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
FilterBuilder: Parent builder for chaining
|
|
162
|
+
"""
|
|
163
|
+
return self._add_filter(FilterOperator.NOT_ILIKE, pattern)
|
|
164
|
+
|
|
165
|
+
def in_(self, values: List[Union[str, int, float, bool, date, datetime]]) -> "FilterBuilder":
|
|
166
|
+
"""
|
|
167
|
+
IN list of values.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
values: List of values to match against
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
FilterBuilder: Parent builder for chaining
|
|
174
|
+
"""
|
|
175
|
+
str_values = ",".join(self._to_str(v) for v in values)
|
|
176
|
+
return self._add_filter(FilterOperator.IN, str_values)
|
|
177
|
+
|
|
178
|
+
def not_in(self, values: List[Union[str, int, float, bool, date, datetime]]) -> "FilterBuilder":
|
|
179
|
+
"""
|
|
180
|
+
NOT IN list of values.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
values: List of values to exclude
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
FilterBuilder: Parent builder for chaining
|
|
187
|
+
"""
|
|
188
|
+
str_values = ",".join(self._to_str(v) for v in values)
|
|
189
|
+
return self._add_filter(FilterOperator.NOT_IN, str_values)
|
|
190
|
+
|
|
191
|
+
def between(
|
|
192
|
+
self,
|
|
193
|
+
low: Union[str, int, float, date, datetime],
|
|
194
|
+
high: Union[str, int, float, date, datetime],
|
|
195
|
+
) -> "FilterBuilder":
|
|
196
|
+
"""
|
|
197
|
+
BETWEEN low AND high (inclusive).
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
low: Lower bound
|
|
201
|
+
high: Upper bound
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
FilterBuilder: Parent builder for chaining
|
|
205
|
+
"""
|
|
206
|
+
value = f"{self._to_str(low)},{self._to_str(high)}"
|
|
207
|
+
return self._add_filter(FilterOperator.BETWEEN, value)
|
|
208
|
+
|
|
209
|
+
def is_null(self) -> "FilterBuilder":
|
|
210
|
+
"""
|
|
211
|
+
IS NULL check.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
FilterBuilder: Parent builder for chaining
|
|
215
|
+
"""
|
|
216
|
+
return self._add_filter(FilterOperator.IS_NULL, "")
|
|
217
|
+
|
|
218
|
+
def is_not_null(self) -> "FilterBuilder":
|
|
219
|
+
"""
|
|
220
|
+
IS NOT NULL check.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
FilterBuilder: Parent builder for chaining
|
|
224
|
+
"""
|
|
225
|
+
return self._add_filter(FilterOperator.IS_NOT_NULL, "")
|
|
226
|
+
|
|
227
|
+
def starts_with(self, prefix: str) -> "FilterBuilder":
|
|
228
|
+
"""
|
|
229
|
+
Starts with prefix (case-insensitive).
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
prefix: String prefix to match
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
FilterBuilder: Parent builder for chaining
|
|
236
|
+
"""
|
|
237
|
+
return self._add_filter(FilterOperator.STARTS_WITH, prefix)
|
|
238
|
+
|
|
239
|
+
def ends_with(self, suffix: str) -> "FilterBuilder":
|
|
240
|
+
"""
|
|
241
|
+
Ends with suffix (case-insensitive).
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
suffix: String suffix to match
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
FilterBuilder: Parent builder for chaining
|
|
248
|
+
"""
|
|
249
|
+
return self._add_filter(FilterOperator.ENDS_WITH, suffix)
|
|
250
|
+
|
|
251
|
+
def contains(self, substring: str) -> "FilterBuilder":
|
|
252
|
+
"""
|
|
253
|
+
Contains substring (case-insensitive).
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
substring: String to search for
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
FilterBuilder: Parent builder for chaining
|
|
260
|
+
"""
|
|
261
|
+
return self._add_filter(FilterOperator.CONTAINS, substring)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class FilterBuilder:
|
|
265
|
+
"""
|
|
266
|
+
Fluent builder for creating filter lists.
|
|
267
|
+
|
|
268
|
+
Example usage:
|
|
269
|
+
filters = (
|
|
270
|
+
FilterBuilder()
|
|
271
|
+
.where("age").gte(30)
|
|
272
|
+
.where("city").eq("Chicago")
|
|
273
|
+
.where("deleted").eq(False)
|
|
274
|
+
.build()
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
This creates a list of Filter objects that can be used with FSPManager.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self):
|
|
281
|
+
"""Initialize an empty FilterBuilder."""
|
|
282
|
+
self._filters: List[Filter] = []
|
|
283
|
+
|
|
284
|
+
def where(self, field: str) -> FieldBuilder:
|
|
285
|
+
"""
|
|
286
|
+
Start building a filter for a field.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
field: Name of the field to filter on
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
FieldBuilder: Builder for the field's filter condition
|
|
293
|
+
"""
|
|
294
|
+
return FieldBuilder(self, field)
|
|
295
|
+
|
|
296
|
+
def add_filter(self, field: str, operator: FilterOperator, value: str) -> "FilterBuilder":
|
|
297
|
+
"""
|
|
298
|
+
Add a filter directly.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
field: Field name
|
|
302
|
+
operator: Filter operator
|
|
303
|
+
value: Filter value as string
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
FilterBuilder: Self for chaining
|
|
307
|
+
"""
|
|
308
|
+
self._filters.append(Filter(field=field, operator=operator, value=value))
|
|
309
|
+
return self
|
|
310
|
+
|
|
311
|
+
def add_filters(self, filters: List[Filter]) -> "FilterBuilder":
|
|
312
|
+
"""
|
|
313
|
+
Add multiple filters at once.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
filters: List of Filter objects to add
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
FilterBuilder: Self for chaining
|
|
320
|
+
"""
|
|
321
|
+
self._filters.extend(filters)
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def build(self) -> Optional[List[Filter]]:
|
|
325
|
+
"""
|
|
326
|
+
Build and return the list of filters.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Optional[List[Filter]]: List of filters, or None if empty
|
|
330
|
+
"""
|
|
331
|
+
return self._filters if self._filters else None
|
|
332
|
+
|
|
333
|
+
def __len__(self) -> int:
|
|
334
|
+
"""Return the number of filters."""
|
|
335
|
+
return len(self._filters)
|
|
336
|
+
|
|
337
|
+
def __bool__(self) -> bool:
|
|
338
|
+
"""Return True if there are any filters."""
|
|
339
|
+
return bool(self._filters)
|
fastapi_fsp/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Configuration classes for fastapi-fsp."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class FSPConfig:
|
|
9
|
+
"""
|
|
10
|
+
Configuration for FSP behavior.
|
|
11
|
+
|
|
12
|
+
This class centralizes all configuration options for the FSPManager,
|
|
13
|
+
making it easier to customize behavior across your application.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
max_per_page: Maximum allowed items per page (default: 100)
|
|
17
|
+
default_per_page: Default items per page when not specified (default: 10)
|
|
18
|
+
default_page: Default page number when not specified (default: 1)
|
|
19
|
+
strict_mode: If True, raise errors for unknown fields (default: False)
|
|
20
|
+
allow_deep_pagination: If True, allow pagination to any page (default: True)
|
|
21
|
+
max_page: Maximum allowed page number, None for unlimited (default: None)
|
|
22
|
+
min_per_page: Minimum allowed items per page (default: 1)
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
# Create a strict configuration
|
|
26
|
+
config = FSPConfig(
|
|
27
|
+
strict_mode=True,
|
|
28
|
+
max_per_page=50,
|
|
29
|
+
default_per_page=20
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Use with FSPManager
|
|
33
|
+
def get_fsp_config():
|
|
34
|
+
return FSPConfig(strict_mode=True)
|
|
35
|
+
|
|
36
|
+
@app.get("/items/")
|
|
37
|
+
def read_items(
|
|
38
|
+
fsp: FSPManager = Depends(FSPManager),
|
|
39
|
+
config: FSPConfig = Depends(get_fsp_config)
|
|
40
|
+
):
|
|
41
|
+
fsp.apply_config(config)
|
|
42
|
+
...
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Pagination settings
|
|
46
|
+
max_per_page: int = 100
|
|
47
|
+
default_per_page: int = 10
|
|
48
|
+
default_page: int = 1
|
|
49
|
+
min_per_page: int = 1
|
|
50
|
+
|
|
51
|
+
# Validation settings
|
|
52
|
+
strict_mode: bool = False
|
|
53
|
+
|
|
54
|
+
# Deep pagination settings
|
|
55
|
+
allow_deep_pagination: bool = True
|
|
56
|
+
max_page: Optional[int] = None
|
|
57
|
+
|
|
58
|
+
# Reserved for future features
|
|
59
|
+
_extra: dict = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def __post_init__(self):
|
|
62
|
+
"""Validate configuration values."""
|
|
63
|
+
if self.max_per_page < 1:
|
|
64
|
+
raise ValueError("max_per_page must be >= 1")
|
|
65
|
+
if self.default_per_page < 1:
|
|
66
|
+
raise ValueError("default_per_page must be >= 1")
|
|
67
|
+
if self.default_per_page > self.max_per_page:
|
|
68
|
+
raise ValueError("default_per_page cannot exceed max_per_page")
|
|
69
|
+
if self.min_per_page < 1:
|
|
70
|
+
raise ValueError("min_per_page must be >= 1")
|
|
71
|
+
if self.min_per_page > self.max_per_page:
|
|
72
|
+
raise ValueError("min_per_page cannot exceed max_per_page")
|
|
73
|
+
if self.default_page < 1:
|
|
74
|
+
raise ValueError("default_page must be >= 1")
|
|
75
|
+
if self.max_page is not None and self.max_page < 1:
|
|
76
|
+
raise ValueError("max_page must be >= 1 or None")
|
|
77
|
+
|
|
78
|
+
def validate_page(self, page: int) -> int:
|
|
79
|
+
"""
|
|
80
|
+
Validate and constrain a page number.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
page: Requested page number
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
int: Valid page number
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If page exceeds max_page and allow_deep_pagination is False
|
|
90
|
+
"""
|
|
91
|
+
if page < 1:
|
|
92
|
+
return self.default_page
|
|
93
|
+
|
|
94
|
+
if not self.allow_deep_pagination and self.max_page is not None:
|
|
95
|
+
if page > self.max_page:
|
|
96
|
+
raise ValueError(f"Page {page} exceeds maximum allowed page {self.max_page}")
|
|
97
|
+
|
|
98
|
+
return page
|
|
99
|
+
|
|
100
|
+
def validate_per_page(self, per_page: int) -> int:
|
|
101
|
+
"""
|
|
102
|
+
Validate and constrain items per page.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
per_page: Requested items per page
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
int: Valid per_page value, constrained to min/max bounds
|
|
109
|
+
"""
|
|
110
|
+
if per_page < self.min_per_page:
|
|
111
|
+
return self.min_per_page
|
|
112
|
+
if per_page > self.max_per_page:
|
|
113
|
+
return self.max_per_page
|
|
114
|
+
return per_page
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Pre-defined configurations for common use cases
|
|
118
|
+
class FSPPresets:
|
|
119
|
+
"""Pre-defined FSPConfig presets for common use cases."""
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def default() -> FSPConfig:
|
|
123
|
+
"""Default configuration with sensible defaults."""
|
|
124
|
+
return FSPConfig()
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def strict() -> FSPConfig:
|
|
128
|
+
"""Strict mode configuration - raises errors for unknown fields."""
|
|
129
|
+
return FSPConfig(strict_mode=True)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def limited_pagination(max_page: int = 100, max_per_page: int = 50) -> FSPConfig:
|
|
133
|
+
"""
|
|
134
|
+
Configuration that limits deep pagination.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
max_page: Maximum allowed page number
|
|
138
|
+
max_per_page: Maximum items per page
|
|
139
|
+
"""
|
|
140
|
+
return FSPConfig(
|
|
141
|
+
max_page=max_page,
|
|
142
|
+
max_per_page=max_per_page,
|
|
143
|
+
allow_deep_pagination=False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def high_volume(max_per_page: int = 500, default_per_page: int = 100) -> FSPConfig:
|
|
148
|
+
"""
|
|
149
|
+
Configuration for high-volume APIs.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
max_per_page: Maximum items per page
|
|
153
|
+
default_per_page: Default items per page
|
|
154
|
+
"""
|
|
155
|
+
return FSPConfig(
|
|
156
|
+
max_per_page=max_per_page,
|
|
157
|
+
default_per_page=default_per_page,
|
|
158
|
+
)
|
fastapi_fsp/fsp.py
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from math import ceil
|
|
5
|
-
from typing import Annotated, Any, List, Optional
|
|
5
|
+
from typing import Annotated, Any, List, Optional, Type
|
|
6
6
|
|
|
7
7
|
from dateutil.parser import parse
|
|
8
8
|
from fastapi import Depends, HTTPException, Query, Request, status
|
|
9
9
|
from pydantic import ValidationError
|
|
10
10
|
from sqlalchemy import ColumnCollection, ColumnElement, Select, func
|
|
11
|
-
from sqlmodel import Session, not_, select
|
|
11
|
+
from sqlmodel import Session, SQLModel, not_, select
|
|
12
12
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
13
13
|
|
|
14
|
+
from fastapi_fsp.config import FSPConfig
|
|
14
15
|
from fastapi_fsp.models import (
|
|
15
16
|
Filter,
|
|
16
17
|
FilterOperator,
|
|
@@ -705,3 +706,106 @@ class FSPManager:
|
|
|
705
706
|
column.desc() if sorting.order == SortingOrder.DESC else column.asc()
|
|
706
707
|
)
|
|
707
708
|
return query
|
|
709
|
+
|
|
710
|
+
def apply_config(self, config: FSPConfig) -> "FSPManager":
|
|
711
|
+
"""
|
|
712
|
+
Apply a configuration to this FSPManager instance.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
config: FSPConfig instance with settings
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
FSPManager: Self for chaining
|
|
719
|
+
"""
|
|
720
|
+
self.strict_mode = config.strict_mode
|
|
721
|
+
# Validate and constrain pagination values
|
|
722
|
+
self.pagination.page = config.validate_page(self.pagination.page)
|
|
723
|
+
self.pagination.per_page = config.validate_per_page(self.pagination.per_page)
|
|
724
|
+
return self
|
|
725
|
+
|
|
726
|
+
def from_model(
|
|
727
|
+
self,
|
|
728
|
+
model: Type[SQLModel],
|
|
729
|
+
session: Session,
|
|
730
|
+
) -> PaginatedResponse[Any]:
|
|
731
|
+
"""
|
|
732
|
+
Convenience method to query directly from a model.
|
|
733
|
+
|
|
734
|
+
This simplifies the common pattern of selecting all from a model.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
model: SQLModel class to query
|
|
738
|
+
session: Database session
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
PaginatedResponse: Complete paginated response
|
|
742
|
+
|
|
743
|
+
Example:
|
|
744
|
+
@app.get("/heroes/")
|
|
745
|
+
def read_heroes(
|
|
746
|
+
session: Session = Depends(get_session),
|
|
747
|
+
fsp: FSPManager = Depends(FSPManager)
|
|
748
|
+
):
|
|
749
|
+
return fsp.from_model(Hero, session)
|
|
750
|
+
"""
|
|
751
|
+
query = select(model)
|
|
752
|
+
return self.generate_response(query, session)
|
|
753
|
+
|
|
754
|
+
async def from_model_async(
|
|
755
|
+
self,
|
|
756
|
+
model: Type[SQLModel],
|
|
757
|
+
session: AsyncSession,
|
|
758
|
+
) -> PaginatedResponse[Any]:
|
|
759
|
+
"""
|
|
760
|
+
Convenience method to query directly from a model (async version).
|
|
761
|
+
|
|
762
|
+
This simplifies the common pattern of selecting all from a model.
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
model: SQLModel class to query
|
|
766
|
+
session: Async database session
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
PaginatedResponse: Complete paginated response
|
|
770
|
+
|
|
771
|
+
Example:
|
|
772
|
+
@app.get("/heroes/")
|
|
773
|
+
async def read_heroes(
|
|
774
|
+
session: AsyncSession = Depends(get_session),
|
|
775
|
+
fsp: FSPManager = Depends(FSPManager)
|
|
776
|
+
):
|
|
777
|
+
return await fsp.from_model_async(Hero, session)
|
|
778
|
+
"""
|
|
779
|
+
query = select(model)
|
|
780
|
+
return await self.generate_response_async(query, session)
|
|
781
|
+
|
|
782
|
+
def with_filters(self, filters: Optional[List[Filter]]) -> "FSPManager":
|
|
783
|
+
"""
|
|
784
|
+
Set or override filters.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
filters: List of filters to apply
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
FSPManager: Self for chaining
|
|
791
|
+
"""
|
|
792
|
+
if filters:
|
|
793
|
+
if self.filters:
|
|
794
|
+
self.filters.extend(filters)
|
|
795
|
+
else:
|
|
796
|
+
self.filters = filters
|
|
797
|
+
return self
|
|
798
|
+
|
|
799
|
+
def with_sorting(self, sorting: Optional[SortingQuery]) -> "FSPManager":
|
|
800
|
+
"""
|
|
801
|
+
Set or override sorting.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
sorting: Sorting configuration
|
|
805
|
+
|
|
806
|
+
Returns:
|
|
807
|
+
FSPManager: Self for chaining
|
|
808
|
+
"""
|
|
809
|
+
if sorting:
|
|
810
|
+
self.sorting = sorting
|
|
811
|
+
return self
|
fastapi_fsp/presets.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Common filter presets for frequently used query patterns."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from fastapi_fsp.models import Filter, FilterOperator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommonFilters:
|
|
10
|
+
"""
|
|
11
|
+
Pre-defined filter presets for common query patterns.
|
|
12
|
+
|
|
13
|
+
These presets help reduce boilerplate for frequently used filter combinations.
|
|
14
|
+
|
|
15
|
+
Example usage:
|
|
16
|
+
from fastapi_fsp.presets import CommonFilters
|
|
17
|
+
|
|
18
|
+
# Get active (non-deleted) records
|
|
19
|
+
filters = CommonFilters.active()
|
|
20
|
+
|
|
21
|
+
# Get records from last 7 days
|
|
22
|
+
filters = CommonFilters.recent(days=7)
|
|
23
|
+
|
|
24
|
+
# Combine presets
|
|
25
|
+
filters = CommonFilters.active() + CommonFilters.recent(days=30)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def active(deleted_field: str = "deleted") -> List[Filter]:
|
|
30
|
+
"""
|
|
31
|
+
Filter for active (non-deleted) records.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
deleted_field: Name of the boolean deleted field (default: "deleted")
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List[Filter]: Filters for non-deleted records
|
|
38
|
+
"""
|
|
39
|
+
return [Filter(field=deleted_field, operator=FilterOperator.EQ, value="false")]
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def deleted(deleted_field: str = "deleted") -> List[Filter]:
|
|
43
|
+
"""
|
|
44
|
+
Filter for deleted records only.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
deleted_field: Name of the boolean deleted field (default: "deleted")
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List[Filter]: Filters for deleted records
|
|
51
|
+
"""
|
|
52
|
+
return [Filter(field=deleted_field, operator=FilterOperator.EQ, value="true")]
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def recent(
|
|
56
|
+
date_field: str = "created_at",
|
|
57
|
+
days: int = 30,
|
|
58
|
+
reference_time: datetime = None,
|
|
59
|
+
) -> List[Filter]:
|
|
60
|
+
"""
|
|
61
|
+
Filter for records created in the last N days.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
date_field: Name of the datetime field (default: "created_at")
|
|
65
|
+
days: Number of days to look back (default: 30)
|
|
66
|
+
reference_time: Reference time for calculation (default: now)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List[Filter]: Filters for recent records
|
|
70
|
+
"""
|
|
71
|
+
if reference_time is None:
|
|
72
|
+
reference_time = datetime.now()
|
|
73
|
+
cutoff = (reference_time - timedelta(days=days)).isoformat()
|
|
74
|
+
return [Filter(field=date_field, operator=FilterOperator.GTE, value=cutoff)]
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def older_than(
|
|
78
|
+
date_field: str = "created_at",
|
|
79
|
+
days: int = 30,
|
|
80
|
+
reference_time: datetime = None,
|
|
81
|
+
) -> List[Filter]:
|
|
82
|
+
"""
|
|
83
|
+
Filter for records created more than N days ago.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
date_field: Name of the datetime field (default: "created_at")
|
|
87
|
+
days: Number of days threshold (default: 30)
|
|
88
|
+
reference_time: Reference time for calculation (default: now)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List[Filter]: Filters for older records
|
|
92
|
+
"""
|
|
93
|
+
if reference_time is None:
|
|
94
|
+
reference_time = datetime.now()
|
|
95
|
+
cutoff = (reference_time - timedelta(days=days)).isoformat()
|
|
96
|
+
return [Filter(field=date_field, operator=FilterOperator.LT, value=cutoff)]
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def date_range(
|
|
100
|
+
date_field: str = "created_at",
|
|
101
|
+
start: datetime = None,
|
|
102
|
+
end: datetime = None,
|
|
103
|
+
) -> List[Filter]:
|
|
104
|
+
"""
|
|
105
|
+
Filter for records within a date range.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
date_field: Name of the datetime field (default: "created_at")
|
|
109
|
+
start: Start of date range (inclusive)
|
|
110
|
+
end: End of date range (inclusive)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List[Filter]: Filters for date range
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ValueError: If neither start nor end is provided
|
|
117
|
+
"""
|
|
118
|
+
if start is None and end is None:
|
|
119
|
+
raise ValueError("At least one of start or end must be provided")
|
|
120
|
+
|
|
121
|
+
filters = []
|
|
122
|
+
if start is not None:
|
|
123
|
+
filters.append(
|
|
124
|
+
Filter(field=date_field, operator=FilterOperator.GTE, value=start.isoformat())
|
|
125
|
+
)
|
|
126
|
+
if end is not None:
|
|
127
|
+
filters.append(
|
|
128
|
+
Filter(field=date_field, operator=FilterOperator.LTE, value=end.isoformat())
|
|
129
|
+
)
|
|
130
|
+
return filters
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def today(date_field: str = "created_at", reference_time: datetime = None) -> List[Filter]:
|
|
134
|
+
"""
|
|
135
|
+
Filter for records created today.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
date_field: Name of the datetime field (default: "created_at")
|
|
139
|
+
reference_time: Reference time for calculation (default: now)
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List[Filter]: Filters for today's records
|
|
143
|
+
"""
|
|
144
|
+
if reference_time is None:
|
|
145
|
+
reference_time = datetime.now()
|
|
146
|
+
start_of_day = reference_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
147
|
+
end_of_day = reference_time.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
148
|
+
return [
|
|
149
|
+
Filter(
|
|
150
|
+
field=date_field,
|
|
151
|
+
operator=FilterOperator.BETWEEN,
|
|
152
|
+
value=f"{start_of_day.isoformat()},{end_of_day.isoformat()}",
|
|
153
|
+
)
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def not_null(field: str) -> List[Filter]:
|
|
158
|
+
"""
|
|
159
|
+
Filter for records where field is not null.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
field: Name of the field to check
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List[Filter]: Filter for non-null values
|
|
166
|
+
"""
|
|
167
|
+
return [Filter(field=field, operator=FilterOperator.IS_NOT_NULL, value="")]
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def is_null(field: str) -> List[Filter]:
|
|
171
|
+
"""
|
|
172
|
+
Filter for records where field is null.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
field: Name of the field to check
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List[Filter]: Filter for null values
|
|
179
|
+
"""
|
|
180
|
+
return [Filter(field=field, operator=FilterOperator.IS_NULL, value="")]
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def enabled(enabled_field: str = "enabled") -> List[Filter]:
|
|
184
|
+
"""
|
|
185
|
+
Filter for enabled records.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
enabled_field: Name of the boolean enabled field (default: "enabled")
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List[Filter]: Filters for enabled records
|
|
192
|
+
"""
|
|
193
|
+
return [Filter(field=enabled_field, operator=FilterOperator.EQ, value="true")]
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def disabled(enabled_field: str = "enabled") -> List[Filter]:
|
|
197
|
+
"""
|
|
198
|
+
Filter for disabled records.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
enabled_field: Name of the boolean enabled field (default: "enabled")
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List[Filter]: Filters for disabled records
|
|
205
|
+
"""
|
|
206
|
+
return [Filter(field=enabled_field, operator=FilterOperator.EQ, value="false")]
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def search(
|
|
210
|
+
field: str,
|
|
211
|
+
term: str,
|
|
212
|
+
match_type: str = "contains",
|
|
213
|
+
) -> List[Filter]:
|
|
214
|
+
"""
|
|
215
|
+
Create a search filter for text matching.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
field: Name of the field to search
|
|
219
|
+
term: Search term
|
|
220
|
+
match_type: Type of match - "contains", "starts_with", "ends_with" (default: "contains")
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
List[Filter]: Search filter
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: If match_type is invalid
|
|
227
|
+
"""
|
|
228
|
+
operator_map = {
|
|
229
|
+
"contains": FilterOperator.CONTAINS,
|
|
230
|
+
"starts_with": FilterOperator.STARTS_WITH,
|
|
231
|
+
"ends_with": FilterOperator.ENDS_WITH,
|
|
232
|
+
}
|
|
233
|
+
operator = operator_map.get(match_type)
|
|
234
|
+
if operator is None:
|
|
235
|
+
valid_types = "contains, starts_with, ends_with"
|
|
236
|
+
raise ValueError(f"Invalid match_type: {match_type}. Use: {valid_types}")
|
|
237
|
+
return [Filter(field=field, operator=operator, value=term)]
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def in_values(field: str, values: List) -> List[Filter]:
|
|
241
|
+
"""
|
|
242
|
+
Filter for records where field is in a list of values.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
field: Name of the field to filter
|
|
246
|
+
values: List of values to match
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
List[Filter]: IN filter
|
|
250
|
+
"""
|
|
251
|
+
str_values = ",".join(str(v) for v in values)
|
|
252
|
+
return [Filter(field=field, operator=FilterOperator.IN, value=str_values)]
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def not_in_values(field: str, values: List) -> List[Filter]:
|
|
256
|
+
"""
|
|
257
|
+
Filter for records where field is not in a list of values.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
field: Name of the field to filter
|
|
261
|
+
values: List of values to exclude
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
List[Filter]: NOT IN filter
|
|
265
|
+
"""
|
|
266
|
+
str_values = ",".join(str(v) for v in values)
|
|
267
|
+
return [Filter(field=field, operator=FilterOperator.NOT_IN, value=str_values)]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fsp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -205,6 +205,164 @@ GET /heroes/?field=full_name&operator=starts_with&value=Spider&field=age&operato
|
|
|
205
205
|
- The field should be declared as `ClassVar[type]` in the SQLModel base class to work with Pydantic
|
|
206
206
|
- Only computed fields with SQL expressions are supported; Python-only properties cannot be filtered at the database level
|
|
207
207
|
|
|
208
|
+
## FilterBuilder API
|
|
209
|
+
|
|
210
|
+
For programmatic filter creation, use the fluent `FilterBuilder` API:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from fastapi_fsp import FilterBuilder
|
|
214
|
+
|
|
215
|
+
# Instead of manually creating Filter objects:
|
|
216
|
+
# filters = [
|
|
217
|
+
# Filter(field="age", operator=FilterOperator.GTE, value="30"),
|
|
218
|
+
# Filter(field="city", operator=FilterOperator.EQ, value="Chicago"),
|
|
219
|
+
# ]
|
|
220
|
+
|
|
221
|
+
# Use the builder pattern:
|
|
222
|
+
filters = (
|
|
223
|
+
FilterBuilder()
|
|
224
|
+
.where("age").gte(30)
|
|
225
|
+
.where("city").eq("Chicago")
|
|
226
|
+
.where("active").eq(True)
|
|
227
|
+
.where("tags").in_(["python", "fastapi"])
|
|
228
|
+
.where("created_at").between("2024-01-01", "2024-12-31")
|
|
229
|
+
.build()
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Use with FSPManager
|
|
233
|
+
@app.get("/heroes/")
|
|
234
|
+
def read_heroes(session: Session = Depends(get_session), fsp: FSPManager = Depends(FSPManager)):
|
|
235
|
+
additional_filters = FilterBuilder().where("deleted").eq(False).build()
|
|
236
|
+
fsp.with_filters(additional_filters)
|
|
237
|
+
return fsp.generate_response(select(Hero), session)
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Available FilterBuilder Methods
|
|
241
|
+
|
|
242
|
+
| Method | Description |
|
|
243
|
+
|--------|-------------|
|
|
244
|
+
| `.eq(value)` | Equal to |
|
|
245
|
+
| `.ne(value)` | Not equal to |
|
|
246
|
+
| `.gt(value)` | Greater than |
|
|
247
|
+
| `.gte(value)` | Greater than or equal |
|
|
248
|
+
| `.lt(value)` | Less than |
|
|
249
|
+
| `.lte(value)` | Less than or equal |
|
|
250
|
+
| `.like(pattern)` | Case-sensitive LIKE |
|
|
251
|
+
| `.ilike(pattern)` | Case-insensitive LIKE |
|
|
252
|
+
| `.in_(values)` | IN list |
|
|
253
|
+
| `.not_in(values)` | NOT IN list |
|
|
254
|
+
| `.between(low, high)` | BETWEEN range |
|
|
255
|
+
| `.is_null()` | IS NULL |
|
|
256
|
+
| `.is_not_null()` | IS NOT NULL |
|
|
257
|
+
| `.starts_with(prefix)` | Starts with (case-insensitive) |
|
|
258
|
+
| `.ends_with(suffix)` | Ends with (case-insensitive) |
|
|
259
|
+
| `.contains(substring)` | Contains (case-insensitive) |
|
|
260
|
+
|
|
261
|
+
## Common Filter Presets
|
|
262
|
+
|
|
263
|
+
For frequently used filter patterns, use `CommonFilters`:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from fastapi_fsp import CommonFilters
|
|
267
|
+
|
|
268
|
+
# Active (non-deleted) records
|
|
269
|
+
filters = CommonFilters.active() # deleted=false
|
|
270
|
+
|
|
271
|
+
# Recent records (last 7 days)
|
|
272
|
+
filters = CommonFilters.recent(days=7)
|
|
273
|
+
|
|
274
|
+
# Date range
|
|
275
|
+
filters = CommonFilters.date_range(start=datetime(2024, 1, 1), end=datetime(2024, 12, 31))
|
|
276
|
+
|
|
277
|
+
# Records created today
|
|
278
|
+
filters = CommonFilters.today()
|
|
279
|
+
|
|
280
|
+
# Null checks
|
|
281
|
+
filters = CommonFilters.not_null("email")
|
|
282
|
+
filters = CommonFilters.is_null("deleted_at")
|
|
283
|
+
|
|
284
|
+
# Search
|
|
285
|
+
filters = CommonFilters.search("name", "john", match_type="contains")
|
|
286
|
+
|
|
287
|
+
# Combine presets
|
|
288
|
+
filters = CommonFilters.active() + CommonFilters.recent(days=30)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Configuration
|
|
292
|
+
|
|
293
|
+
Customize FSPManager behavior with `FSPConfig`:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from fastapi_fsp import FSPConfig, FSPPresets
|
|
297
|
+
|
|
298
|
+
# Custom configuration
|
|
299
|
+
config = FSPConfig(
|
|
300
|
+
max_per_page=50,
|
|
301
|
+
default_per_page=20,
|
|
302
|
+
strict_mode=True, # Raise errors for unknown fields
|
|
303
|
+
max_page=100,
|
|
304
|
+
allow_deep_pagination=False,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Or use presets
|
|
308
|
+
config = FSPPresets.strict() # strict_mode=True
|
|
309
|
+
config = FSPPresets.limited_pagination(max_page=50) # Limit deep pagination
|
|
310
|
+
config = FSPPresets.high_volume(max_per_page=500) # High-volume APIs
|
|
311
|
+
|
|
312
|
+
# Apply configuration
|
|
313
|
+
@app.get("/heroes/")
|
|
314
|
+
def read_heroes(session: Session = Depends(get_session), fsp: FSPManager = Depends(FSPManager)):
|
|
315
|
+
fsp.apply_config(config)
|
|
316
|
+
return fsp.generate_response(select(Hero), session)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Strict Mode
|
|
320
|
+
|
|
321
|
+
When `strict_mode=True`, FSPManager raises HTTP 400 errors for unknown filter/sort fields:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# With strict_mode=True, this raises HTTP 400:
|
|
325
|
+
# GET /heroes/?field=unknown_field&operator=eq&value=test
|
|
326
|
+
# Error: "Unknown field 'unknown_field'. Available fields: age, id, name, secret_name"
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Convenience Methods
|
|
330
|
+
|
|
331
|
+
### from_model()
|
|
332
|
+
|
|
333
|
+
Simplify common queries with `from_model()`:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
@app.get("/heroes/")
|
|
337
|
+
def read_heroes(session: Session = Depends(get_session), fsp: FSPManager = Depends(FSPManager)):
|
|
338
|
+
# Instead of:
|
|
339
|
+
# query = select(Hero)
|
|
340
|
+
# return fsp.generate_response(query, session)
|
|
341
|
+
|
|
342
|
+
# Use:
|
|
343
|
+
return fsp.from_model(Hero, session)
|
|
344
|
+
|
|
345
|
+
# Async version
|
|
346
|
+
@app.get("/heroes/")
|
|
347
|
+
async def read_heroes(session: AsyncSession = Depends(get_session), fsp: FSPManager = Depends(FSPManager)):
|
|
348
|
+
return await fsp.from_model_async(Hero, session)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Method Chaining
|
|
352
|
+
|
|
353
|
+
Chain configuration methods:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
@app.get("/heroes/")
|
|
357
|
+
def read_heroes(session: Session = Depends(get_session), fsp: FSPManager = Depends(FSPManager)):
|
|
358
|
+
return (
|
|
359
|
+
fsp
|
|
360
|
+
.with_filters(CommonFilters.active())
|
|
361
|
+
.apply_config(FSPPresets.strict())
|
|
362
|
+
.generate_response(select(Hero), session)
|
|
363
|
+
)
|
|
364
|
+
```
|
|
365
|
+
|
|
208
366
|
## Response model
|
|
209
367
|
|
|
210
368
|
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fastapi_fsp/__init__.py,sha256=vQtASadi_DWCs14VpPO60upWbJxqxSotA7thQ7dgYb0,925
|
|
2
|
+
fastapi_fsp/builder.py,sha256=due8kTVApNXWZJGVDhqe6Ch9jCqdjnNe0rvWNUOgVMw,9660
|
|
3
|
+
fastapi_fsp/config.py,sha256=GcrSyv_wOvNgLFq00fhsZznZH4Fnl0dCBJa73n2QwIs,4977
|
|
4
|
+
fastapi_fsp/fsp.py,sha256=WNcarSmffEDs86VE7xerFC1-JC89mqusM_fx1k4v3P8,27022
|
|
5
|
+
fastapi_fsp/models.py,sha256=1MwLBQFmUP8OwO3Gqby1u7s9ruimCR2XGfUzAqF4Tj4,2034
|
|
6
|
+
fastapi_fsp/presets.py,sha256=hpfUmCaeqoCeb1PimpUoGEW5S0Ycc-yBEZQq6vJWv50,8500
|
|
7
|
+
fastapi_fsp-0.3.0.dist-info/METADATA,sha256=tGLU0lGCCYPVBGV55c8I8gjf66pdLInb_wSKG9jBlq4,12610
|
|
8
|
+
fastapi_fsp-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
fastapi_fsp-0.3.0.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
10
|
+
fastapi_fsp-0.3.0.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
fastapi_fsp/__init__.py,sha256=0I_XN_ptNu1NyNRpjdyVm6nwvrilAB8FyT2EfgVF_QA,215
|
|
2
|
-
fastapi_fsp/fsp.py,sha256=b6OuHgbTpWrFtxGKm7fcPpjHtD3Cdjrs22m3IYOBhp4,23986
|
|
3
|
-
fastapi_fsp/models.py,sha256=1MwLBQFmUP8OwO3Gqby1u7s9ruimCR2XGfUzAqF4Tj4,2034
|
|
4
|
-
fastapi_fsp-0.2.3.dist-info/METADATA,sha256=w4wfXs3hye-UI0-h2a81OjkG3yzzZWUq91n1HhmyUi4,8224
|
|
5
|
-
fastapi_fsp-0.2.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
-
fastapi_fsp-0.2.3.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
7
|
-
fastapi_fsp-0.2.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|