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.
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/PKG-INFO +1 -1
- fastapi_basekit-0.1.25/fastapi_basekit/aio/beanie/repository/base.py +360 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/service/base.py +56 -7
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/controller/base.py +6 -1
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -1
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/repository/base.py +107 -104
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/service/base.py +60 -39
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/PKG-INFO +1 -1
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/pyproject.toml +1 -1
- fastapi_basekit-0.1.23/fastapi_basekit/aio/beanie/repository/base.py +0 -168
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/LICENSE +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/README.md +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/controller/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/permissions/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/permissions/base.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/handler.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/base.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/jwt.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/schema/schema.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/SOURCES.txt +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/requires.txt +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/top_level.txt +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/setup.cfg +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_api_exceptions.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_base_response.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_base_service.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_controller_auto_permissions.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_crud_beanie_controller.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_crud_controller.py +0 -0
- {fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/tests/test_jwt_service.py +0 -0
|
@@ -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()
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/service/base.py
RENAMED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/repository/base.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
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
|
|
633
|
-
|
|
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
|
-
|
|
644
|
-
|
|
646
|
+
# Intentar con argumentos (subclases modernas)
|
|
647
|
+
queryset = self.build_list_queryset(**query_kwargs)
|
|
645
648
|
except TypeError:
|
|
646
|
-
|
|
647
|
-
|
|
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(
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/service/base.py
RENAMED
|
@@ -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 =
|
|
95
|
-
count: int =
|
|
96
|
+
page: Optional[int] = None,
|
|
97
|
+
count: Optional[int] = None,
|
|
96
98
|
filters: Optional[Dict[str, Any]] = None,
|
|
97
|
-
use_or: bool =
|
|
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
|
-
#
|
|
102
|
-
|
|
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
|
-
|
|
121
|
+
applied_filters = self.get_filters(self.params["filters"])
|
|
106
122
|
kwargs = self.get_kwargs_query()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-basekit"
|
|
7
|
-
version = "0.1.
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/controller/__init__.py
RENAMED
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/controller/base.py
RENAMED
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/repository/__init__.py
RENAMED
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/beanie/service/__init__.py
RENAMED
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/controller/__init__.py
RENAMED
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/permissions/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/aio/sqlalchemy/service/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/exceptions/api_exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit/servicios/thrid/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_basekit-0.1.23 → fastapi_basekit-0.1.25}/fastapi_basekit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|