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/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 Exception:
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
- try:
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)]