oplnk-python-utils 1.1.7__tar.gz → 1.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {oplnk_python_utils-1.1.7/oplnk_python_utils.egg-info → oplnk_python_utils-1.1.8}/PKG-INFO +18 -10
  2. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/README.md +17 -9
  3. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/__init__.py +1 -1
  4. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/core/parsing.py +90 -11
  5. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/query/query_builder.py +304 -30
  6. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8/oplnk_python_utils.egg-info}/PKG-INFO +18 -10
  7. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/setup.py +1 -1
  8. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/LICENSE +0 -0
  9. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/MANIFEST.in +0 -0
  10. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/cache/__init__.py +0 -0
  11. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/cache/cache_utils.py +0 -0
  12. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/core/__init__.py +0 -0
  13. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/core/data.py +0 -0
  14. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/core/serialization.py +0 -0
  15. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/query/__init__.py +0 -0
  16. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils/reports/__init__.py +0 -0
  17. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
  18. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
  19. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
  20. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils.egg-info/requires.txt +0 -0
  21. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/oplnk_python_utils.egg-info/top_level.txt +0 -0
  22. {oplnk_python_utils-1.1.7 → oplnk_python_utils-1.1.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.1.7
3
+ Version: 1.1.8
4
4
  Summary: Reusable utilities for caching, data manipulation, parsing, and MongoDB query building
5
5
  Home-page: https://github.com/openlinkspa/oplnk-python-utils
6
6
  Author: OpenLink SpA
@@ -109,7 +109,7 @@ merged = merge_dicts(dict1, dict2)
109
109
 
110
110
  ### Parsing Utilities (`core.parsing`)
111
111
 
112
- Functions for parsing query parameters and type conversion.
112
+ Functions for parsing query parameters, type conversion, and safe field-path handling for MongoDB aggregation.
113
113
 
114
114
  ```python
115
115
  from oplnk_python_utils.core.parsing import (
@@ -158,6 +158,14 @@ nested_dict = parse_list_to_dict(["filters", "name", "$eq"], "john")
158
158
  - **`parse_key_string_to_list(s)`**: Parse bracketed notation strings like `"filters[name][$eq]"`
159
159
  - **`parse_list_to_dict(lst, value)`**: Convert key list and value into nested dictionary structure
160
160
 
161
+ #### Field Path Utilities for Aggregation (NEW)
162
+
163
+ - **`normalize_field_path(field)`**: Normalize paths with array notation for MongoDB aggregation.
164
+ - Example: `"user.addresses[1].street" -> "user.addresses.1.street"`
165
+ - **`validate_field_path(field)`**: Validate field paths for invalid characters before using them in pipelines.
166
+ - **`build_aggregation_field_expression(field)`**: Produce a proper aggregation expression, automatically prefixing with `$` when needed.
167
+ - Example: `"user.addresses[1].street" -> "$user.addresses.1.street"`
168
+
161
169
  ### Serialization Utilities (`core.serialization`)
162
170
 
163
171
  Functions for converting data types to serializable formats.
@@ -203,15 +211,15 @@ cache_key = generate_cache_id({
203
211
  })
204
212
  # Automatically normalizes data internally and creates SHA256 hash
205
213
 
206
- # Get from cache or execute function and cache result
207
- async def expensive_operation(query):
208
- # Some expensive database operation
209
- return await db.collection.find(query).to_list()
214
+ # Get from cache or execute a function and cache result (sync fetch_func)
215
+ def fetch_users():
216
+ # Some expensive database operation (sync example)
217
+ return list(db.collection.find({"status": "active"}))
210
218
 
211
- result = await get_or_set_cache(
219
+ result = get_or_set_cache(
212
220
  redis_client=redis,
213
221
  cache_key="user_query_abc123",
214
- fetch_func=expensive_operation,
222
+ fetch_func=fetch_users, # zero-arg callable
215
223
  ttl=3600, # 1 hour
216
224
  serializer=datetime_serializer
217
225
  )
@@ -270,10 +278,10 @@ async def create_user(request: Request):
270
278
  # Automatically invalidates 'users' tag after creation
271
279
  return {"created": True}
272
280
 
273
- # Manual cache key building
281
+ # Manual cache key building (build_cache_key is async)
274
282
  @app.get("/custom")
275
283
  async def custom_endpoint(request: Request):
276
- cache_key = build_cache_key(request, "myapi", "custom", "read")
284
+ cache_key = await build_cache_key(request, "myapi", "custom", "read")
277
285
  # Use cache_key for custom caching logic
278
286
  return {"data": "custom"}
279
287
 
@@ -67,7 +67,7 @@ merged = merge_dicts(dict1, dict2)
67
67
 
68
68
  ### Parsing Utilities (`core.parsing`)
69
69
 
70
- Functions for parsing query parameters and type conversion.
70
+ Functions for parsing query parameters, type conversion, and safe field-path handling for MongoDB aggregation.
71
71
 
72
72
  ```python
73
73
  from oplnk_python_utils.core.parsing import (
@@ -116,6 +116,14 @@ nested_dict = parse_list_to_dict(["filters", "name", "$eq"], "john")
116
116
  - **`parse_key_string_to_list(s)`**: Parse bracketed notation strings like `"filters[name][$eq]"`
117
117
  - **`parse_list_to_dict(lst, value)`**: Convert key list and value into nested dictionary structure
118
118
 
119
+ #### Field Path Utilities for Aggregation (NEW)
120
+
121
+ - **`normalize_field_path(field)`**: Normalize paths with array notation for MongoDB aggregation.
122
+ - Example: `"user.addresses[1].street" -> "user.addresses.1.street"`
123
+ - **`validate_field_path(field)`**: Validate field paths for invalid characters before using them in pipelines.
124
+ - **`build_aggregation_field_expression(field)`**: Produce a proper aggregation expression, automatically prefixing with `$` when needed.
125
+ - Example: `"user.addresses[1].street" -> "$user.addresses.1.street"`
126
+
119
127
  ### Serialization Utilities (`core.serialization`)
120
128
 
121
129
  Functions for converting data types to serializable formats.
@@ -161,15 +169,15 @@ cache_key = generate_cache_id({
161
169
  })
162
170
  # Automatically normalizes data internally and creates SHA256 hash
163
171
 
164
- # Get from cache or execute function and cache result
165
- async def expensive_operation(query):
166
- # Some expensive database operation
167
- return await db.collection.find(query).to_list()
172
+ # Get from cache or execute a function and cache result (sync fetch_func)
173
+ def fetch_users():
174
+ # Some expensive database operation (sync example)
175
+ return list(db.collection.find({"status": "active"}))
168
176
 
169
- result = await get_or_set_cache(
177
+ result = get_or_set_cache(
170
178
  redis_client=redis,
171
179
  cache_key="user_query_abc123",
172
- fetch_func=expensive_operation,
180
+ fetch_func=fetch_users, # zero-arg callable
173
181
  ttl=3600, # 1 hour
174
182
  serializer=datetime_serializer
175
183
  )
@@ -228,10 +236,10 @@ async def create_user(request: Request):
228
236
  # Automatically invalidates 'users' tag after creation
229
237
  return {"created": True}
230
238
 
231
- # Manual cache key building
239
+ # Manual cache key building (build_cache_key is async)
232
240
  @app.get("/custom")
233
241
  async def custom_endpoint(request: Request):
234
- cache_key = build_cache_key(request, "myapi", "custom", "read")
242
+ cache_key = await build_cache_key(request, "myapi", "custom", "read")
235
243
  # Use cache_key for custom caching logic
236
244
  return {"data": "custom"}
237
245
 
@@ -7,7 +7,7 @@ from . import core
7
7
  from . import cache
8
8
  from . import query
9
9
 
10
- __version__ = "1.1.7"
10
+ __version__ = "1.1.8"
11
11
  __author__ = "OpenLink SpA"
12
12
 
13
13
  __all__ = ["core", "cache", "query", "reports", "__version__"]
@@ -11,10 +11,10 @@ from .data import merge_dicts
11
11
  def infer_type(value: Union[str, List, Any]) -> Any:
12
12
  """
13
13
  Infer the correct Python type from a string value.
14
-
14
+
15
15
  Args:
16
16
  value: The value to convert (string, list, or other)
17
-
17
+
18
18
  Returns:
19
19
  Value converted to appropriate Python type
20
20
  """
@@ -54,10 +54,10 @@ def infer_type(value: Union[str, List, Any]) -> Any:
54
54
  def parse_key_string_to_list(s: str) -> List[str]:
55
55
  """
56
56
  Parse a string like 'filters[name][$containsi]' into ['filters', 'name', '$containsi'].
57
-
57
+
58
58
  Args:
59
59
  s: String with bracketed notation
60
-
60
+
61
61
  Returns:
62
62
  List of parsed key components
63
63
  """
@@ -69,11 +69,11 @@ def parse_key_string_to_list(s: str) -> List[str]:
69
69
  def parse_list_to_dict(lst: List[str], value: str) -> Dict[str, Any]:
70
70
  """
71
71
  Convert a list of keys and a value into a nested dictionary.
72
-
72
+
73
73
  Args:
74
74
  lst: List of keys for nested structure
75
75
  value: Value to place at the deepest level
76
-
76
+
77
77
  Returns:
78
78
  Nested dictionary structure
79
79
  """
@@ -93,16 +93,16 @@ def parse_params_to_dict(params: Union[Dict[str, str], Any]) -> Dict[str, Any]:
93
93
  """
94
94
  Parse query parameters into a structured dictionary.
95
95
  Handles nested parameters like filters[name][$containsi]=john
96
-
96
+
97
97
  Args:
98
98
  params: Query parameters (FastAPI QueryParams or dict-like object)
99
-
99
+
100
100
  Returns:
101
101
  Structured dictionary with nested parameters
102
102
  """
103
103
  # Handle FastAPI QueryParams or similar objects
104
- if hasattr(params, 'items'):
105
- param_dict = dict(params.items()) if hasattr(params, 'items') else dict(params)
104
+ if hasattr(params, "items"):
105
+ param_dict = dict(params.items()) if hasattr(params, "items") else dict(params)
106
106
  else:
107
107
  param_dict = params
108
108
 
@@ -111,4 +111,83 @@ def parse_params_to_dict(params: Union[Dict[str, str], Any]) -> Dict[str, Any]:
111
111
  args = parse_key_string_to_list(key)
112
112
  dictionary = parse_list_to_dict(args, value)
113
113
  result = merge_dicts(result, dictionary)
114
- return result
114
+ return result
115
+
116
+
117
+ # Field handling utilities for MongoDB aggregation pipelines
118
+
119
+
120
+ def normalize_field_path(field: str) -> str:
121
+ """
122
+ Normalize field path for MongoDB aggregation pipeline.
123
+ Handles dot notation and prepares for array notation.
124
+
125
+ Examples:
126
+ "establishment_type.name" -> "establishment_type.name"
127
+ "establishment_type[0].name" -> "establishment_type.0.name"
128
+ "user.addresses[1].street" -> "user.addresses.1.street"
129
+
130
+ Args:
131
+ field: Field path to normalize
132
+
133
+ Returns:
134
+ Normalized field path compatible with MongoDB aggregation
135
+ """
136
+ # Convert array notation to dot notation for MongoDB
137
+ # establishment_type[0].name -> establishment_type.0.name
138
+
139
+ # Pattern to match array notation like [0], [1], etc.
140
+ array_pattern = r"\[(\d+)\]"
141
+ normalized = re.sub(array_pattern, r".\1", field)
142
+
143
+ return normalized
144
+
145
+
146
+ def validate_field_path(field: str) -> bool:
147
+ """
148
+ Validate if a field path is valid for MongoDB aggregation.
149
+
150
+ Args:
151
+ field: Field path to validate
152
+
153
+ Returns:
154
+ bool: True if valid, False otherwise
155
+ """
156
+ if not field or not isinstance(field, str):
157
+ return False
158
+
159
+ # Check for invalid characters
160
+ invalid_chars = ["$", " ", "\t", "\n", "\r"]
161
+ if any(
162
+ char in field
163
+ for char in invalid_chars
164
+ if char != "$" or not field.startswith("$")
165
+ ):
166
+ return False
167
+
168
+ return True
169
+
170
+
171
+ def build_aggregation_field_expression(field: str) -> str:
172
+ """
173
+ Build proper MongoDB aggregation field expression.
174
+
175
+ Args:
176
+ field: Field name or path
177
+
178
+ Returns:
179
+ str: Properly formatted field expression for aggregation
180
+
181
+ Raises:
182
+ ValueError: If field path is invalid
183
+ """
184
+ if not validate_field_path(field):
185
+ raise ValueError(f"Invalid field path: {field}")
186
+
187
+ normalized_field = normalize_field_path(field)
188
+
189
+ # If field doesn't start with $, add it for aggregation
190
+ if not normalized_field.startswith("$"):
191
+ return f"${normalized_field}"
192
+
193
+ return normalized_field
@@ -3,18 +3,18 @@ Query Builder - Generic query building functionality for MongoDB aggregation pip
3
3
  """
4
4
 
5
5
  import re
6
+ import time
6
7
  from copy import deepcopy
7
- from typing import List, Dict, Any, Optional, Union, Callable
8
- from datetime import datetime
9
- from bson import ObjectId
8
+ from typing import List, Dict, Any, Optional, Union
10
9
  from fastapi import HTTPException, status
10
+ from pprint import pprint
11
11
 
12
12
  # Import core utilities
13
13
  from ..core.parsing import (
14
- infer_type,
15
- parse_key_string_to_list,
16
- parse_list_to_dict,
17
14
  parse_params_to_dict,
15
+ normalize_field_path,
16
+ validate_field_path,
17
+ build_aggregation_field_expression,
18
18
  )
19
19
 
20
20
 
@@ -46,6 +46,7 @@ class QueryBuilder:
46
46
  cache_client=None,
47
47
  cache_key_prefix: str = "",
48
48
  clear_cache: bool = False,
49
+ debug: bool = False,
49
50
  ) -> Dict[str, Any]:
50
51
  """
51
52
  Complete query method that handles parameter parsing and query execution.
@@ -60,8 +61,25 @@ class QueryBuilder:
60
61
  cache_client: Cache client (Redis)
61
62
  cache_key_prefix: Prefix for cache keys
62
63
  clear_cache: Whether to clear cache before query
64
+ debug: Whether to enable debug output
63
65
  """
66
+ # Start timing
67
+ start_time = time.time()
68
+
69
+ if debug:
70
+ print("🔵 [DEBUG] Parámetros de entrada:")
71
+ pprint(
72
+ {
73
+ "params": params if params is not None else "None",
74
+ "query": query if query is not None else "None",
75
+ "use_cache": use_cache,
76
+ "cache_key_prefix": cache_key_prefix,
77
+ "clear_cache": clear_cache,
78
+ }
79
+ )
80
+
64
81
  # Parse params if provided, otherwise use query directly
82
+ parsing_start_time = time.time()
65
83
  if params is not None:
66
84
  parsed_query = parse_params_to_dict(params)
67
85
  elif query is not None:
@@ -73,6 +91,13 @@ class QueryBuilder:
73
91
  if "clear_cache" in parsed_query:
74
92
  clear_cache = parsed_query.pop("clear_cache", False)
75
93
 
94
+ parsing_time = time.time() - parsing_start_time
95
+
96
+ if debug:
97
+ print("🔵 [DEBUG] Query parseada:")
98
+ pprint(parsed_query)
99
+ print(f"🔵 [DEBUG] Tiempo de parsing: {parsing_time:.3f}s")
100
+
76
101
  return self.get_query_response(
77
102
  db_collection,
78
103
  parsed_query,
@@ -82,6 +107,9 @@ class QueryBuilder:
82
107
  cache_key_prefix=cache_key_prefix,
83
108
  use_cache=use_cache,
84
109
  clear_cache=clear_cache,
110
+ debug=debug,
111
+ start_time=start_time,
112
+ parsing_time=parsing_time,
85
113
  )
86
114
 
87
115
  def build_filter_pipeline(self, filters: Dict[str, Any]) -> Dict[str, Any]:
@@ -421,6 +449,9 @@ class QueryBuilder:
421
449
  field_name = list(condition.keys())[0]
422
450
  field_conditions = condition[field_name]
423
451
 
452
+ # Normalize field name for dot notation support
453
+ normalized_field_name = normalize_field_path(field_name)
454
+
424
455
  if isinstance(field_conditions, dict):
425
456
  operator = list(field_conditions.keys())[0]
426
457
  value = field_conditions[operator]
@@ -443,38 +474,68 @@ class QueryBuilder:
443
474
  # Expand this condition into multiple alternatives
444
475
  value_type, str_value = get_value_type_and_string(value)
445
476
  expanded_conditions = expand_text_search_condition(
446
- field_name, operator, str_value
477
+ normalized_field_name, operator, str_value
447
478
  )
448
479
 
449
480
  # Add all expanded conditions to the $or
450
481
  or_conditions.extend(expanded_conditions)
451
482
  else:
452
- # Regular condition, process normally
453
- sub_pipeline = self.build_filter_pipeline(condition)
483
+ # Regular condition, process normally with normalized field
484
+ normalized_condition = {
485
+ normalized_field_name: field_conditions
486
+ }
487
+ sub_pipeline = self.build_filter_pipeline(
488
+ normalized_condition
489
+ )
454
490
  or_conditions.append(sub_pipeline)
455
491
  else:
456
- # Regular condition, process normally
457
- sub_pipeline = self.build_filter_pipeline(condition)
492
+ # Regular condition, process normally with normalized field
493
+ normalized_condition = {
494
+ normalized_field_name: field_conditions
495
+ }
496
+ sub_pipeline = self.build_filter_pipeline(
497
+ normalized_condition
498
+ )
458
499
  or_conditions.append(sub_pipeline)
459
500
 
460
501
  pipeline["$or"] = or_conditions
461
502
  case "$and":
462
503
  and_conditions = []
463
504
  for condition in conditions:
464
- sub_pipeline = self.build_filter_pipeline(condition)
505
+ # Normalize field names in condition for dot notation support
506
+ normalized_condition = {}
507
+ for field_name, field_conditions in condition.items():
508
+ normalized_field_name = normalize_field_path(field_name)
509
+ normalized_condition[normalized_field_name] = (
510
+ field_conditions
511
+ )
512
+
513
+ sub_pipeline = self.build_filter_pipeline(normalized_condition)
465
514
  and_conditions.append(sub_pipeline)
466
515
  pipeline["$and"] = and_conditions
467
516
  case _:
468
517
  try:
469
- sub_pipeline = build_sub_pipeline(conditions, field)
518
+ # Normalize field path for dot notation and array notation support
519
+ normalized_field = normalize_field_path(field)
520
+
521
+ # Validate the field path
522
+ if not validate_field_path(normalized_field):
523
+ print(
524
+ f"Warning: Invalid field path '{field}', skipping filter condition"
525
+ )
526
+ continue
527
+
528
+ sub_pipeline = build_sub_pipeline(conditions, normalized_field)
470
529
 
471
530
  if isinstance(sub_pipeline, dict):
472
531
  if "_field" in sub_pipeline:
473
- new_field = f"{field}.{sub_pipeline['_field']}"
532
+ new_field = (
533
+ f"{normalized_field}.{sub_pipeline['_field']}"
534
+ )
474
535
  pipeline[new_field] = sub_pipeline["_conditions"]
475
536
  continue
476
537
 
477
- pipeline[field] = sub_pipeline
538
+ pipeline[normalized_field] = sub_pipeline
478
539
  except (ValueError, TypeError) as e:
479
540
  # Log the error and skip this filter condition
480
541
  print(f"Error processing filter for field '{field}': {e}")
@@ -483,22 +544,64 @@ class QueryBuilder:
483
544
  return pipeline
484
545
 
485
546
  def build_sort_pipeline(self, sort_params: List[str]) -> Dict[str, Any]:
486
- """Build sort pipeline from sort parameters"""
547
+ """
548
+ Build sort pipeline from sort parameters with support for dot notation and array notation.
549
+ """
487
550
  sort_pipeline = {}
488
551
  for item in sort_params:
489
- field, order = item.split(":")
490
- sort_pipeline[field] = 1 if order == "asc" else -1
552
+ try:
553
+ field, order = item.split(":")
554
+ normalized_field = normalize_field_path(field)
555
+
556
+ if validate_field_path(normalized_field):
557
+ sort_pipeline[normalized_field] = 1 if order == "asc" else -1
558
+ else:
559
+ print(f"Warning: Invalid field path '{field}', skipping in sort")
560
+ except ValueError as e:
561
+ print(
562
+ f"Warning: Invalid sort parameter '{item}', expected format 'field:order': {e}"
563
+ )
564
+ except Exception as e:
565
+ print(f"Warning: Error processing sort parameter '{item}': {e}")
566
+
491
567
  return {"$sort": sort_pipeline}
492
568
 
493
569
  def build_fields_pipeline(
494
570
  self, fields_params: List[str], populate_params: Optional[List] = None
495
571
  ) -> Dict[str, Any]:
496
- """Build fields projection pipeline"""
497
- fields_pipeline = {field: 1 for field in fields_params}
572
+ """
573
+ Build fields projection pipeline with support for dot notation and array notation.
574
+ """
575
+ fields_pipeline = {}
498
576
 
577
+ # Process fields_params with normalization
578
+ for field in fields_params:
579
+ try:
580
+ normalized_field = normalize_field_path(field)
581
+ if validate_field_path(normalized_field):
582
+ fields_pipeline[normalized_field] = 1
583
+ else:
584
+ print(
585
+ f"Warning: Invalid field path '{field}', skipping in projection"
586
+ )
587
+ except Exception as e:
588
+ print(f"Warning: Error processing field '{field}' in projection: {e}")
589
+
590
+ # Process populate_params if provided
499
591
  if populate_params:
500
592
  for field in populate_params:
501
- fields_pipeline[field] = 1
593
+ try:
594
+ normalized_field = normalize_field_path(field)
595
+ if validate_field_path(normalized_field):
596
+ fields_pipeline[normalized_field] = 1
597
+ else:
598
+ print(
599
+ f"Warning: Invalid populate field path '{field}', skipping in projection"
600
+ )
601
+ except Exception as e:
602
+ print(
603
+ f"Warning: Error processing populate field '{field}' in projection: {e}"
604
+ )
502
605
 
503
606
  return {"$project": fields_pipeline}
504
607
 
@@ -508,14 +611,84 @@ class QueryBuilder:
508
611
  include_project: bool = True,
509
612
  pipeline: Optional[List] = None,
510
613
  ) -> List[Dict[str, Any]]:
511
- """Build distinct pipeline"""
512
- group_id = {field: f"${field}" for field in distinct_params}
513
- group_stage = {"$group": {"_id": group_id}}
514
- if include_project:
515
- replace_root_stage = {"$replaceRoot": {"newRoot": "$_id"}}
516
- return [group_stage, replace_root_stage]
517
- else:
518
- return [group_stage]
614
+ """
615
+ Build distinct pipeline with support for dot notation and array notation.
616
+ """
617
+ try:
618
+ # Build group_id with proper field expressions
619
+ group_id = {}
620
+ project_fields = {}
621
+
622
+ for field in distinct_params:
623
+ try:
624
+ # Use utility function to build proper aggregation expression
625
+ field_expression = build_aggregation_field_expression(field)
626
+
627
+ print(f"🔵 [DEBUG] Field: {field}")
628
+ print(f"🔵 [DEBUG] Field expression: {field_expression}")
629
+
630
+ # For MongoDB $group stage, we need to use a safe key name (no dots)
631
+ # We'll use the original field name as key, but replace dots with underscores for the group
632
+ safe_key = (
633
+ field.replace(".", "_").replace("[", "_").replace("]", "_")
634
+ )
635
+ group_id[safe_key] = field_expression
636
+
637
+ print(f"🔵 [DEBUG] Safe key: {safe_key}")
638
+ print(f"🔵 [DEBUG] Group ID entry: {safe_key} = {field_expression}")
639
+
640
+ # Build nested structure for the projection to maintain original field structure
641
+ parts = field.split(".")
642
+ current = project_fields
643
+ for i, part in enumerate(parts[:-1]):
644
+ if part not in current:
645
+ current[part] = {}
646
+ current = current[part]
647
+
648
+ # Set the final value
649
+ current[parts[-1]] = f"$_id.{safe_key}"
650
+
651
+ print(
652
+ f"🔵 [DEBUG] Project fields after processing {field}: {project_fields}"
653
+ )
654
+
655
+ # Debug: Check if field exists in pipeline before distinct
656
+ print(
657
+ f"🔵 [DEBUG] Pipeline before distinct contains establishment_type lookup: {any('establishment_type' in str(stage) for stage in pipeline) if pipeline else 'No pipeline provided'}"
658
+ )
659
+ print(
660
+ f"🔵 [DEBUG] Available relations: {[rel[1] for rel in self.relations]}"
661
+ )
662
+
663
+ except ValueError as e:
664
+ # Log error and skip invalid field
665
+ print(f"Warning: Skipping invalid field '{field}' in distinct: {e}")
666
+ continue
667
+
668
+ if not group_id:
669
+ raise ValueError("No valid fields provided for distinct operation")
670
+
671
+ group_stage = {"$group": {"_id": group_id}}
672
+
673
+ if include_project:
674
+ # Use $project to create the nested structure
675
+ project_stage = {"$project": project_fields}
676
+ return [group_stage, project_stage]
677
+ else:
678
+ return [group_stage]
679
+
680
+ except Exception as e:
681
+ # Fallback to original behavior for backwards compatibility
682
+ print(
683
+ f"Warning: Error in enhanced distinct pipeline, falling back to original: {e}"
684
+ )
685
+ group_id = {field: f"${field}" for field in distinct_params}
686
+ group_stage = {"$group": {"_id": group_id}}
687
+ if include_project:
688
+ replace_root_stage = {"$replaceRoot": {"newRoot": "$_id"}}
689
+ return [group_stage, replace_root_stage]
690
+ else:
691
+ return [group_stage]
519
692
 
520
693
  def build_pagination_pipeline(
521
694
  self, pagination_params: Dict[str, Any], pipeline: Optional[List] = None
@@ -883,6 +1056,9 @@ class QueryBuilder:
883
1056
  cache_key_prefix: str = "",
884
1057
  use_cache: bool = False,
885
1058
  clear_cache: bool = False,
1059
+ debug: bool = False,
1060
+ start_time: float = 0,
1061
+ parsing_time: float = 0,
886
1062
  ) -> Dict[str, Any]:
887
1063
  """
888
1064
  Get query response with optional caching support
@@ -896,16 +1072,64 @@ class QueryBuilder:
896
1072
  cache_key_prefix: Prefix for cache keys
897
1073
  use_cache: Whether to use caching
898
1074
  clear_cache: Whether to clear cache before query
1075
+ debug: Whether to enable debug output
1076
+ start_time: Start time of the query
1077
+ parsing_time: Time spent parsing parameters
899
1078
  """
900
1079
  try:
1080
+ # Build pipelines
1081
+ pipeline_build_start = time.time()
901
1082
  base_pipeline = self.make_aggregation_query(query, pipeline)
902
1083
  count_pipeline = self.make_count_aggregation_query(query, pipeline_count)
1084
+ pipeline_build_time = time.time() - pipeline_build_start
1085
+
1086
+ if debug:
1087
+ print("🔵 [DEBUG] Pipeline de agregación construido:")
1088
+ pprint(base_pipeline)
1089
+ print("🔵 [DEBUG] Pipeline de count construido:")
1090
+ pprint(count_pipeline)
1091
+
1092
+ # Debug filters
1093
+ if "filters" in query:
1094
+ filter_pipeline = self.build_filter_pipeline(query["filters"])
1095
+ print("🔵 [DEBUG] Filtros aplicados:")
1096
+ pprint(filter_pipeline)
1097
+
1098
+ # Debug special operations
1099
+ if "populate" in query:
1100
+ print("🔵 [DEBUG] Relaciones a popular:")
1101
+ pprint(query["populate"])
1102
+
1103
+ if "distinct" in query:
1104
+ print("🔵 [DEBUG] Campos distinct:")
1105
+ pprint(query["distinct"])
1106
+
1107
+ if "sort" in query:
1108
+ print("🔵 [DEBUG] Ordenamiento aplicado:")
1109
+ pprint(query["sort"])
1110
+
1111
+ if "pagination" in query:
1112
+ print("🔵 [DEBUG] Paginación aplicada:")
1113
+ pprint(query["pagination"])
1114
+
1115
+ # Start MongoDB query timing
1116
+ mongodb_start_time = time.time()
903
1117
 
904
1118
  # Get data
905
1119
  data_result = list(db_collection.aggregate(base_pipeline))
906
1120
 
907
1121
  # Get count
908
1122
  if use_cache and cache_client:
1123
+ if debug:
1124
+ print("🔵 [DEBUG] Configuración de cache:")
1125
+ pprint(
1126
+ {
1127
+ "cache_key_prefix": cache_key_prefix,
1128
+ "clear_cache": clear_cache,
1129
+ "use_cache": use_cache,
1130
+ }
1131
+ )
1132
+
909
1133
  # Import cache utilities
910
1134
  from ..cache import (
911
1135
  generate_cache_id,
@@ -919,6 +1143,10 @@ class QueryBuilder:
919
1143
  query_hash = generate_cache_id(query_for_hash)
920
1144
  count_key = f"{cache_key_prefix}:query:count:{query_hash}"
921
1145
 
1146
+ if debug:
1147
+ print("🔵 [DEBUG] Cache key generada:")
1148
+ print(f"count_key: {count_key}")
1149
+
922
1150
  # Get or set meta from cache
923
1151
  meta_result = get_or_set_cache(
924
1152
  cache_client,
@@ -935,8 +1163,54 @@ class QueryBuilder:
935
1163
  else:
936
1164
  meta_result = list(db_collection.aggregate(count_pipeline))
937
1165
 
938
- return self.sanitize_result(query, data_result, meta_result)
1166
+ # End MongoDB query timing
1167
+ mongodb_time = time.time() - mongodb_start_time
1168
+
1169
+ if debug:
1170
+ print("🔵 [DEBUG] Resultado de query de datos:")
1171
+ print(f"Registros encontrados: {len(data_result)}")
1172
+ if data_result:
1173
+ print("Muestra del primer registro:")
1174
+ pprint(data_result[0])
1175
+ if len(data_result) > 1:
1176
+ print(f"... y {len(data_result) - 1} registros más")
1177
+ else:
1178
+ print("Sin datos encontrados")
1179
+
1180
+ print("🔵 [DEBUG] Resultado de count:")
1181
+ pprint(meta_result)
1182
+
1183
+ result = self.sanitize_result(query, data_result, meta_result)
1184
+
1185
+ if debug:
1186
+ # Calculate total time
1187
+ total_time = time.time() - start_time
1188
+
1189
+ print("🔵 [DEBUG] Respuesta final sanitizada:")
1190
+ preview_data = result.get("data", [])
1191
+ data_preview = {
1192
+ "data_count": len(preview_data),
1193
+ "first_record": preview_data[0] if preview_data else None,
1194
+ "remaining_records": (
1195
+ f"... y {len(preview_data) - 1} más"
1196
+ if len(preview_data) > 1
1197
+ else "ninguno más"
1198
+ ),
1199
+ "meta": result.get("meta", {}),
1200
+ "has_pagination": "pagination" in query,
1201
+ }
1202
+ pprint(data_preview)
1203
+
1204
+ print("🔵 [DEBUG] ⏱️ MÉTRICAS DE PERFORMANCE:")
1205
+ print(f" 📊 Tiempo de parsing/pre-consulta: {parsing_time:.3f}s")
1206
+ print(f" 🗄️ Tiempo real de consulta MongoDB: {mongodb_time:.3f}s")
1207
+ print(f" 🚀 Tiempo total de procesamiento: {total_time:.3f}s")
1208
+ print("=" * 60)
1209
+
1210
+ return result
939
1211
  except Exception as e:
1212
+ if debug:
1213
+ print(f"🔴 [DEBUG] Error en get_query_response: {str(e)}")
940
1214
  raise HTTPException(
941
1215
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
942
1216
  detail=str(e),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.1.7
3
+ Version: 1.1.8
4
4
  Summary: Reusable utilities for caching, data manipulation, parsing, and MongoDB query building
5
5
  Home-page: https://github.com/openlinkspa/oplnk-python-utils
6
6
  Author: OpenLink SpA
@@ -109,7 +109,7 @@ merged = merge_dicts(dict1, dict2)
109
109
 
110
110
  ### Parsing Utilities (`core.parsing`)
111
111
 
112
- Functions for parsing query parameters and type conversion.
112
+ Functions for parsing query parameters, type conversion, and safe field-path handling for MongoDB aggregation.
113
113
 
114
114
  ```python
115
115
  from oplnk_python_utils.core.parsing import (
@@ -158,6 +158,14 @@ nested_dict = parse_list_to_dict(["filters", "name", "$eq"], "john")
158
158
  - **`parse_key_string_to_list(s)`**: Parse bracketed notation strings like `"filters[name][$eq]"`
159
159
  - **`parse_list_to_dict(lst, value)`**: Convert key list and value into nested dictionary structure
160
160
 
161
+ #### Field Path Utilities for Aggregation (NEW)
162
+
163
+ - **`normalize_field_path(field)`**: Normalize paths with array notation for MongoDB aggregation.
164
+ - Example: `"user.addresses[1].street" -> "user.addresses.1.street"`
165
+ - **`validate_field_path(field)`**: Validate field paths for invalid characters before using them in pipelines.
166
+ - **`build_aggregation_field_expression(field)`**: Produce a proper aggregation expression, automatically prefixing with `$` when needed.
167
+ - Example: `"user.addresses[1].street" -> "$user.addresses.1.street"`
168
+
161
169
  ### Serialization Utilities (`core.serialization`)
162
170
 
163
171
  Functions for converting data types to serializable formats.
@@ -203,15 +211,15 @@ cache_key = generate_cache_id({
203
211
  })
204
212
  # Automatically normalizes data internally and creates SHA256 hash
205
213
 
206
- # Get from cache or execute function and cache result
207
- async def expensive_operation(query):
208
- # Some expensive database operation
209
- return await db.collection.find(query).to_list()
214
+ # Get from cache or execute a function and cache result (sync fetch_func)
215
+ def fetch_users():
216
+ # Some expensive database operation (sync example)
217
+ return list(db.collection.find({"status": "active"}))
210
218
 
211
- result = await get_or_set_cache(
219
+ result = get_or_set_cache(
212
220
  redis_client=redis,
213
221
  cache_key="user_query_abc123",
214
- fetch_func=expensive_operation,
222
+ fetch_func=fetch_users, # zero-arg callable
215
223
  ttl=3600, # 1 hour
216
224
  serializer=datetime_serializer
217
225
  )
@@ -270,10 +278,10 @@ async def create_user(request: Request):
270
278
  # Automatically invalidates 'users' tag after creation
271
279
  return {"created": True}
272
280
 
273
- # Manual cache key building
281
+ # Manual cache key building (build_cache_key is async)
274
282
  @app.get("/custom")
275
283
  async def custom_endpoint(request: Request):
276
- cache_key = build_cache_key(request, "myapi", "custom", "read")
284
+ cache_key = await build_cache_key(request, "myapi", "custom", "read")
277
285
  # Use cache_key for custom caching logic
278
286
  return {"data": "custom"}
279
287
 
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="oplnk-python-utils",
5
- version="1.1.7",
5
+ version="1.1.8",
6
6
  author="OpenLink SpA",
7
7
  author_email="contacto@openlink.cl",
8
8
  description="Reusable utilities for caching, data manipulation, parsing, and MongoDB query building",