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 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.2.3
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,,