fastapi-fsp 0.2.2__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 +159 -7
- fastapi_fsp/presets.py +267 -0
- fastapi_fsp-0.3.0.dist-info/METADATA +433 -0
- fastapi_fsp-0.3.0.dist-info/RECORD +10 -0
- fastapi_fsp-0.2.2.dist-info/METADATA +0 -216
- fastapi_fsp-0.2.2.dist-info/RECORD +0 -7
- {fastapi_fsp-0.2.2.dist-info → fastapi_fsp-0.3.0.dist-info}/WHEEL +0 -0
- {fastapi_fsp-0.2.2.dist-info → fastapi_fsp-0.3.0.dist-info}/licenses/LICENSE +0 -0
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,
|
|
@@ -228,10 +229,54 @@ class FSPManager:
|
|
|
228
229
|
if col_id not in self._type_cache:
|
|
229
230
|
try:
|
|
230
231
|
self._type_cache[col_id] = getattr(column.type, "python_type", None)
|
|
231
|
-
except
|
|
232
|
+
except (AttributeError, NotImplementedError):
|
|
233
|
+
# For computed fields (hybrid_property, etc.), type inference may fail
|
|
232
234
|
self._type_cache[col_id] = None
|
|
233
235
|
return self._type_cache[col_id]
|
|
234
236
|
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _get_entity_attribute(query: Select, field: str) -> Optional[ColumnElement[Any]]:
|
|
239
|
+
"""
|
|
240
|
+
Try to get a column-like attribute from the query's entity.
|
|
241
|
+
|
|
242
|
+
This enables filtering/sorting on computed fields like hybrid_property
|
|
243
|
+
that have SQL expressions defined.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
query: SQLAlchemy Select query
|
|
247
|
+
field: Name of the field/attribute to get
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Optional[ColumnElement]: The SQL expression if available, None otherwise
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
# Get the entity class from the query
|
|
254
|
+
column_descriptions = query.column_descriptions
|
|
255
|
+
if not column_descriptions:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
entity = column_descriptions[0].get("entity")
|
|
259
|
+
if entity is None:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# Get the attribute from the entity class
|
|
263
|
+
attr = getattr(entity, field, None)
|
|
264
|
+
if attr is None:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
# Check if it's directly usable as a ColumnElement (hybrid_property with expression)
|
|
268
|
+
# When accessing a hybrid_property on the class, it returns the SQL expression
|
|
269
|
+
if isinstance(attr, ColumnElement):
|
|
270
|
+
return attr
|
|
271
|
+
|
|
272
|
+
# Some expressions may need to call __clause_element__
|
|
273
|
+
if hasattr(attr, "__clause_element__"):
|
|
274
|
+
return attr.__clause_element__()
|
|
275
|
+
|
|
276
|
+
return None
|
|
277
|
+
except Exception:
|
|
278
|
+
return None
|
|
279
|
+
|
|
235
280
|
def paginate(self, query: Select, session: Session) -> Any:
|
|
236
281
|
"""
|
|
237
282
|
Execute pagination on a query.
|
|
@@ -591,6 +636,11 @@ class FSPManager:
|
|
|
591
636
|
for f in filters:
|
|
592
637
|
# filter of `filters` has been validated in the `_parse_filters`
|
|
593
638
|
column = columns_map.get(f.field)
|
|
639
|
+
|
|
640
|
+
# Fall back to computed fields (hybrid_property, etc.) if not in columns_map
|
|
641
|
+
if column is None:
|
|
642
|
+
column = FSPManager._get_entity_attribute(query, f.field)
|
|
643
|
+
|
|
594
644
|
if column is None:
|
|
595
645
|
if self.strict_mode:
|
|
596
646
|
available = ", ".join(sorted(columns_map.keys()))
|
|
@@ -635,11 +685,10 @@ class FSPManager:
|
|
|
635
685
|
"""
|
|
636
686
|
if sorting and sorting.sort_by:
|
|
637
687
|
column = columns_map.get(sorting.sort_by)
|
|
688
|
+
|
|
689
|
+
# Fall back to computed fields (hybrid_property, etc.) if not in columns_map
|
|
638
690
|
if column is None:
|
|
639
|
-
|
|
640
|
-
column = getattr(query.column_descriptions[0]["entity"], sorting.sort_by, None)
|
|
641
|
-
except Exception:
|
|
642
|
-
pass
|
|
691
|
+
column = FSPManager._get_entity_attribute(query, sorting.sort_by)
|
|
643
692
|
|
|
644
693
|
if column is None:
|
|
645
694
|
if self.strict_mode:
|
|
@@ -657,3 +706,106 @@ class FSPManager:
|
|
|
657
706
|
column.desc() if sorting.order == SortingOrder.DESC else column.asc()
|
|
658
707
|
)
|
|
659
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)]
|