fastapi-basekit 0.1.23__tar.gz → 0.1.25__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/PKG-INFO +1 -1
  2. fastapi_basekit-0.1.25/fastapi_basekit/aio/beanie/repository/base.py +360 -0
  3. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/service/base.py +56 -7
  4. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/controller/base.py +6 -1
  5. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -1
  6. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/repository/base.py +107 -104
  7. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/service/base.py +60 -39
  8. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/PKG-INFO +1 -1
  9. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/pyproject.toml +1 -1
  10. fastapi_basekit-0.1.23/fastapi_basekit/aio/beanie/repository/base.py +0 -168
  11. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/LICENSE +0 -0
  12. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/README.md +0 -0
  13. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/__init__.py +0 -0
  14. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/__init__.py +0 -0
  15. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  16. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  17. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
  18. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  19. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  20. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/controller/__init__.py +0 -0
  21. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  22. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/permissions/base.py +0 -0
  23. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  24. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  25. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  26. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  27. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/__init__.py +0 -0
  28. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  29. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/handler.py +0 -0
  30. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/__init__.py +0 -0
  31. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/base.py +0 -0
  32. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/jwt.py +0 -0
  33. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/schema.py +0 -0
  34. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/__init__.py +0 -0
  35. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  36. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  37. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/SOURCES.txt +0 -0
  38. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  39. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/requires.txt +0 -0
  40. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/top_level.txt +0 -0
  41. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/setup.cfg +0 -0
  42. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_api_exceptions.py +0 -0
  43. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_base_response.py +0 -0
  44. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_base_service.py +0 -0
  45. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_controller_auto_permissions.py +0 -0
  46. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_crud_beanie_controller.py +0 -0
  47. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_crud_controller.py +0 -0
  48. {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_jwt_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie or SQLAlchemy)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -0,0 +1,360 @@
1
+ from typing import Any, Dict, List, Optional, Type, Union
2
+ from typing import get_args, get_origin
3
+ import re
4
+
5
+ from bson import ObjectId, Link
6
+ from pydantic import BaseModel
7
+ from beanie import Document
8
+ from beanie.odm.queries.find import FindMany
9
+ from beanie.operators import Or, RegEx
10
+
11
+
12
+ class BaseRepository:
13
+ model: Type[Document]
14
+
15
+ def _parse_order_field(self, order_by: str) -> tuple[str, int, bool]:
16
+ """Parse order_by string into components.
17
+
18
+ Args:
19
+ order_by: Order string, e.g., "-created_at" or "tool__name"
20
+
21
+ Returns:
22
+ tuple: (field_path, direction, is_nested)
23
+ - field_path: "tool.name" or "created_at"
24
+ - direction: 1 (asc) or -1 (desc)
25
+ - is_nested: True if contains "__"
26
+ """
27
+ # Check for descending prefix
28
+ direction = -1 if order_by.startswith("-") else 1
29
+ field = order_by.lstrip("-")
30
+
31
+ # Check if nested (contains __ or .)
32
+ is_nested = "__" in field or "." in field
33
+
34
+ # Convert __ to . for MongoDB field path (normalize)
35
+ field_path = field.replace("__", ".")
36
+
37
+ return field_path, direction, is_nested
38
+
39
+ def _get_collection_name_from_field(self, field_name: str) -> Optional[str]:
40
+ """Get the collection name for a Link field.
41
+
42
+ Args:
43
+ field_name: Name of the field in the model
44
+
45
+ Returns:
46
+ Collection name or None if not a Link field
47
+ """
48
+ if not hasattr(self.model, "model_fields"):
49
+ return None
50
+
51
+ model_fields = self.model.model_fields
52
+ field_info = model_fields.get(field_name)
53
+
54
+ if not field_info:
55
+ return None
56
+
57
+ field_type = field_info.annotation
58
+ origin = get_origin(field_type)
59
+
60
+ # Handle Link[Model]
61
+ if origin is Link:
62
+ args = get_args(field_type)
63
+ if args:
64
+ linked_model = args[0]
65
+ if hasattr(linked_model, "Settings") and hasattr(linked_model.Settings, "name"):
66
+ return linked_model.Settings.name
67
+
68
+ # Handle Optional[Link[Model]]
69
+ if origin is Union:
70
+ args = get_args(field_type)
71
+ for arg in args:
72
+ arg_origin = get_origin(arg)
73
+ if arg_origin is Link:
74
+ link_args = get_args(arg)
75
+ if link_args:
76
+ linked_model = link_args[0]
77
+ if hasattr(linked_model, "Settings") and hasattr(linked_model.Settings, "name"):
78
+ return linked_model.Settings.name
79
+
80
+ return None
81
+
82
+ def _get_query_kwargs(
83
+ self,
84
+ fetch_links: bool = False,
85
+ nesting_depths_per_field: Optional[Dict[str, int]] = None,
86
+ projection: Optional[Union[List[str], Type[BaseModel]]] = None,
87
+ ):
88
+ kwargs = {
89
+ "fetch_links": fetch_links,
90
+ "nesting_depths_per_field": (
91
+ nesting_depths_per_field if fetch_links else None
92
+ ),
93
+ }
94
+ if projection is not None:
95
+ kwargs["projection"] = projection
96
+ return kwargs
97
+
98
+ def build_filter_query(
99
+ self,
100
+ search: Optional[str],
101
+ search_fields: List[str],
102
+ filters: dict = None,
103
+ order_by: Optional[List[tuple]] = None,
104
+ **kwargs,
105
+ ) -> FindMany[Document]:
106
+ """Versión personalizada que soporta campos Link."""
107
+ exprs = []
108
+
109
+ if search and search_fields:
110
+ exprs.append(
111
+ Or(
112
+ *[
113
+ RegEx(
114
+ getattr(self.model, f),
115
+ f".*{search}.*",
116
+ options="i",
117
+ )
118
+ for f in search_fields
119
+ ]
120
+ )
121
+ )
122
+
123
+ # Obtener campos del modelo
124
+ model_fields = (
125
+ self.model.model_fields
126
+ if hasattr(self.model, "model_fields")
127
+ else {}
128
+ )
129
+
130
+ def _is_link_field(field_name: str) -> bool:
131
+ """Verifica si un campo es de tipo Link."""
132
+ field_info = model_fields.get(field_name)
133
+ if not field_info:
134
+ return False
135
+
136
+ field_type = field_info.annotation
137
+ origin = get_origin(field_type)
138
+
139
+ # Caso directo: Link[Model]
140
+ if origin is Link:
141
+ return True
142
+
143
+ # Caso Optional[Link[Model]] = Union[Link[Model], None]
144
+ # O cualquier Union que contenga Link
145
+ if origin is not None:
146
+ args = get_args(field_type)
147
+ for arg in args:
148
+ # Verificar si el argumento es Link
149
+ arg_origin = get_origin(arg)
150
+ if arg_origin is Link:
151
+ return True
152
+
153
+ return False
154
+
155
+ for k, v in (filters or {}).items():
156
+ if hasattr(self.model, k):
157
+ field_attr = getattr(self.model, k)
158
+
159
+ if _is_link_field(k):
160
+ exprs.append(field_attr.id == v)
161
+ else:
162
+ exprs.append(field_attr == v)
163
+
164
+ query = self.model.find(*exprs, **self._get_query_kwargs(**kwargs))
165
+
166
+ # Apply ordering if provided
167
+ if order_by:
168
+ query = query.sort(order_by)
169
+
170
+ return query
171
+
172
+ async def paginate(
173
+ self, query: FindMany[Document], page: int, count: int, order_by: Optional[List[tuple]] = None
174
+ ) -> tuple[List[Document], int]:
175
+ # Apply ordering if provided and not already applied
176
+ if order_by:
177
+ query = query.sort(order_by)
178
+
179
+ total = await query.count()
180
+ items = await query.skip(count * (page - 1)).limit(count).to_list()
181
+ return items, total
182
+
183
+ async def list_with_aggregation(
184
+ self,
185
+ search: Optional[str],
186
+ search_fields: List[str],
187
+ filters: dict,
188
+ order_by: str,
189
+ page: int,
190
+ count: int,
191
+ **kwargs
192
+ ) -> tuple[List[Document], int]:
193
+ """List with support for nested ordering using aggregation ($facet optimized)."""
194
+ field_path, direction, is_nested = self._parse_order_field(order_by)
195
+ pipeline = []
196
+
197
+ # 1. Match stage (same as before)
198
+ match_conditions = {}
199
+ if filters:
200
+ for key, value in filters.items():
201
+ if isinstance(value, ObjectId):
202
+ match_conditions[key] = value
203
+ elif hasattr(value, "id"):
204
+ match_conditions[f"{key}.$id"] = value.id
205
+ else:
206
+ match_conditions[key] = value
207
+
208
+ if search and search_fields:
209
+ search_conditions = [
210
+ {field: {"$regex": f".*{search}.*", "$options": "i"}}
211
+ for field in search_fields
212
+ ]
213
+ if search_conditions:
214
+ if match_conditions:
215
+ match_conditions = {"$and": [match_conditions, {"$or": search_conditions}]}
216
+ else:
217
+ match_conditions = {"$or": search_conditions}
218
+
219
+ if match_conditions:
220
+ pipeline.append({"$match": match_conditions})
221
+
222
+ # 2. Lookup & Sort (same as before)
223
+ collection_name = None
224
+ first_field = None
225
+
226
+ if is_nested:
227
+ parts = field_path.split(".")
228
+ first_field = parts[0]
229
+ collection_name = self._get_collection_name_from_field(first_field)
230
+
231
+ if collection_name:
232
+ pipeline.extend([
233
+ {
234
+ "$lookup": {
235
+ "from": collection_name,
236
+ "localField": f"{first_field}.$id",
237
+ "foreignField": "_id",
238
+ "as": f"{first_field}_data"
239
+ }
240
+ },
241
+ {
242
+ "$unwind": {
243
+ "path": f"${first_field}_data",
244
+ "preserveNullAndEmptyArrays": True
245
+ }
246
+ }
247
+ ])
248
+ remaining_path = ".".join(parts[1:]) if len(parts) > 1 else ""
249
+ sort_field = f"{first_field}_data.{remaining_path}" if remaining_path else f"{first_field}_data"
250
+ else:
251
+ sort_field = field_path
252
+ else:
253
+ sort_field = field_path
254
+
255
+ pipeline.append({"$sort": {sort_field: direction}})
256
+
257
+ # 3. Facet for Single Query Pagination
258
+ project_exclusion = {f"{first_field}_data": 0} if (is_nested and collection_name) else None
259
+
260
+ facet_stage = {
261
+ "$facet": {
262
+ "metadata": [{"$count": "total"}],
263
+ "data": [
264
+ {"$skip": count * (page - 1)},
265
+ {"$limit": count}
266
+ ]
267
+ }
268
+ }
269
+
270
+ # Add projection to remove join artifacts if needed
271
+ if project_exclusion:
272
+ facet_stage["$facet"]["data"].append({"$project": project_exclusion})
273
+
274
+ pipeline.append(facet_stage)
275
+
276
+ # Execute Pipeline
277
+ results = await self.model.aggregate(pipeline).to_list()
278
+
279
+ # Process Results
280
+ if not results or not results[0].get("metadata"):
281
+ return [], 0
282
+
283
+ data = results[0]
284
+ total = data["metadata"][0]["total"] if data["metadata"] else 0
285
+ items_raw = data["data"]
286
+
287
+ # Efficient Validation
288
+ items = []
289
+ for raw_item in items_raw:
290
+ try:
291
+ items.append(self.model.model_validate(raw_item))
292
+ except Exception:
293
+ continue
294
+
295
+ return items, total
296
+
297
+ async def get_by_id(
298
+ self,
299
+ obj_id: Union[str, ObjectId],
300
+ **kwargs,
301
+ ) -> Optional[Document]:
302
+ if not isinstance(obj_id, ObjectId):
303
+ obj_id = ObjectId(obj_id)
304
+ return await self.model.find_one(
305
+ self.model.id == obj_id,
306
+ **self._get_query_kwargs(**kwargs),
307
+ )
308
+
309
+ async def get_by_field(
310
+ self,
311
+ field_name: str,
312
+ value: Any,
313
+ **kwargs,
314
+ ) -> Optional[Document]:
315
+ if not hasattr(self.model, field_name):
316
+ raise AttributeError(
317
+ f"{self.model.__name__} no tiene el campo '{field_name}'"
318
+ )
319
+ return await self.model.find_one(
320
+ getattr(self.model, field_name) == value,
321
+ **self._get_query_kwargs(**kwargs),
322
+ )
323
+
324
+ async def get_by_fields(
325
+ self,
326
+ filters: Dict[str, Any],
327
+ **kwargs,
328
+ ) -> Optional[Document]:
329
+ exprs = [
330
+ getattr(self.model, f) == v
331
+ for f, v in filters.items()
332
+ if hasattr(self.model, f)
333
+ ]
334
+ if not exprs:
335
+ return None
336
+ return await self.model.find_one(
337
+ *exprs, **self._get_query_kwargs(**kwargs)
338
+ )
339
+
340
+ async def list_all(
341
+ self,
342
+ **kwargs,
343
+ ) -> List[Document]:
344
+ query = self.model.find_all(**self._get_query_kwargs(**kwargs))
345
+ return await query.to_list()
346
+
347
+ async def create(self, obj: Union[Document, Dict[str, Any]]) -> Document:
348
+ if isinstance(obj, dict):
349
+ obj = self.model(**obj)
350
+ await obj.insert()
351
+ return obj
352
+
353
+ async def update(self, obj: Document, data: Dict[str, Any]) -> Document:
354
+ for key, value in data.items():
355
+ setattr(obj, key, value)
356
+ await obj.save()
357
+ return obj
358
+
359
+ async def delete(self, obj: Document) -> None:
360
+ await obj.delete()
@@ -19,6 +19,7 @@ class BaseService:
19
19
  duplicate_check_fields: List[str] = []
20
20
  kwargs_query: Dict[str, Union[str, int]] = {}
21
21
  action: str = ""
22
+ order_by: Optional[List[tuple]] = None
22
23
 
23
24
  def __init__(
24
25
  self, repository: BaseRepository, request: Optional[Request] = None
@@ -45,6 +46,15 @@ class BaseService:
45
46
  def get_kwargs_query(self) -> Dict[str, Any]:
46
47
  return self.kwargs_query
47
48
 
49
+ def get_order(self) -> Optional[List[tuple]]:
50
+ """Override this method to define custom ordering.
51
+
52
+ Returns:
53
+ List of tuples with (field_name, direction) where direction is 1 for ascending or -1 for descending.
54
+ Example: [("created_at", -1)] for newest first
55
+ """
56
+ return self.order_by
57
+
48
58
  def get_filters(
49
59
  self,
50
60
  filters: Optional[Dict[str, Any]] = None,
@@ -65,16 +75,55 @@ class BaseService:
65
75
  page: int = 1,
66
76
  count: int = 25,
67
77
  filters: Optional[Dict[str, Any]] = None,
78
+ order_by: Optional[str] = None, # NEW: Dynamic ordering (e.g., "-created_at" or "tool__name")
68
79
  ):
69
80
  kwargs = self.get_kwargs_query()
70
81
  applied_filters = self.get_filters(filters)
71
- query = self.repository.build_filter_query(
72
- search=search,
73
- search_fields=self.search_fields,
74
- filters=applied_filters,
75
- **kwargs,
76
- )
77
- return await self.repository.paginate(query, page, count)
82
+
83
+ # Determine which ordering to use
84
+ if order_by:
85
+ # Dynamic ordering from parameter (takes precedence)
86
+ order_str = order_by
87
+ else:
88
+ # Fall back to service-level ordering
89
+ default_order = self.get_order()
90
+ if default_order:
91
+ # Convert list of tuples to string format
92
+ # e.g., [("created_at", -1)] -> "-created_at"
93
+ field, direction = default_order[0]
94
+ order_str = f"{'-' if direction == -1 else ''}{field}"
95
+ else:
96
+ order_str = None
97
+
98
+ # Check if we need aggregation (nested field with __ or .)
99
+ if order_str and ("__" in order_str or "." in order_str):
100
+ # Use aggregation pipeline for nested ordering
101
+ return await self.repository.list_with_aggregation(
102
+ search=search,
103
+ search_fields=self.search_fields,
104
+ filters=applied_filters,
105
+ order_by=order_str,
106
+ page=page,
107
+ count=count,
108
+ **kwargs,
109
+ )
110
+ else:
111
+ # Use standard query for simple ordering
112
+ order_list = None
113
+ if order_str:
114
+ # Parse the order string
115
+ direction = -1 if order_str.startswith("-") else 1
116
+ field = order_str.lstrip("-")
117
+ order_list = [(field, direction)]
118
+
119
+ query = self.repository.build_filter_query(
120
+ search=search,
121
+ search_fields=self.search_fields,
122
+ filters=applied_filters,
123
+ order_by=order_list,
124
+ **kwargs,
125
+ )
126
+ return await self.repository.paginate(query, page, count, order_by=order_list)
78
127
 
79
128
  async def create(
80
129
  self, payload: BaseModel, check_fields: Optional[List[str]] = None
@@ -21,6 +21,7 @@ class BaseController:
21
21
  "page",
22
22
  "count",
23
23
  "search",
24
+ "order_by",
24
25
  "__class__",
25
26
  "args",
26
27
  "kwargs",
@@ -142,12 +143,13 @@ class BaseController:
142
143
  query_params = self.request.query_params if self.request else {}
143
144
 
144
145
  # Parámetros especiales de paginación y búsqueda
145
- standard_params = {"page", "count", "search"}
146
+ standard_params = {"page", "count", "search", "order_by"}
146
147
 
147
148
  # Valores por defecto
148
149
  page = 1
149
150
  count = 10
150
151
  search = None
152
+ order_by = None
151
153
  filters = {}
152
154
 
153
155
  # Intentar obtener valores validados del frame local
@@ -191,6 +193,8 @@ class BaseController:
191
193
  )
192
194
  elif param_name == "search":
193
195
  search = final_value
196
+ elif param_name == "order_by":
197
+ order_by = final_value
194
198
  elif param_name not in standard_params:
195
199
  # Es un filtro
196
200
  filters[param_name] = final_value
@@ -199,6 +203,7 @@ class BaseController:
199
203
  "page": page,
200
204
  "count": count,
201
205
  "search": search,
206
+ "order_by": order_by,
202
207
  "filters": filters,
203
208
  }
204
209
 
@@ -53,7 +53,6 @@ class SQLAlchemyBaseController(BaseController):
53
53
  **params,
54
54
  "use_or": use_or,
55
55
  "joins": joins,
56
- "order_by": order_by,
57
56
  }
58
57
  items, total = await self.service.list(**service_params)
59
58
  count = params.get("count") or 0
@@ -16,10 +16,12 @@ class BaseRepository:
16
16
  """
17
17
 
18
18
  model: Type[Any]
19
+ service: Optional[Any] = None
19
20
 
20
21
  def __init__(self, db: AsyncSession):
21
22
  """Inicializa el repositorio con la sesión a reutilizar."""
22
23
  self._session = db
24
+ self.service = None
23
25
 
24
26
  @property
25
27
  def session(self) -> AsyncSession:
@@ -50,6 +52,51 @@ class BaseRepository:
50
52
  query = query.options(joinedload(relationship_attr))
51
53
  return query
52
54
 
55
+ def _resolve_field_path(
56
+ self, path: str
57
+ ) -> Tuple[Optional[Any], Dict[str, Any]]:
58
+ """
59
+ Resuelve una ruta de campo (ej. 'user__role__name') a su atributo
60
+ de SQLAlchemy y el conjunto de JOINs necesarios para el filtrado.
61
+
62
+ Args:
63
+ path: Ruta del campo con sintaxis '__'
64
+
65
+ Returns:
66
+ Tuple con:
67
+ - attr: Atributo final (columna o relación) o None si no existe
68
+ - joins_to_apply: Dict con las relaciones intermedias (name: attr)
69
+ """
70
+ parts = path.split("__")
71
+ current_model = self.model
72
+ attr = getattr(current_model, parts[0], None)
73
+ joins_to_apply = {}
74
+
75
+ if attr is None:
76
+ return None, {}
77
+
78
+ # Recorrer la cadena de relaciones intermedias
79
+ for part in parts[1:]:
80
+ # Chequear si el atributo actual es una relación ORM
81
+ if hasattr(attr.property, "entity") and isinstance(
82
+ attr.property, Relationship
83
+ ):
84
+ # Es una relación: Añadir al diccionario de JOINs
85
+ relation_name = attr.key
86
+ joins_to_apply[relation_name] = attr
87
+
88
+ # Mover al modelo relacionado y obtener el siguiente atributo
89
+ current_model = attr.property.mapper.class_
90
+ attr = getattr(current_model, part, None)
91
+
92
+ if attr is None:
93
+ return None, {}
94
+ else:
95
+ # No es una relación o es el final del path
96
+ break
97
+
98
+ return attr, joins_to_apply
99
+
53
100
  def _resolve_attribute(
54
101
  self, filters: Dict[str, Any]
55
102
  ) -> Tuple[Dict[str, Tuple[Any, Any]], Dict[str, Any]]:
@@ -84,17 +131,13 @@ class BaseRepository:
84
131
  joins_to_apply = {}
85
132
 
86
133
  for filter_path, value in filters.items():
87
- parts = filter_path.split("__")
88
-
89
- # Inicialización
90
- current_model = self.model
91
- attr = getattr(current_model, parts[0], None)
134
+ attr, field_joins = self._resolve_field_path(filter_path)
92
135
 
93
136
  if attr is None:
94
- # Si no existe el campo base, omitir silenciosamente
95
- # (mantiene compatibilidad con código existente)
96
137
  continue
97
138
 
139
+ joins_to_apply.update(field_joins)
140
+
98
141
  # Procesar valor (manejo de Enum a su valor/lista de valores)
99
142
  processed_value = value
100
143
  if (
@@ -106,47 +149,14 @@ class BaseRepository:
106
149
  elif hasattr(value, "value"):
107
150
  processed_value = value.value
108
151
 
109
- # 1. Recorrer la cadena de relaciones intermedias
110
- for part in parts[1:]:
111
- # Chequear si el atributo actual es una relación ORM
112
- if hasattr(attr.property, "entity") and isinstance(
113
- attr.property, Relationship
114
- ):
115
- # Es una relación: Añadir al diccionario de JOINs si no
116
- # está ya
117
- relation_name = (
118
- attr.key
119
- ) # Nombre del atributo en el modelo
120
- # (e.g., 'user_roles')
121
- joins_to_apply[relation_name] = attr
122
-
123
- # Mover al modelo relacionado y obtener el siguiente
124
- # atributo
125
- current_model = attr.property.mapper.class_
126
- attr = getattr(current_model, part, None)
127
-
128
- if attr is None:
129
- break # El siguiente campo no existe
130
- else:
131
- # No es una relación (parte final del path), romper el
132
- # bucle
133
- break
134
-
135
- # 2. Si el atributo final es válido, añadir a las condiciones
136
- # resueltas
137
- # Verificamos si es una Columna (o un campo scalar)
138
- # Para columnas simples, attr.property.columns existe
139
- # Para relaciones, attr.property es Relationship
140
- if attr is not None:
141
- # Verificar que no sea una relación (solo queremos columnas)
142
- is_relationship = isinstance(attr.property, Relationship)
143
- # Verificar que sea una columna o atributo válido
144
- is_column = hasattr(attr.property, "columns") or hasattr(
145
- attr, "comparator"
146
- )
147
-
148
- if not is_relationship and is_column:
149
- resolved_filters[filter_path] = (attr, processed_value)
152
+ # Verificar que no sea una relación (solo queremos columnas para filtrar)
153
+ is_relationship = isinstance(attr.property, Relationship)
154
+ is_column = hasattr(attr.property, "columns") or hasattr(
155
+ attr, "comparator"
156
+ )
157
+
158
+ if not is_relationship and is_column:
159
+ resolved_filters[filter_path] = (attr, processed_value)
150
160
 
151
161
  return resolved_filters, joins_to_apply
152
162
 
@@ -233,53 +243,21 @@ class BaseRepository:
233
243
  order_expression = attr.desc() if should_descend else attr.asc()
234
244
  return order_expression, {}
235
245
 
236
- # 2. Si no es un campo especial, procesar como campo normal o relación
237
- # Dividir la ruta en partes (puede ser simple o con relaciones)
238
- parts = field_path.split("__")
239
-
240
- # Inicialización: empezar con el modelo base
241
- current_model = self.model
242
- attr = getattr(current_model, parts[0], None)
246
+ # 2. Si no es un campo especial, procesar con el helper
247
+ attr, field_joins = self._resolve_field_path(field_path)
243
248
 
244
249
  if attr is None:
245
- # Campo base no existe, retornar None
246
250
  return None, {}
247
251
 
248
- # 3. Recorrer la cadena de relaciones intermedias
249
- for part in parts[1:]:
250
- # Chequear si el atributo actual es una relación ORM
251
- if hasattr(attr.property, "entity") and isinstance(
252
- attr.property, Relationship
253
- ):
254
- # Es una relación: Añadir al diccionario de JOINs si no está ya
255
- relation_name = attr.key # Nombre del atributo en el modelo
256
- joins_to_apply[relation_name] = attr
257
-
258
- # Mover al modelo relacionado y obtener el siguiente atributo
259
- current_model = attr.property.mapper.class_
260
- attr = getattr(current_model, part, None)
261
-
262
- if attr is None:
263
- # El siguiente campo no existe
264
- return None, {}
265
- else:
266
- # No es una relación (parte final del path), romper el bucle
267
- break
252
+ joins_to_apply.update(field_joins)
268
253
 
269
254
  # 4. Verificar que el atributo final es válido para ordenar
270
- # Solo queremos columnas, no relaciones
271
- if attr is None:
272
- return None, {}
273
-
274
- # Verificar que no sea una relación (solo queremos columnas)
275
255
  is_relationship = isinstance(attr.property, Relationship)
276
- # Verificar que sea una columna o atributo válido
277
256
  is_column = hasattr(attr.property, "columns") or hasattr(
278
257
  attr, "comparator"
279
258
  )
280
259
 
281
260
  if is_relationship or not is_column:
282
- # No es una columna válida para ordenar
283
261
  return None, {}
284
262
 
285
263
  # 5. Determinar la dirección del ordenamiento
@@ -337,25 +315,44 @@ class BaseRepository:
337
315
 
338
316
  def _build_search_condition(
339
317
  self, search: Optional[str], search_fields: Optional[List[str]]
340
- ) -> Optional[Any]:
318
+ ) -> Tuple[Optional[Any], Dict[str, Any]]:
341
319
  """Construye una condición OR para búsqueda textual en múltiples campos
342
320
 
343
321
  Usa ilike (insensible a mayúsculas) si hay término y campos válidos.
344
- Ignora campos inexistentes silenciosamente.
322
+ Soporta rutas anidadas con '__'.
323
+
324
+ Returns:
325
+ Tuple con:
326
+ - search_condition: Condición OR de SQLAlchemy o None
327
+ - search_joins: Dict con JOINs necesarios para la búsqueda
345
328
  """
346
329
  if not search or not search_fields:
347
- return None
330
+ return None, {}
331
+
348
332
  exprs: List[Any] = []
349
- for field_name in search_fields:
350
- try:
351
- field = self._get_field(field_name)
352
- except AttributeError:
353
- # Si el campo no existe en el modelo, lo omitimos
333
+ search_joins: Dict[str, Any] = {}
334
+
335
+ for field_path in search_fields:
336
+ attr, field_joins = self._resolve_field_path(field_path)
337
+
338
+ if attr is None:
354
339
  continue
355
- exprs.append(field.ilike(f"%{search}%"))
340
+
341
+ search_joins.update(field_joins)
342
+
343
+ # Verificar que sea una columna válida para ILIKE
344
+ is_column = hasattr(attr.property, "columns") or hasattr(
345
+ attr, "comparator"
346
+ )
347
+ is_relationship = isinstance(attr.property, Relationship)
348
+
349
+ if not is_relationship and is_column:
350
+ exprs.append(attr.ilike(f"%{search}%"))
351
+
356
352
  if not exprs:
357
- return None
358
- return or_(*exprs)
353
+ return None, {}
354
+
355
+ return or_(*exprs), search_joins
359
356
 
360
357
  async def create(self, obj_in: Any | Dict) -> Any:
361
358
  """Crea un nuevo registro en la base de datos."""
@@ -578,9 +575,17 @@ class BaseRepository:
578
575
  )
579
576
 
580
577
  # 4. Aplicar búsqueda textual (search)
581
- search_condition = self._build_search_condition(search, search_fields)
578
+ search_condition, search_joins = self._build_search_condition(
579
+ search, search_fields
580
+ )
581
+
582
+ # 5. Aplicar JOINs de búsqueda
583
+ for relation_name, relation_attr in search_joins.items():
584
+ # Evitar unir dos veces la misma relación si ya se hizo por filtros
585
+ # NOTA: join() en SQLAlchemy es idempotente si es la misma entidad/atributo
586
+ queryset = queryset.join(relation_attr)
582
587
 
583
- # 5. Combina todos los filtros
588
+ # 6. Combina todos los filtros
584
589
  if combined_filters is not None and search_condition is not None:
585
590
  queryset = queryset.where(
586
591
  and_(combined_filters, search_condition)
@@ -625,12 +630,11 @@ class BaseRepository:
625
630
  order_by: Optional[Any] = None,
626
631
  search: Optional[str] = None,
627
632
  search_fields: Optional[List[str]] = None,
628
- base_query: Optional[Select[Tuple[Any]]] = None,
629
633
  **kwargs: Any,
630
634
  ) -> tuple[List[Any], int]:
631
635
 
632
- # 1. Construir query personalizado
633
- kwargs = {
636
+ # 1. Construir query base
637
+ query_kwargs = {
634
638
  "filters": filters,
635
639
  "use_or": use_or,
636
640
  "joins": joins,
@@ -638,13 +642,12 @@ class BaseRepository:
638
642
  "search": search,
639
643
  "search_fields": search_fields,
640
644
  }
641
-
642
645
  try:
643
- # Try with arguments (Modern subclasses)
644
- queryset = self.build_list_queryset(**kwargs)
646
+ # Intentar con argumentos (subclases modernas)
647
+ queryset = self.build_list_queryset(**query_kwargs)
645
648
  except TypeError:
646
- # Fallback to no arguments (Legacy subclasses)
647
- queryset = self.build_list_queryset()
649
+ # Fallback sin argumentos (subclases legacy)
650
+ queryset = self.build_list_queryset()
648
651
 
649
652
  # 2. Aplicar filtros estándar
650
653
  queryset = self.apply_list_filters(
@@ -23,14 +23,37 @@ class BaseService:
23
23
  self,
24
24
  repository: BaseRepository,
25
25
  request: Optional[Request] = None,
26
+ **kwargs,
26
27
  ):
27
28
  self.repository = repository
28
29
  self.request = request
30
+
31
+ # Vincular el servicio al repositorio principal
32
+ if self.repository:
33
+ self.repository.service = self
34
+
35
+ # Procesar kwargs adicionales para vincular otros repositorios
36
+ for name, value in kwargs.items():
37
+ if isinstance(value, BaseRepository):
38
+ value.service = self
39
+ setattr(self, name, value)
29
40
  endpoint_func = (
30
41
  self.request.scope.get("endpoint") if self.request else None
31
42
  )
32
43
  self.action = endpoint_func.__name__ if endpoint_func else None
33
44
 
45
+ # Parámetros compartidos para consultas (especialmente list)
46
+ self.params: Dict[str, Any] = {
47
+ "search": None,
48
+ "page": 1,
49
+ "count": 25,
50
+ "filters": {},
51
+ "use_or": False,
52
+ "joins": None,
53
+ "order_by": None,
54
+ "meta": {}
55
+ }
56
+
34
57
  def get_filters(
35
58
  self, filters: Optional[Dict[str, Any]] = None
36
59
  ) -> Dict[str, Any]:
@@ -51,27 +74,6 @@ class BaseService:
51
74
  """
52
75
  return self.kwargs_query or {}
53
76
 
54
- def build_queryset(self) -> Select[Tuple[Any]]:
55
- """Construye el queryset base para las consultas de listado.
56
-
57
- Este método puede ser sobrescrito por el usuario para personalizar
58
- el queryset base (agregar joins, agregaciones, etc.) sin necesidad
59
- de reescribir el método list().
60
-
61
- Ejemplo de uso:
62
- def build_queryset(self):
63
- from sqlalchemy import func, select
64
- from .models import User, Referral
65
-
66
- return select(
67
- User,
68
- func.count(Referral.id).label('referidos_count')
69
- ).outerjoin(Referral, User.id == Referral.user_id).group_by(User.id)
70
-
71
- Returns:
72
- Select: Query base de SQLAlchemy
73
- """
74
- return select(self.repository.model)
75
77
 
76
78
  async def retrieve(
77
79
  self, id: str, joins: Optional[List[str]] = None
@@ -91,33 +93,52 @@ class BaseService:
91
93
  async def list(
92
94
  self,
93
95
  search: Optional[str] = None,
94
- page: int = 1,
95
- count: int = 25,
96
+ page: Optional[int] = None,
97
+ count: Optional[int] = None,
96
98
  filters: Optional[Dict[str, Any]] = None,
97
- use_or: bool = False,
99
+ use_or: Optional[bool] = None,
98
100
  joins: Optional[List[str]] = None,
99
101
  order_by: Optional[Any] = None,
100
102
  ) -> tuple[List[Any], int]:
101
- # Construye el queryset base personalizado
102
- base_query = self.build_queryset()
103
+ # Actualiza self.params con los argumentos proporcionados (si no son None)
104
+ if search is not None:
105
+ self.params["search"] = search
106
+ if page is not None:
107
+ self.params["page"] = page
108
+ if count is not None:
109
+ self.params["count"] = count
110
+ if filters is not None:
111
+ self.params["filters"] = filters
112
+ if use_or is not None:
113
+ self.params["use_or"] = use_or
114
+ if joins is not None:
115
+ self.params["joins"] = joins
116
+ if order_by is not None:
117
+ self.params["order_by"] = order_by
118
+
103
119
 
104
120
  # Aplica filtros y kwargs de consulta definidos por el servicio
105
- applied = self.get_filters(filters)
121
+ applied_filters = self.get_filters(self.params["filters"])
106
122
  kwargs = self.get_kwargs_query()
107
- if joins is None:
108
- joins = kwargs.get("joins")
109
- if order_by is None:
110
- order_by = kwargs.get("order_by", self.order_by)
123
+
124
+ # Prioridad de joins: argumento explícito > kwargs del servicio (por acción)
125
+ final_joins = self.params["joins"]
126
+ if final_joins is None:
127
+ final_joins = kwargs.get("joins")
128
+
129
+ # Prioridad de order_by: argumento explícito > kwargs del servicio > default del servicio
130
+ final_order_by = self.params["order_by"]
131
+ if final_order_by is None:
132
+ final_order_by = kwargs.get("order_by", self.order_by)
111
133
 
112
134
  return await self.repository.list_paginated(
113
- base_query=base_query,
114
- page=page,
115
- count=count,
116
- filters=applied,
117
- use_or=use_or,
118
- joins=joins,
119
- order_by=order_by,
120
- search=search,
135
+ page=self.params["page"],
136
+ count=self.params["count"],
137
+ filters=applied_filters,
138
+ use_or=self.params["use_or"],
139
+ joins=final_joins,
140
+ order_by=final_order_by,
141
+ search=self.params["search"],
121
142
  search_fields=self.search_fields,
122
143
  )
123
144
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.1.23
3
+ Version: 0.1.25
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie or SQLAlchemy)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-basekit"
7
- version = "0.1.23"
7
+ version = "0.1.25"
8
8
  description = "Utilities and base classes for FastAPI async projects (Beanie or SQLAlchemy)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,168 +0,0 @@
1
- from typing import Any, Dict, List, Optional, Type, Union
2
- from typing import get_args, get_origin
3
-
4
- from bson import ObjectId, Link
5
- from pydantic import BaseModel
6
- from beanie import Document
7
- from beanie.odm.queries.find import FindMany
8
- from beanie.operators import Or, RegEx
9
-
10
-
11
- class BaseRepository:
12
- model: Type[Document]
13
-
14
- def _get_query_kwargs(
15
- self,
16
- fetch_links: bool = False,
17
- nesting_depths_per_field: Optional[Dict[str, int]] = None,
18
- projection: Optional[Union[List[str], Type[BaseModel]]] = None,
19
- ):
20
- kwargs = {
21
- "fetch_links": fetch_links,
22
- "nesting_depths_per_field": (
23
- nesting_depths_per_field if fetch_links else None
24
- ),
25
- }
26
- if projection is not None:
27
- kwargs["projection"] = projection
28
- return kwargs
29
-
30
- def build_filter_query(
31
- self,
32
- search: Optional[str],
33
- search_fields: List[str],
34
- filters: dict = None,
35
- **kwargs,
36
- ) -> FindMany[Document]:
37
- """Versión personalizada que soporta campos Link."""
38
- exprs = []
39
-
40
- if search and search_fields:
41
- exprs.append(
42
- Or(
43
- *[
44
- RegEx(
45
- getattr(self.model, f),
46
- f".*{search}.*",
47
- options="i",
48
- )
49
- for f in search_fields
50
- ]
51
- )
52
- )
53
-
54
- # Obtener campos del modelo
55
- model_fields = (
56
- self.model.model_fields
57
- if hasattr(self.model, "model_fields")
58
- else {}
59
- )
60
-
61
- def _is_link_field(field_name: str) -> bool:
62
- """Verifica si un campo es de tipo Link."""
63
- field_info = model_fields.get(field_name)
64
- if not field_info:
65
- return False
66
-
67
- field_type = field_info.annotation
68
- origin = get_origin(field_type)
69
-
70
- # Caso directo: Link[Model]
71
- if origin is Link:
72
- return True
73
-
74
- # Caso Optional[Link[Model]] = Union[Link[Model], None]
75
- # O cualquier Union que contenga Link
76
- if origin is not None:
77
- args = get_args(field_type)
78
- for arg in args:
79
- # Verificar si el argumento es Link
80
- arg_origin = get_origin(arg)
81
- if arg_origin is Link:
82
- return True
83
-
84
- return False
85
-
86
- for k, v in (filters or {}).items():
87
- if hasattr(self.model, k):
88
- field_attr = getattr(self.model, k)
89
-
90
- if _is_link_field(k):
91
- exprs.append(field_attr.id == v)
92
- else:
93
- exprs.append(field_attr == v)
94
-
95
- query = self.model.find(*exprs, **self._get_query_kwargs(**kwargs))
96
- return query
97
-
98
- async def paginate(
99
- self, query: FindMany[Document], page: int, count: int
100
- ) -> tuple[List[Document], int]:
101
- total = await query.count()
102
- items = await query.skip(count * (page - 1)).limit(count).to_list()
103
- return items, total
104
-
105
- async def get_by_id(
106
- self,
107
- obj_id: Union[str, ObjectId],
108
- **kwargs,
109
- ) -> Optional[Document]:
110
- if not isinstance(obj_id, ObjectId):
111
- obj_id = ObjectId(obj_id)
112
- return await self.model.find_one(
113
- self.model.id == obj_id,
114
- **self._get_query_kwargs(**kwargs),
115
- )
116
-
117
- async def get_by_field(
118
- self,
119
- field_name: str,
120
- value: Any,
121
- **kwargs,
122
- ) -> Optional[Document]:
123
- if not hasattr(self.model, field_name):
124
- raise AttributeError(
125
- f"{self.model.__name__} no tiene el campo '{field_name}'"
126
- )
127
- return await self.model.find_one(
128
- getattr(self.model, field_name) == value,
129
- **self._get_query_kwargs(**kwargs),
130
- )
131
-
132
- async def get_by_fields(
133
- self,
134
- filters: Dict[str, Any],
135
- **kwargs,
136
- ) -> Optional[Document]:
137
- exprs = [
138
- getattr(self.model, f) == v
139
- for f, v in filters.items()
140
- if hasattr(self.model, f)
141
- ]
142
- if not exprs:
143
- return None
144
- return await self.model.find_one(
145
- *exprs, **self._get_query_kwargs(**kwargs)
146
- )
147
-
148
- async def list_all(
149
- self,
150
- **kwargs,
151
- ) -> List[Document]:
152
- query = self.model.find_all(**self._get_query_kwargs(**kwargs))
153
- return await query.to_list()
154
-
155
- async def create(self, obj: Union[Document, Dict[str, Any]]) -> Document:
156
- if isinstance(obj, dict):
157
- obj = self.model(**obj)
158
- await obj.insert()
159
- return obj
160
-
161
- async def update(self, obj: Document, data: Dict[str, Any]) -> Document:
162
- for key, value in data.items():
163
- setattr(obj, key, value)
164
- await obj.save()
165
- return obj
166
-
167
- async def delete(self, obj: Document) -> None:
168
- await obj.delete()