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.
@@ -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
+ )