oplnk-python-utils 1.0.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.
- oplnk_python_utils/__init__.py +18 -0
- oplnk_python_utils/cache/__init__.py +21 -0
- oplnk_python_utils/cache/cache_utils.py +88 -0
- oplnk_python_utils/core/__init__.py +13 -0
- oplnk_python_utils/core/data.py +58 -0
- oplnk_python_utils/core/parsing.py +114 -0
- oplnk_python_utils/core/serialization.py +32 -0
- oplnk_python_utils/query/__init__.py +9 -0
- oplnk_python_utils/query/query_builder.py +817 -0
- oplnk_python_utils-1.0.0.dist-info/METADATA +540 -0
- oplnk_python_utils-1.0.0.dist-info/RECORD +14 -0
- oplnk_python_utils-1.0.0.dist-info/WHEEL +5 -0
- oplnk_python_utils-1.0.0.dist-info/licenses/LICENSE +21 -0
- oplnk_python_utils-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query Builder - Generic query building functionality for MongoDB aggregation pipelines
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from typing import List, Dict, Any, Optional, Union, Callable
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from bson import ObjectId
|
|
10
|
+
from fastapi import HTTPException, status
|
|
11
|
+
|
|
12
|
+
# Import core utilities
|
|
13
|
+
from ..core.parsing import (
|
|
14
|
+
infer_type,
|
|
15
|
+
parse_key_string_to_list,
|
|
16
|
+
parse_list_to_dict,
|
|
17
|
+
parse_params_to_dict
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class QueryBuilder:
|
|
22
|
+
"""
|
|
23
|
+
Generic query builder for MongoDB aggregation pipelines.
|
|
24
|
+
Can be used standalone or inherited by CRUD classes.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, collection_prefix: str = "", relations: List = None):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the QueryBuilder
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
collection_prefix: Prefix for collection names
|
|
33
|
+
relations: List of relations for lookup operations
|
|
34
|
+
"""
|
|
35
|
+
self.collection_prefix = collection_prefix
|
|
36
|
+
self.relations = relations or []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_query(
|
|
41
|
+
self,
|
|
42
|
+
db_collection,
|
|
43
|
+
params: Union[Dict[str, str], Any] = None,
|
|
44
|
+
query: Optional[Dict[str, Any]] = None,
|
|
45
|
+
pipeline: Optional[List[Dict[str, Any]]] = None,
|
|
46
|
+
pipeline_count: Optional[List[Dict[str, Any]]] = None,
|
|
47
|
+
use_cache: bool = False,
|
|
48
|
+
cache_client = None,
|
|
49
|
+
cache_key_prefix: str = "",
|
|
50
|
+
clear_cache: bool = False
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Complete query method that handles parameter parsing and query execution.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
db_collection: MongoDB collection object
|
|
57
|
+
params: Raw query parameters (will be parsed if provided)
|
|
58
|
+
query: Pre-parsed query dictionary (used if params not provided)
|
|
59
|
+
pipeline: Optional custom pipeline for data
|
|
60
|
+
pipeline_count: Optional custom pipeline for count
|
|
61
|
+
use_cache: Whether to use caching
|
|
62
|
+
cache_client: Cache client (Redis)
|
|
63
|
+
cache_key_prefix: Prefix for cache keys
|
|
64
|
+
clear_cache: Whether to clear cache before query
|
|
65
|
+
"""
|
|
66
|
+
# Parse params if provided, otherwise use query directly
|
|
67
|
+
if params is not None:
|
|
68
|
+
parsed_query = parse_params_to_dict(params)
|
|
69
|
+
elif query is not None:
|
|
70
|
+
parsed_query = query
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError("Either 'params' or 'query' must be provided")
|
|
73
|
+
|
|
74
|
+
# Handle clear_cache from parsed query
|
|
75
|
+
if 'clear_cache' in parsed_query:
|
|
76
|
+
clear_cache = parsed_query.pop("clear_cache", False)
|
|
77
|
+
|
|
78
|
+
if use_cache and cache_client:
|
|
79
|
+
# Import cache utilities when needed
|
|
80
|
+
from ..cache import generate_cache_id, get_or_set_cache, datetime_serializer
|
|
81
|
+
|
|
82
|
+
query_hash = generate_cache_id(parsed_query)
|
|
83
|
+
id_cache_endpoint = f"{cache_key_prefix}:query:data:{query_hash}"
|
|
84
|
+
|
|
85
|
+
return get_or_set_cache(
|
|
86
|
+
cache_client,
|
|
87
|
+
id_cache_endpoint,
|
|
88
|
+
lambda: self.get_query_response(
|
|
89
|
+
db_collection, parsed_query, pipeline, pipeline_count,
|
|
90
|
+
cache_client=cache_client,
|
|
91
|
+
cache_key_prefix=cache_key_prefix,
|
|
92
|
+
use_cache=True,
|
|
93
|
+
clear_cache=clear_cache
|
|
94
|
+
),
|
|
95
|
+
clear_cache=f"{cache_key_prefix}:query:data:*" if clear_cache else False,
|
|
96
|
+
ttl=60*30,
|
|
97
|
+
serializer=datetime_serializer
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
return self.get_query_response(
|
|
101
|
+
db_collection, parsed_query, pipeline, pipeline_count,
|
|
102
|
+
cache_client=cache_client,
|
|
103
|
+
cache_key_prefix=cache_key_prefix,
|
|
104
|
+
use_cache=False,
|
|
105
|
+
clear_cache=clear_cache
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def build_filter_pipeline(self, filters: Dict[str, Any]) -> Dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Build a filter pipeline from query filters.
|
|
111
|
+
Supports various operators including text search, comparison, and logical operators.
|
|
112
|
+
"""
|
|
113
|
+
def get_value_type_and_string(value):
|
|
114
|
+
"""Determine value type and return appropriate string representation"""
|
|
115
|
+
if value is None:
|
|
116
|
+
return "null", ""
|
|
117
|
+
elif isinstance(value, bool):
|
|
118
|
+
return "boolean", str(value).lower()
|
|
119
|
+
elif isinstance(value, (int, float)):
|
|
120
|
+
return "number", str(value)
|
|
121
|
+
elif isinstance(value, str):
|
|
122
|
+
return "string", value
|
|
123
|
+
else:
|
|
124
|
+
# For dates, ObjectId, etc.
|
|
125
|
+
return "other", str(value)
|
|
126
|
+
|
|
127
|
+
def is_numeric_string(s):
|
|
128
|
+
"""Check if string represents a number"""
|
|
129
|
+
try:
|
|
130
|
+
float(s)
|
|
131
|
+
return True
|
|
132
|
+
except ValueError:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def expand_text_search_condition(field_name, operator, str_value):
|
|
136
|
+
"""Expand a single text search condition into multiple conditions for different field types"""
|
|
137
|
+
expanded_conditions = []
|
|
138
|
+
escaped_value = re.escape(str_value)
|
|
139
|
+
|
|
140
|
+
# Condition 1: For string fields - use regex
|
|
141
|
+
match operator:
|
|
142
|
+
case "$containsi":
|
|
143
|
+
expanded_conditions.append({field_name: {"$regex": escaped_value, "$options": "i"}})
|
|
144
|
+
case "$contains":
|
|
145
|
+
expanded_conditions.append({field_name: {"$regex": escaped_value}})
|
|
146
|
+
case "$notContainsi":
|
|
147
|
+
expanded_conditions.append({field_name: {"$not": {"$regex": escaped_value, "$options": "i"}}})
|
|
148
|
+
case "$notContains":
|
|
149
|
+
expanded_conditions.append({field_name: {"$not": {"$regex": escaped_value}}})
|
|
150
|
+
case "$startsWith":
|
|
151
|
+
expanded_conditions.append({field_name: {"$regex": f"^{escaped_value}"}})
|
|
152
|
+
case "$startsWithi":
|
|
153
|
+
expanded_conditions.append({field_name: {"$regex": f"^{escaped_value}", "$options": "i"}})
|
|
154
|
+
case "$endsWith":
|
|
155
|
+
expanded_conditions.append({field_name: {"$regex": f"{escaped_value}$"}})
|
|
156
|
+
case "$endsWithi":
|
|
157
|
+
expanded_conditions.append({field_name: {"$regex": f"{escaped_value}$", "$options": "i"}})
|
|
158
|
+
case "$eqi":
|
|
159
|
+
expanded_conditions.append({field_name: {"$regex": f"^{escaped_value}$", "$options": "i"}})
|
|
160
|
+
case "$nei":
|
|
161
|
+
expanded_conditions.append({field_name: {"$not": {"$regex": f"^{escaped_value}$", "$options": "i"}}})
|
|
162
|
+
|
|
163
|
+
# Condition 2: For numeric fields - if the search value looks like a number
|
|
164
|
+
if is_numeric_string(str_value):
|
|
165
|
+
try:
|
|
166
|
+
# Convert to number for direct numeric comparison
|
|
167
|
+
if '.' in str_value:
|
|
168
|
+
numeric_value = float(str_value)
|
|
169
|
+
else:
|
|
170
|
+
numeric_value = int(str_value)
|
|
171
|
+
|
|
172
|
+
match operator:
|
|
173
|
+
case "$containsi" | "$contains":
|
|
174
|
+
# Exact numeric match
|
|
175
|
+
expanded_conditions.append({field_name: numeric_value})
|
|
176
|
+
|
|
177
|
+
# For integer searches, also check decimal equivalents
|
|
178
|
+
if str_value == str(int(float(str_value))): # It's a whole number like "10"
|
|
179
|
+
int_val = int(float(str_value))
|
|
180
|
+
# Add the float version too
|
|
181
|
+
expanded_conditions.append({field_name: float(int_val)})
|
|
182
|
+
|
|
183
|
+
# For single digit or small numbers, add range searches
|
|
184
|
+
# to catch things like 10 in 10.5, 100, etc.
|
|
185
|
+
if len(str_value) <= 2 and int_val >= 0:
|
|
186
|
+
# Range for numbers like 10.x (10.0 to 10.999...)
|
|
187
|
+
expanded_conditions.append({
|
|
188
|
+
field_name: {"$gte": int_val, "$lt": int_val + 1}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
# Range for numbers like x10 (10, 110, 210, etc.)
|
|
192
|
+
if int_val > 0:
|
|
193
|
+
# Numbers that start with this digit
|
|
194
|
+
range_start = int_val * (10 ** (len(str(int_val))))
|
|
195
|
+
range_end = range_start + (10 ** (len(str(int_val)))) - 1
|
|
196
|
+
if range_start != int_val: # Avoid duplicate
|
|
197
|
+
expanded_conditions.append({
|
|
198
|
+
field_name: {"$gte": range_start, "$lte": range_end}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
case "$eqi":
|
|
202
|
+
expanded_conditions.append({field_name: numeric_value})
|
|
203
|
+
if isinstance(numeric_value, int):
|
|
204
|
+
expanded_conditions.append({field_name: float(numeric_value)})
|
|
205
|
+
|
|
206
|
+
case "$nei":
|
|
207
|
+
expanded_conditions.append({field_name: {"$ne": numeric_value}})
|
|
208
|
+
if isinstance(numeric_value, int):
|
|
209
|
+
expanded_conditions.append({field_name: {"$ne": float(numeric_value)}})
|
|
210
|
+
|
|
211
|
+
case "$startsWith" | "$startsWithi":
|
|
212
|
+
# Exact match
|
|
213
|
+
expanded_conditions.append({field_name: numeric_value})
|
|
214
|
+
# Range match for numbers starting with this value
|
|
215
|
+
if str_value == str(int(float(str_value))):
|
|
216
|
+
int_val = int(float(str_value))
|
|
217
|
+
range_start = int_val * (10 ** (len(str(int_val))))
|
|
218
|
+
range_end = range_start + (10 ** (len(str(int_val)))) - 1
|
|
219
|
+
if range_start != int_val:
|
|
220
|
+
expanded_conditions.append({
|
|
221
|
+
field_name: {"$gte": range_start, "$lte": range_end}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
case "$endsWith" | "$endsWithi":
|
|
225
|
+
# For ends with, just exact match for now
|
|
226
|
+
expanded_conditions.append({field_name: numeric_value})
|
|
227
|
+
|
|
228
|
+
except ValueError:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
return expanded_conditions
|
|
232
|
+
|
|
233
|
+
def build_sub_pipeline(conditions, field_name=None):
|
|
234
|
+
sub_pipeline = {}
|
|
235
|
+
for operator, value in conditions.items():
|
|
236
|
+
value_type, str_value = get_value_type_and_string(value)
|
|
237
|
+
|
|
238
|
+
# Text search operators that need special handling for mixed types
|
|
239
|
+
text_search_ops = ["$contains", "$notContains", "$containsi", "$notContainsi",
|
|
240
|
+
"$startsWith", "$startsWithi", "$endsWith", "$endsWithi", "$eqi", "$nei"]
|
|
241
|
+
|
|
242
|
+
if operator in text_search_ops:
|
|
243
|
+
# For simple field conditions (not in $or), use basic approach
|
|
244
|
+
escaped_value = re.escape(str_value)
|
|
245
|
+
|
|
246
|
+
if is_numeric_string(str_value):
|
|
247
|
+
try:
|
|
248
|
+
if '.' in str_value:
|
|
249
|
+
numeric_value = float(str_value)
|
|
250
|
+
else:
|
|
251
|
+
numeric_value = int(str_value)
|
|
252
|
+
|
|
253
|
+
# For exact match operators, prefer numeric
|
|
254
|
+
if operator in ["$eqi", "$eq"]:
|
|
255
|
+
sub_pipeline = numeric_value
|
|
256
|
+
elif operator in ["$nei", "$ne"]:
|
|
257
|
+
sub_pipeline = {"$ne": numeric_value}
|
|
258
|
+
else:
|
|
259
|
+
# For contains operations, try numeric first
|
|
260
|
+
sub_pipeline = numeric_value
|
|
261
|
+
except ValueError:
|
|
262
|
+
# Fall back to regex
|
|
263
|
+
if operator == "$containsi":
|
|
264
|
+
sub_pipeline = {"$regex": escaped_value, "$options": "i"}
|
|
265
|
+
elif operator == "$contains":
|
|
266
|
+
sub_pipeline = {"$regex": escaped_value}
|
|
267
|
+
else:
|
|
268
|
+
sub_pipeline = {"$regex": escaped_value, "$options": "i"}
|
|
269
|
+
else:
|
|
270
|
+
# Non-numeric search, use regex
|
|
271
|
+
if operator == "$containsi":
|
|
272
|
+
sub_pipeline = {"$regex": escaped_value, "$options": "i"}
|
|
273
|
+
elif operator == "$contains":
|
|
274
|
+
sub_pipeline = {"$regex": escaped_value}
|
|
275
|
+
elif operator == "$startsWith":
|
|
276
|
+
sub_pipeline = {"$regex": f"^{escaped_value}"}
|
|
277
|
+
elif operator == "$startsWithi":
|
|
278
|
+
sub_pipeline = {"$regex": f"^{escaped_value}", "$options": "i"}
|
|
279
|
+
elif operator == "$endsWith":
|
|
280
|
+
sub_pipeline = {"$regex": f"{escaped_value}$"}
|
|
281
|
+
elif operator == "$endsWithi":
|
|
282
|
+
sub_pipeline = {"$regex": f"{escaped_value}$", "$options": "i"}
|
|
283
|
+
elif operator == "$eqi":
|
|
284
|
+
sub_pipeline = {"$regex": f"^{escaped_value}$", "$options": "i"}
|
|
285
|
+
elif operator == "$nei":
|
|
286
|
+
sub_pipeline = {"$not": {"$regex": f"^{escaped_value}$", "$options": "i"}}
|
|
287
|
+
else:
|
|
288
|
+
sub_pipeline = {"$regex": escaped_value, "$options": "i"}
|
|
289
|
+
else:
|
|
290
|
+
# Handle non-text operators normally
|
|
291
|
+
match operator:
|
|
292
|
+
case "$eq":
|
|
293
|
+
sub_pipeline = value
|
|
294
|
+
case "$ne":
|
|
295
|
+
sub_pipeline = {"$ne": value}
|
|
296
|
+
case "$lt":
|
|
297
|
+
sub_pipeline = {"$lt": value}
|
|
298
|
+
case "$lte":
|
|
299
|
+
sub_pipeline = {"$lte": value}
|
|
300
|
+
case "$gt":
|
|
301
|
+
sub_pipeline = {"$gt": value}
|
|
302
|
+
case "$gte":
|
|
303
|
+
sub_pipeline = {"$gte": value}
|
|
304
|
+
case "$in":
|
|
305
|
+
if not isinstance(value, list):
|
|
306
|
+
value = [value]
|
|
307
|
+
sub_pipeline = {"$in": value}
|
|
308
|
+
case "$notIn":
|
|
309
|
+
if not isinstance(value, list):
|
|
310
|
+
value = [value]
|
|
311
|
+
sub_pipeline = {"$nin": value}
|
|
312
|
+
case "$listContains":
|
|
313
|
+
sub_pipeline = value
|
|
314
|
+
case "$listNotContains":
|
|
315
|
+
sub_pipeline = {"$ne": value}
|
|
316
|
+
case "$null":
|
|
317
|
+
sub_pipeline = {"$in": [None, ""]}
|
|
318
|
+
case "$notNull":
|
|
319
|
+
sub_pipeline = {"$nin": [None, ""]}
|
|
320
|
+
case "$exists":
|
|
321
|
+
sub_pipeline = {"$exists": value}
|
|
322
|
+
case "$notExists":
|
|
323
|
+
sub_pipeline = {"$not": {"$exists": value}}
|
|
324
|
+
case "$between":
|
|
325
|
+
if not isinstance(value, list) or len(value) != 2:
|
|
326
|
+
raise ValueError(f"$between operator requires an array with exactly 2 elements, got: {value}")
|
|
327
|
+
sub_pipeline = {"$gte": value[0], "$lte": value[1]}
|
|
328
|
+
case _:
|
|
329
|
+
if isinstance(value, dict):
|
|
330
|
+
sub_pipeline = {
|
|
331
|
+
'_field': operator,
|
|
332
|
+
'_conditions': build_sub_pipeline(value, field_name)
|
|
333
|
+
}
|
|
334
|
+
else:
|
|
335
|
+
# If it's not a recognized operator, treat as equality
|
|
336
|
+
sub_pipeline = {operator: value}
|
|
337
|
+
return sub_pipeline
|
|
338
|
+
|
|
339
|
+
pipeline = {}
|
|
340
|
+
|
|
341
|
+
for field, conditions in filters.items():
|
|
342
|
+
match field:
|
|
343
|
+
case "$or":
|
|
344
|
+
or_conditions = []
|
|
345
|
+
for condition in conditions:
|
|
346
|
+
# Check if this condition contains text search operators
|
|
347
|
+
field_name = list(condition.keys())[0]
|
|
348
|
+
field_conditions = condition[field_name]
|
|
349
|
+
|
|
350
|
+
if isinstance(field_conditions, dict):
|
|
351
|
+
operator = list(field_conditions.keys())[0]
|
|
352
|
+
value = field_conditions[operator]
|
|
353
|
+
|
|
354
|
+
# Text search operators that need expansion
|
|
355
|
+
text_search_ops = ["$contains", "$notContains", "$containsi", "$notContainsi",
|
|
356
|
+
"$startsWith", "$startsWithi", "$endsWith", "$endsWithi", "$eqi", "$nei"]
|
|
357
|
+
|
|
358
|
+
if operator in text_search_ops:
|
|
359
|
+
# Expand this condition into multiple alternatives
|
|
360
|
+
value_type, str_value = get_value_type_and_string(value)
|
|
361
|
+
expanded_conditions = expand_text_search_condition(field_name, operator, str_value)
|
|
362
|
+
|
|
363
|
+
# Add all expanded conditions to the $or
|
|
364
|
+
or_conditions.extend(expanded_conditions)
|
|
365
|
+
else:
|
|
366
|
+
# Regular condition, process normally
|
|
367
|
+
sub_pipeline = self.build_filter_pipeline(condition)
|
|
368
|
+
or_conditions.append(sub_pipeline)
|
|
369
|
+
else:
|
|
370
|
+
# Regular condition, process normally
|
|
371
|
+
sub_pipeline = self.build_filter_pipeline(condition)
|
|
372
|
+
or_conditions.append(sub_pipeline)
|
|
373
|
+
|
|
374
|
+
pipeline["$or"] = or_conditions
|
|
375
|
+
case "$and":
|
|
376
|
+
and_conditions = []
|
|
377
|
+
for condition in conditions:
|
|
378
|
+
sub_pipeline = self.build_filter_pipeline(condition)
|
|
379
|
+
and_conditions.append(sub_pipeline)
|
|
380
|
+
pipeline["$and"] = and_conditions
|
|
381
|
+
case _:
|
|
382
|
+
try:
|
|
383
|
+
sub_pipeline = build_sub_pipeline(conditions, field)
|
|
384
|
+
|
|
385
|
+
if isinstance(sub_pipeline, dict):
|
|
386
|
+
if '_field' in sub_pipeline:
|
|
387
|
+
new_field = f"{field}.{sub_pipeline['_field']}"
|
|
388
|
+
pipeline[new_field] = sub_pipeline['_conditions']
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
pipeline[field] = sub_pipeline
|
|
392
|
+
except (ValueError, TypeError) as e:
|
|
393
|
+
# Log the error and skip this filter condition
|
|
394
|
+
print(f"Error processing filter for field '{field}': {e}")
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
return pipeline
|
|
398
|
+
|
|
399
|
+
def build_sort_pipeline(self, sort_params: List[str]) -> Dict[str, Any]:
|
|
400
|
+
"""Build sort pipeline from sort parameters"""
|
|
401
|
+
sort_pipeline = {}
|
|
402
|
+
for item in sort_params:
|
|
403
|
+
field, order = item.split(":")
|
|
404
|
+
sort_pipeline[field] = 1 if order == "asc" else -1
|
|
405
|
+
return {"$sort": sort_pipeline}
|
|
406
|
+
|
|
407
|
+
def build_fields_pipeline(self, fields_params: List[str], populate_params: Optional[List] = None) -> Dict[str, Any]:
|
|
408
|
+
"""Build fields projection pipeline"""
|
|
409
|
+
fields_pipeline = {field: 1 for field in fields_params}
|
|
410
|
+
|
|
411
|
+
if populate_params:
|
|
412
|
+
for field in populate_params:
|
|
413
|
+
fields_pipeline[field] = 1
|
|
414
|
+
|
|
415
|
+
return {"$project": fields_pipeline}
|
|
416
|
+
|
|
417
|
+
def build_distinct_pipeline(self, distinct_params: List[str], include_project: bool = True, pipeline: Optional[List] = None) -> List[Dict[str, Any]]:
|
|
418
|
+
"""Build distinct pipeline"""
|
|
419
|
+
group_id = {field: f"${field}" for field in distinct_params}
|
|
420
|
+
group_stage = {"$group": {"_id": group_id}}
|
|
421
|
+
if include_project:
|
|
422
|
+
replace_root_stage = {"$replaceRoot": {"newRoot": "$_id"}}
|
|
423
|
+
return [group_stage, replace_root_stage]
|
|
424
|
+
else:
|
|
425
|
+
return [group_stage]
|
|
426
|
+
|
|
427
|
+
def build_pagination_pipeline(self, pagination_params: Dict[str, Any], pipeline: Optional[List] = None) -> List[Dict[str, Any]]:
|
|
428
|
+
"""Build pagination pipeline"""
|
|
429
|
+
pageSize = pagination_params.get("pageSize", 10)
|
|
430
|
+
page = pagination_params.get("page", 1)
|
|
431
|
+
skip = (int(page) - 1) * int(pageSize)
|
|
432
|
+
|
|
433
|
+
pipeline_copy = deepcopy(pipeline) if pipeline else []
|
|
434
|
+
|
|
435
|
+
has_skip = any('$skip' in stage for stage in pipeline_copy)
|
|
436
|
+
has_limit = any('$limit' in stage for stage in pipeline_copy)
|
|
437
|
+
|
|
438
|
+
if not has_skip:
|
|
439
|
+
pipeline_copy.append({"$skip": skip})
|
|
440
|
+
else:
|
|
441
|
+
for stage in pipeline_copy:
|
|
442
|
+
if '$skip' in stage:
|
|
443
|
+
stage['$skip'] = skip
|
|
444
|
+
|
|
445
|
+
if not has_limit:
|
|
446
|
+
pipeline_copy.append({"$limit": pageSize})
|
|
447
|
+
else:
|
|
448
|
+
for stage in pipeline_copy:
|
|
449
|
+
if '$limit' in stage:
|
|
450
|
+
stage['$limit'] = pageSize
|
|
451
|
+
|
|
452
|
+
return pipeline_copy
|
|
453
|
+
|
|
454
|
+
def sanitize_result(self, query: Dict[str, Any], data_result: Any, meta_result: Any) -> Dict[str, Any]:
|
|
455
|
+
"""Sanitize query results"""
|
|
456
|
+
if "distinct" in query:
|
|
457
|
+
if (
|
|
458
|
+
isinstance(data_result, list)
|
|
459
|
+
and len(data_result) == 1
|
|
460
|
+
and all(v is None for v in data_result[0].values())
|
|
461
|
+
):
|
|
462
|
+
data_result = []
|
|
463
|
+
meta_result = {"totalCount": 0}
|
|
464
|
+
|
|
465
|
+
if isinstance(meta_result, list):
|
|
466
|
+
if meta_result:
|
|
467
|
+
meta_result = meta_result[0]
|
|
468
|
+
else:
|
|
469
|
+
meta_result = {"totalCount": 0}
|
|
470
|
+
|
|
471
|
+
if not meta_result or "totalCount" not in meta_result:
|
|
472
|
+
meta_result = {"totalCount": 0}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
"data": data_result,
|
|
476
|
+
"meta": meta_result
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
def get_lookup_relation(self, relation_field: List[str]) -> tuple:
|
|
480
|
+
"""Get lookup relation configuration for a specific relation field"""
|
|
481
|
+
_prefix = self.collection_prefix + "_"
|
|
482
|
+
if len(relation_field) > 2 and relation_field[2]:
|
|
483
|
+
_prefix = relation_field[2] + "_"
|
|
484
|
+
|
|
485
|
+
if len(relation_field) > 2 and not relation_field[2]:
|
|
486
|
+
_prefix = ""
|
|
487
|
+
|
|
488
|
+
relation_field_name = relation_field[1]
|
|
489
|
+
|
|
490
|
+
if "_id" in relation_field_name:
|
|
491
|
+
match_stage = {"$match": {"$expr": {"$eq": ["$_id", "$$lid"]}}}
|
|
492
|
+
collection_name_relation = relation_field_name.replace("_id", "")
|
|
493
|
+
if "_ids" in relation_field_name:
|
|
494
|
+
match_stage = {"$match": {"$expr": {"$in": ["$_id", "$$lid"]}}}
|
|
495
|
+
collection_name_relation = relation_field_name.replace("_ids", "")
|
|
496
|
+
|
|
497
|
+
if len(relation_field) > 3 and relation_field[3]:
|
|
498
|
+
collection_name_relation = relation_field[3].replace("_id", "")
|
|
499
|
+
|
|
500
|
+
_as = collection_name_relation
|
|
501
|
+
if len(relation_field) > 4 and relation_field[4]:
|
|
502
|
+
_as = relation_field[4]
|
|
503
|
+
|
|
504
|
+
if relation_field[0] == "Users":
|
|
505
|
+
lookup_relation = [
|
|
506
|
+
{
|
|
507
|
+
"$lookup": {
|
|
508
|
+
"from": "users",
|
|
509
|
+
"let": {"usersId": "$person"},
|
|
510
|
+
"pipeline": [
|
|
511
|
+
{
|
|
512
|
+
"$match": {
|
|
513
|
+
"$expr": {
|
|
514
|
+
"$eq": [
|
|
515
|
+
"$_id",
|
|
516
|
+
{
|
|
517
|
+
"$toObjectId": "$$usersId"
|
|
518
|
+
},
|
|
519
|
+
]
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
"$project": {
|
|
525
|
+
"role": 0,
|
|
526
|
+
"hashed_password": 0,
|
|
527
|
+
"id": 0,
|
|
528
|
+
"avatar": 0,
|
|
529
|
+
"active_directory": 0,
|
|
530
|
+
"privacy_policy": 0,
|
|
531
|
+
"terms_condition": 0,
|
|
532
|
+
"privacy_policy_date": 0,
|
|
533
|
+
"terms_condition_date": 0,
|
|
534
|
+
"disabled": 0,
|
|
535
|
+
"username": 0,
|
|
536
|
+
"establishment_asignation_ids": 0,
|
|
537
|
+
"establishment_type_ids": 0,
|
|
538
|
+
"permission_by_establishment": 0,
|
|
539
|
+
"permission_by_establishment_type": 0,
|
|
540
|
+
"terms_condition_date": 0,
|
|
541
|
+
"establishment_ids": 0,
|
|
542
|
+
"establishment_active_id": 0,
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
"$addFields": {
|
|
547
|
+
"_id": {
|
|
548
|
+
"$toString": "$_id"
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
],
|
|
553
|
+
"as": "person",
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
"$unwind": {
|
|
558
|
+
"path": "$person",
|
|
559
|
+
"preserveNullAndEmptyArrays": True,
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
]
|
|
563
|
+
else:
|
|
564
|
+
lookup_relation = [
|
|
565
|
+
{
|
|
566
|
+
"$lookup": {
|
|
567
|
+
"from": f"{_prefix}{collection_name_relation}",
|
|
568
|
+
"let": {"lid": f"${relation_field_name}"},
|
|
569
|
+
"pipeline": [
|
|
570
|
+
{"$addFields": {"_id": {"$toString": "$_id"}}},
|
|
571
|
+
match_stage,
|
|
572
|
+
{
|
|
573
|
+
"$project": {
|
|
574
|
+
"creation_user": 0,
|
|
575
|
+
"creation_date": 0,
|
|
576
|
+
"disable": 0,
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
"as": _as,
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
{"$unwind": {"path": f"${_as}", "preserveNullAndEmptyArrays": True}}
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
if not "_ids" in relation_field_name:
|
|
587
|
+
lookup_relation.append(
|
|
588
|
+
{
|
|
589
|
+
"$unwind": {
|
|
590
|
+
"path": f"${collection_name_relation}",
|
|
591
|
+
"preserveNullAndEmptyArrays": True,
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
return lookup_relation, collection_name_relation
|
|
597
|
+
|
|
598
|
+
def get_query_lookup_relations(self, query_relations):
|
|
599
|
+
"""Get lookup relations for query population"""
|
|
600
|
+
query_lookup_relations = []
|
|
601
|
+
|
|
602
|
+
if isinstance(query_relations, list):
|
|
603
|
+
for query_relation in query_relations:
|
|
604
|
+
for relation in self.relations:
|
|
605
|
+
relation_name = relation[1]
|
|
606
|
+
if re.search(r"_ids$", relation_name):
|
|
607
|
+
relation_name = relation_name[:-4]
|
|
608
|
+
|
|
609
|
+
if re.search(r"_id$", relation_name):
|
|
610
|
+
relation_name = relation_name[:-3]
|
|
611
|
+
|
|
612
|
+
if relation_name == query_relation:
|
|
613
|
+
lookup_relation = self.get_lookup_relation(relation)
|
|
614
|
+
query_lookup_relations = [
|
|
615
|
+
*query_lookup_relations,
|
|
616
|
+
*lookup_relation[0]
|
|
617
|
+
]
|
|
618
|
+
if isinstance(query_relations, dict):
|
|
619
|
+
for query_relation in query_relations.keys():
|
|
620
|
+
for relation in self.relations:
|
|
621
|
+
relation_name = relation[1]
|
|
622
|
+
if re.search(r"_ids$", relation_name):
|
|
623
|
+
relation_name = relation_name[:-4]
|
|
624
|
+
|
|
625
|
+
if re.search(r"_id$", relation_name):
|
|
626
|
+
relation_name = relation_name[:-3]
|
|
627
|
+
|
|
628
|
+
if relation_name == query_relation:
|
|
629
|
+
if query_relations[query_relation] == True:
|
|
630
|
+
lookup_relation = self.get_lookup_relation(relation)
|
|
631
|
+
query_lookup_relations = [
|
|
632
|
+
*query_lookup_relations,
|
|
633
|
+
*lookup_relation[0]
|
|
634
|
+
]
|
|
635
|
+
|
|
636
|
+
if isinstance(query_relations[query_relation], dict):
|
|
637
|
+
lookup_relation = self.get_lookup_relation(relation, query_relations[query_relation])
|
|
638
|
+
query_lookup_relations = [
|
|
639
|
+
*query_lookup_relations,
|
|
640
|
+
*lookup_relation[0]
|
|
641
|
+
]
|
|
642
|
+
return query_lookup_relations
|
|
643
|
+
|
|
644
|
+
def get_count_lookup_relation(self, relation_field: List[str]) -> tuple:
|
|
645
|
+
"""Get count lookup relation for a specific relation field"""
|
|
646
|
+
_prefix = self.collection_prefix + "_"
|
|
647
|
+
if len(relation_field) > 2 and relation_field[2]:
|
|
648
|
+
_prefix = relation_field[2] + "_"
|
|
649
|
+
|
|
650
|
+
if len(relation_field) > 2 and not relation_field[2]:
|
|
651
|
+
_prefix = ""
|
|
652
|
+
|
|
653
|
+
relation_field_name = relation_field[1]
|
|
654
|
+
|
|
655
|
+
if "_id" in relation_field_name:
|
|
656
|
+
match_stage = {"$match": {"$expr": {"$eq": ["$_id", "$$lid"]}}}
|
|
657
|
+
collection_name_relation = relation_field_name.replace("_id", "")
|
|
658
|
+
if "_ids" in relation_field_name:
|
|
659
|
+
match_stage = {"$match": {"$expr": {"$in": ["$_id", "$$lid"]}}}
|
|
660
|
+
collection_name_relation = relation_field_name.replace("_ids", "")
|
|
661
|
+
|
|
662
|
+
if len(relation_field) > 3 and relation_field[3]:
|
|
663
|
+
collection_name_relation = relation_field[3].replace("_id", "")
|
|
664
|
+
|
|
665
|
+
_as = collection_name_relation
|
|
666
|
+
if len(relation_field) > 4 and relation_field[4]:
|
|
667
|
+
_as = relation_field[4]
|
|
668
|
+
|
|
669
|
+
lookup_relation = [
|
|
670
|
+
{
|
|
671
|
+
"$lookup": {
|
|
672
|
+
"from": f"{_prefix}{collection_name_relation}",
|
|
673
|
+
"let": {"lid": f"${relation_field_name}"},
|
|
674
|
+
"pipeline": [
|
|
675
|
+
{"$addFields": {"_id": {"$toString": "$_id"}}},
|
|
676
|
+
match_stage
|
|
677
|
+
],
|
|
678
|
+
"as": _as,
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
if not '_ids' in relation_field_name:
|
|
684
|
+
lookup_relation.append(
|
|
685
|
+
{
|
|
686
|
+
"$unwind": {
|
|
687
|
+
"path": f"${collection_name_relation}",
|
|
688
|
+
"preserveNullAndEmptyArrays": True,
|
|
689
|
+
}
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
return lookup_relation, collection_name_relation
|
|
693
|
+
|
|
694
|
+
def get_count_lookup_relations(self, query_relations: List[str]) -> List[Dict[str, Any]]:
|
|
695
|
+
"""Get count lookup relations for query population"""
|
|
696
|
+
query_lookup_relations = []
|
|
697
|
+
|
|
698
|
+
for query_relation in query_relations:
|
|
699
|
+
for relation in self.relations:
|
|
700
|
+
relation_name = relation[1]
|
|
701
|
+
if re.search(r"_ids$", relation_name):
|
|
702
|
+
relation_name = relation_name[:-4]
|
|
703
|
+
|
|
704
|
+
if re.search(r"_id$", relation_name):
|
|
705
|
+
relation_name = relation_name[:-3]
|
|
706
|
+
|
|
707
|
+
if relation_name == query_relation:
|
|
708
|
+
lookup_relation = self.get_count_lookup_relation(relation)
|
|
709
|
+
query_lookup_relations = [
|
|
710
|
+
*query_lookup_relations,
|
|
711
|
+
*lookup_relation[0]
|
|
712
|
+
]
|
|
713
|
+
|
|
714
|
+
return query_lookup_relations
|
|
715
|
+
|
|
716
|
+
def build_populate_pipeline(self, populate_params):
|
|
717
|
+
"""Build populate pipeline"""
|
|
718
|
+
return self.get_query_lookup_relations(populate_params)
|
|
719
|
+
|
|
720
|
+
def make_aggregation_query(self, query: Dict[str, Any], base_pipeline: Optional[List] = None) -> List[Dict[str, Any]]:
|
|
721
|
+
"""Make aggregation query pipeline"""
|
|
722
|
+
pipeline = (
|
|
723
|
+
[] if not base_pipeline
|
|
724
|
+
else deepcopy(base_pipeline)
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
if "populate" in query:
|
|
728
|
+
pipeline.extend(self.build_populate_pipeline(query["populate"]))
|
|
729
|
+
|
|
730
|
+
if "filters" in query:
|
|
731
|
+
filter_pipeline = self.build_filter_pipeline(query["filters"])
|
|
732
|
+
pipeline.append({"$match": filter_pipeline})
|
|
733
|
+
|
|
734
|
+
if "distinct" in query:
|
|
735
|
+
pipeline.extend(self.build_distinct_pipeline(query["distinct"], include_project=True, pipeline=pipeline))
|
|
736
|
+
elif "fields" in query:
|
|
737
|
+
pipeline.append(self.build_fields_pipeline(query["fields"], query.get("populate", None)))
|
|
738
|
+
|
|
739
|
+
if "sort" in query:
|
|
740
|
+
pipeline.append(self.build_sort_pipeline(query["sort"]))
|
|
741
|
+
|
|
742
|
+
pipeline = self.build_pagination_pipeline(query.get("pagination", {}), pipeline)
|
|
743
|
+
|
|
744
|
+
return pipeline
|
|
745
|
+
|
|
746
|
+
def make_count_aggregation_query(self, query: Dict[str, Any], base_pipeline: Optional[List] = None) -> List[Dict[str, Any]]:
|
|
747
|
+
"""Make count aggregation query pipeline"""
|
|
748
|
+
pipeline = deepcopy(base_pipeline) if base_pipeline else []
|
|
749
|
+
|
|
750
|
+
if "filters" in query:
|
|
751
|
+
if not pipeline:
|
|
752
|
+
lookup_filters = {field: value for field, value in query["filters"].items() if list(value.keys())[0][0] != "$"}
|
|
753
|
+
|
|
754
|
+
if list(lookup_filters.keys()):
|
|
755
|
+
populate_from_filters = self.get_count_lookup_relations(list(lookup_filters.keys()))
|
|
756
|
+
pipeline.extend(populate_from_filters)
|
|
757
|
+
|
|
758
|
+
filter_pipeline = self.build_filter_pipeline(query["filters"])
|
|
759
|
+
pipeline.append({"$match": filter_pipeline})
|
|
760
|
+
|
|
761
|
+
if "distinct" in query:
|
|
762
|
+
pipeline.extend(self.build_distinct_pipeline(query["distinct"], include_project=False, pipeline=pipeline))
|
|
763
|
+
|
|
764
|
+
pipeline.append({"$count": "totalCount"})
|
|
765
|
+
|
|
766
|
+
return pipeline
|
|
767
|
+
|
|
768
|
+
def get_query_response(self, db_collection, query: Dict[str, Any], pipeline: Optional[List] = None, pipeline_count: Optional[List] = None, cache_client=None, cache_key_prefix: str = "", use_cache: bool = False, clear_cache: bool = False) -> Dict[str, Any]:
|
|
769
|
+
"""
|
|
770
|
+
Get query response with optional caching support
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
db_collection: MongoDB collection object
|
|
774
|
+
query: Query parameters
|
|
775
|
+
pipeline: Optional base pipeline
|
|
776
|
+
pipeline_count: Optional count pipeline
|
|
777
|
+
cache_client: Optional cache client (Redis)
|
|
778
|
+
cache_key_prefix: Prefix for cache keys
|
|
779
|
+
use_cache: Whether to use caching
|
|
780
|
+
clear_cache: Whether to clear cache before query
|
|
781
|
+
"""
|
|
782
|
+
try:
|
|
783
|
+
base_pipeline = self.make_aggregation_query(query, pipeline)
|
|
784
|
+
count_pipeline = self.make_count_aggregation_query(query, pipeline_count)
|
|
785
|
+
|
|
786
|
+
# Get data
|
|
787
|
+
data_result = list(db_collection.aggregate(base_pipeline))
|
|
788
|
+
|
|
789
|
+
# Get count
|
|
790
|
+
if use_cache and cache_client:
|
|
791
|
+
# Import cache utilities
|
|
792
|
+
from ..cache import generate_cache_id, get_or_set_cache, datetime_serializer
|
|
793
|
+
|
|
794
|
+
# Prepare cache key for meta (count)
|
|
795
|
+
query_for_hash = deepcopy(query)
|
|
796
|
+
query_for_hash.pop('pagination', None)
|
|
797
|
+
query_hash = generate_cache_id(query_for_hash)
|
|
798
|
+
count_key = f"{cache_key_prefix}:query:count:{query_hash}"
|
|
799
|
+
|
|
800
|
+
# Get or set meta from cache
|
|
801
|
+
meta_result = get_or_set_cache(
|
|
802
|
+
cache_client,
|
|
803
|
+
count_key,
|
|
804
|
+
lambda: list(db_collection.aggregate(count_pipeline)),
|
|
805
|
+
clear_cache=f"{cache_key_prefix}:query:count:*" if clear_cache else False,
|
|
806
|
+
ttl=60*30,
|
|
807
|
+
serializer=lambda x: datetime_serializer(x[0]) if x else {"totalCount": 0}
|
|
808
|
+
)
|
|
809
|
+
else:
|
|
810
|
+
meta_result = list(db_collection.aggregate(count_pipeline))
|
|
811
|
+
|
|
812
|
+
return self.sanitize_result(query, data_result, meta_result)
|
|
813
|
+
except Exception as e:
|
|
814
|
+
raise HTTPException(
|
|
815
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
816
|
+
detail=str(e),
|
|
817
|
+
)
|