oplnk-python-utils 1.1.8__tar.gz → 1.2.0__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.8/oplnk_python_utils.egg-info → oplnk_python_utils-1.2.0}/PKG-INFO +40 -1
  2. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/README.md +39 -0
  3. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/__init__.py +1 -1
  4. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/query/query_builder.py +192 -82
  5. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0/oplnk_python_utils.egg-info}/PKG-INFO +40 -1
  6. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/setup.py +1 -1
  7. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/LICENSE +0 -0
  8. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/MANIFEST.in +0 -0
  9. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/cache/__init__.py +0 -0
  10. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/cache/cache_utils.py +0 -0
  11. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/core/__init__.py +0 -0
  12. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/core/data.py +0 -0
  13. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/core/parsing.py +0 -0
  14. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/core/serialization.py +0 -0
  15. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/query/__init__.py +0 -0
  16. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils/reports/__init__.py +0 -0
  17. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
  18. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
  19. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
  20. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils.egg-info/requires.txt +0 -0
  21. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/oplnk_python_utils.egg-info/top_level.txt +0 -0
  22. {oplnk_python_utils-1.1.8 → oplnk_python_utils-1.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.1.8
3
+ Version: 1.2.0
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
@@ -623,6 +623,45 @@ relations = [
623
623
  ]
624
624
  ```
625
625
 
626
+ ### Reserved Audit Fields (NEW in v1.2.0)
627
+
628
+ The QueryBuilder automatically handles common audit fields without requiring manual configuration:
629
+
630
+ ```python
631
+ # No need to define these in relations anymore:
632
+ query_builder = QueryBuilder(
633
+ collection_prefix="myapp",
634
+ relations=[
635
+ # creation_user and modification_user are automatically handled!
636
+ ["User", "user_id", "myapp", "users"],
637
+ ]
638
+ )
639
+
640
+ # Usage - just populate them:
641
+ query = {
642
+ "populate": ["creation_user", "modification_user"]
643
+ }
644
+
645
+ # Result will include:
646
+ # {
647
+ # "data": [{
648
+ # "_id": "123",
649
+ # "creation_user": "user_id_string",
650
+ # "creation_user_info": { // Automatically populated
651
+ # "_id": "user_id_string",
652
+ # "name": "John Doe",
653
+ # "person": "person_id"
654
+ # }
655
+ # }]
656
+ # }
657
+ ```
658
+
659
+ **Reserved fields:**
660
+ - `creation_user` → populated as `creation_user_info`
661
+ - `modification_user` → populated as `modification_user_info`
662
+
663
+ These fields are only populated when explicitly requested via the `populate` parameter, following the Strapi philosophy of on-demand loading.
664
+
626
665
  ### Custom Pipelines
627
666
 
628
667
  ```python
@@ -581,6 +581,45 @@ relations = [
581
581
  ]
582
582
  ```
583
583
 
584
+ ### Reserved Audit Fields (NEW in v1.2.0)
585
+
586
+ The QueryBuilder automatically handles common audit fields without requiring manual configuration:
587
+
588
+ ```python
589
+ # No need to define these in relations anymore:
590
+ query_builder = QueryBuilder(
591
+ collection_prefix="myapp",
592
+ relations=[
593
+ # creation_user and modification_user are automatically handled!
594
+ ["User", "user_id", "myapp", "users"],
595
+ ]
596
+ )
597
+
598
+ # Usage - just populate them:
599
+ query = {
600
+ "populate": ["creation_user", "modification_user"]
601
+ }
602
+
603
+ # Result will include:
604
+ # {
605
+ # "data": [{
606
+ # "_id": "123",
607
+ # "creation_user": "user_id_string",
608
+ # "creation_user_info": { // Automatically populated
609
+ # "_id": "user_id_string",
610
+ # "name": "John Doe",
611
+ # "person": "person_id"
612
+ # }
613
+ # }]
614
+ # }
615
+ ```
616
+
617
+ **Reserved fields:**
618
+ - `creation_user` → populated as `creation_user_info`
619
+ - `modification_user` → populated as `modification_user_info`
620
+
621
+ These fields are only populated when explicitly requested via the `populate` parameter, following the Strapi philosophy of on-demand loading.
622
+
584
623
  ### Custom Pipelines
585
624
 
586
625
  ```python
@@ -7,7 +7,7 @@ from . import core
7
7
  from . import cache
8
8
  from . import query
9
9
 
10
- __version__ = "1.1.8"
10
+ __version__ = "1.2.0"
11
11
  __author__ = "OpenLink SpA"
12
12
 
13
13
  __all__ = ["core", "cache", "query", "reports", "__version__"]
@@ -17,6 +17,65 @@ from ..core.parsing import (
17
17
  build_aggregation_field_expression,
18
18
  )
19
19
 
20
+ RESERVED_AUDIT_FIELDS = {
21
+ "creation_user": {
22
+ "collection": "users",
23
+ "field_name": "creation_user",
24
+ "alias": "creation_user_info",
25
+ "field_type": "id",
26
+ "project_exclude": {
27
+ "role": 0,
28
+ "hashed_password": 0,
29
+ "id": 0,
30
+ "avatar": 0,
31
+ "active_directory": 0,
32
+ "privacy_policy": 0,
33
+ "terms_condition": 0,
34
+ "privacy_policy_date": 0,
35
+ "disabled": 0,
36
+ "first_name": 0,
37
+ "last_name": 0,
38
+ "username": 0,
39
+ "email": 0,
40
+ "establishment_asignation_ids": 0,
41
+ "establishment_type_ids": 0,
42
+ "permission_by_establishment": 0,
43
+ "permission_by_establishment_type": 0,
44
+ "terms_condition_date": 0,
45
+ "establishment_ids": 0,
46
+ "establishment_active_id": 0,
47
+ },
48
+ },
49
+ "modification_user": {
50
+ "collection": "users",
51
+ "field_name": "modification_user",
52
+ "alias": "modification_user_info",
53
+ "field_type": "id",
54
+ "project_exclude": {
55
+ "role": 0,
56
+ "hashed_password": 0,
57
+ "id": 0,
58
+ "avatar": 0,
59
+ "active_directory": 0,
60
+ "privacy_policy": 0,
61
+ "terms_condition": 0,
62
+ "privacy_policy_date": 0,
63
+ "disabled": 0,
64
+ "first_name": 0,
65
+ "last_name": 0,
66
+ "username": 0,
67
+ "email": 0,
68
+ "establishment_asignation_ids": 0,
69
+ "establishment_type_ids": 0,
70
+ "permission_by_establishment": 0,
71
+ "permission_by_establishment_type": 0,
72
+ "terms_condition_date": 0,
73
+ "establishment_ids": 0,
74
+ "establishment_active_id": 0,
75
+ },
76
+ },
77
+ }
78
+
20
79
 
21
80
  class QueryBuilder:
22
81
  """
@@ -743,6 +802,51 @@ class QueryBuilder:
743
802
 
744
803
  return {"data": data_result, "meta": meta_result}
745
804
 
805
+ def get_reserved_field_lookup(self, field_name: str) -> List[Dict[str, Any]]:
806
+ """
807
+ Generate lookup pipeline for reserved audit fields (creation_user, modification_user).
808
+
809
+ This allows automatic population of audit fields without needing to define them
810
+ in the relations list, following the Strapi philosophy of on-demand loading.
811
+
812
+ Args:
813
+ field_name: Name of the reserved field (e.g., "creation_user")
814
+
815
+ Returns:
816
+ List of aggregation stages for the lookup, or None if not a reserved field
817
+ """
818
+ if field_name not in RESERVED_AUDIT_FIELDS:
819
+ return None
820
+
821
+ field_config = RESERVED_AUDIT_FIELDS[field_name]
822
+
823
+ lookup_pipeline = [
824
+ {
825
+ "$lookup": {
826
+ "from": field_config["collection"],
827
+ "let": {"userId": f"${field_config['field_name']}"},
828
+ "pipeline": [
829
+ {
830
+ "$match": {
831
+ "$expr": {"$eq": ["$_id", {"$toObjectId": "$$userId"}]}
832
+ }
833
+ },
834
+ {"$project": field_config["project_exclude"]},
835
+ {"$addFields": {"_id": {"$toString": "$_id"}}},
836
+ ],
837
+ "as": field_config["alias"],
838
+ }
839
+ },
840
+ {
841
+ "$unwind": {
842
+ "path": f"${field_config['alias']}",
843
+ "preserveNullAndEmptyArrays": True,
844
+ }
845
+ },
846
+ ]
847
+
848
+ return lookup_pipeline
849
+
746
850
  def get_lookup_relation(self, relation_field: List[str]) -> tuple:
747
851
  """Get lookup relation configuration for a specific relation field"""
748
852
  _prefix = self.collection_prefix + "_"
@@ -768,85 +872,38 @@ class QueryBuilder:
768
872
  if len(relation_field) > 4 and relation_field[4]:
769
873
  _as = relation_field[4]
770
874
 
771
- if relation_field[0] == "Users":
772
- lookup_relation = [
773
- {
774
- "$lookup": {
775
- "from": "users",
776
- "let": {"usersId": "$person"},
777
- "pipeline": [
778
- {
779
- "$match": {
780
- "$expr": {
781
- "$eq": [
782
- "$_id",
783
- {"$toObjectId": "$$usersId"},
784
- ]
785
- }
786
- }
787
- },
788
- {
789
- "$project": {
790
- "role": 0,
791
- "hashed_password": 0,
792
- "id": 0,
793
- "avatar": 0,
794
- "active_directory": 0,
795
- "privacy_policy": 0,
796
- "terms_condition": 0,
797
- "privacy_policy_date": 0,
798
- "terms_condition_date": 0,
799
- "disabled": 0,
800
- "username": 0,
801
- "establishment_asignation_ids": 0,
802
- "establishment_type_ids": 0,
803
- "permission_by_establishment": 0,
804
- "permission_by_establishment_type": 0,
805
- "terms_condition_date": 0,
806
- "establishment_ids": 0,
807
- "establishment_active_id": 0,
808
- }
809
- },
810
- {"$addFields": {"_id": {"$toString": "$_id"}}},
811
- ],
812
- "as": "person",
813
- }
814
- },
815
- {
816
- "$unwind": {
817
- "path": "$person",
818
- "preserveNullAndEmptyArrays": True,
819
- }
820
- },
821
- ]
875
+ # Agregar protección $ifNull para arrays
876
+ if "_ids" in relation_field_name:
877
+ let_value = {"lid": {"$ifNull": [f"${relation_field_name}", []]}}
822
878
  else:
823
- lookup_relation = [
824
- {
825
- "$lookup": {
826
- "from": f"{_prefix}{collection_name_relation}",
827
- "let": {"lid": f"${relation_field_name}"},
828
- "pipeline": [
829
- {"$addFields": {"_id": {"$toString": "$_id"}}},
830
- match_stage,
831
- {
832
- "$project": {
833
- "creation_user": 0,
834
- "creation_date": 0,
835
- "disable": 0,
836
- }
837
- },
838
- ],
839
- "as": _as,
840
- },
879
+ let_value = {"lid": f"${relation_field_name}"}
880
+
881
+ lookup_relation = [
882
+ {
883
+ "$lookup": {
884
+ "from": f"{_prefix}{collection_name_relation}",
885
+ "let": let_value,
886
+ "pipeline": [
887
+ {"$addFields": {"_id": {"$toString": "$_id"}}},
888
+ match_stage,
889
+ {
890
+ "$project": {
891
+ "creation_user": 0,
892
+ "creation_date": 0,
893
+ "disable": 0,
894
+ }
895
+ },
896
+ ],
897
+ "as": _as,
841
898
  },
842
- {"$unwind": {"path": f"${_as}", "preserveNullAndEmptyArrays": True}},
843
- ]
899
+ },
900
+ ]
844
901
 
845
902
  if not "_ids" in relation_field_name:
846
903
  lookup_relation.append(
847
904
  {
848
905
  "$unwind": {
849
- "path": f"${collection_name_relation}",
906
+ "path": f"${_as}",
850
907
  "preserveNullAndEmptyArrays": True,
851
908
  }
852
909
  }
@@ -860,6 +917,13 @@ class QueryBuilder:
860
917
 
861
918
  if isinstance(query_relations, list):
862
919
  for query_relation in query_relations:
920
+ # Check if it's a reserved field first
921
+ if query_relation in RESERVED_AUDIT_FIELDS:
922
+ reserved_lookup = self.get_reserved_field_lookup(query_relation)
923
+ if reserved_lookup:
924
+ query_lookup_relations.extend(reserved_lookup)
925
+ continue
926
+
863
927
  for relation in self.relations:
864
928
  relation_name = relation[1]
865
929
  if re.search(r"_ids$", relation_name):
@@ -876,6 +940,14 @@ class QueryBuilder:
876
940
  ]
877
941
  if isinstance(query_relations, dict):
878
942
  for query_relation in query_relations.keys():
943
+ # Check if it's a reserved field first
944
+ if query_relation in RESERVED_AUDIT_FIELDS:
945
+ if query_relations[query_relation] == True:
946
+ reserved_lookup = self.get_reserved_field_lookup(query_relation)
947
+ if reserved_lookup:
948
+ query_lookup_relations.extend(reserved_lookup)
949
+ continue
950
+
879
951
  for relation in self.relations:
880
952
  relation_name = relation[1]
881
953
  if re.search(r"_ids$", relation_name):
@@ -927,11 +999,17 @@ class QueryBuilder:
927
999
  if len(relation_field) > 4 and relation_field[4]:
928
1000
  _as = relation_field[4]
929
1001
 
1002
+ # Agregar protección $ifNull para arrays
1003
+ if "_ids" in relation_field_name:
1004
+ let_value = {"lid": {"$ifNull": [f"${relation_field_name}", []]}}
1005
+ else:
1006
+ let_value = {"lid": f"${relation_field_name}"}
1007
+
930
1008
  lookup_relation = [
931
1009
  {
932
1010
  "$lookup": {
933
1011
  "from": f"{_prefix}{collection_name_relation}",
934
- "let": {"lid": f"${relation_field_name}"},
1012
+ "let": let_value,
935
1013
  "pipeline": [
936
1014
  {"$addFields": {"_id": {"$toString": "$_id"}}},
937
1015
  match_stage,
@@ -945,7 +1023,7 @@ class QueryBuilder:
945
1023
  lookup_relation.append(
946
1024
  {
947
1025
  "$unwind": {
948
- "path": f"${collection_name_relation}",
1026
+ "path": f"${_as}",
949
1027
  "preserveNullAndEmptyArrays": True,
950
1028
  }
951
1029
  }
@@ -1020,15 +1098,47 @@ class QueryBuilder:
1020
1098
 
1021
1099
  if "filters" in query:
1022
1100
  if not pipeline:
1023
- lookup_filters = {
1024
- field: value
1025
- for field, value in query["filters"].items()
1026
- if list(value.keys())[0][0] != "$"
1027
- }
1028
-
1029
- if list(lookup_filters.keys()):
1101
+ # Extract potential relation field names from filters, supporting
1102
+ # logical operators ($or/$and) and dot-notation (e.g., "company.name").
1103
+ def extract_relation_candidates(filters_dict):
1104
+ relation_names = set()
1105
+
1106
+ def walk(node):
1107
+ if isinstance(node, dict):
1108
+ for k, v in node.items():
1109
+ if k in ("$or", "$and"):
1110
+ # Value can be a list of conditions or a nested dict
1111
+ if isinstance(v, list):
1112
+ for item in v:
1113
+ walk(item)
1114
+ else:
1115
+ walk(v)
1116
+ else:
1117
+ # Field key: take root before dot and strip _id/_ids
1118
+ if (
1119
+ isinstance(k, str)
1120
+ and k
1121
+ and not k.startswith("$")
1122
+ ):
1123
+ root = k.split(".")[0]
1124
+ root = re.sub(r"(_ids|_id)$", "", root)
1125
+ if root:
1126
+ relation_names.add(root)
1127
+ # Continue walking nested structures like
1128
+ # {"company": {"name": {"$containsi": "x"}}}
1129
+ walk(v)
1130
+ elif isinstance(node, list):
1131
+ for item in node:
1132
+ walk(item)
1133
+ # Primitives are ignored
1134
+
1135
+ walk(filters_dict)
1136
+ return list(relation_names)
1137
+
1138
+ relation_candidates = extract_relation_candidates(query["filters"])
1139
+ if relation_candidates:
1030
1140
  populate_from_filters = self.get_count_lookup_relations(
1031
- list(lookup_filters.keys())
1141
+ relation_candidates
1032
1142
  )
1033
1143
  pipeline.extend(populate_from_filters)
1034
1144
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.1.8
3
+ Version: 1.2.0
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
@@ -623,6 +623,45 @@ relations = [
623
623
  ]
624
624
  ```
625
625
 
626
+ ### Reserved Audit Fields (NEW in v1.2.0)
627
+
628
+ The QueryBuilder automatically handles common audit fields without requiring manual configuration:
629
+
630
+ ```python
631
+ # No need to define these in relations anymore:
632
+ query_builder = QueryBuilder(
633
+ collection_prefix="myapp",
634
+ relations=[
635
+ # creation_user and modification_user are automatically handled!
636
+ ["User", "user_id", "myapp", "users"],
637
+ ]
638
+ )
639
+
640
+ # Usage - just populate them:
641
+ query = {
642
+ "populate": ["creation_user", "modification_user"]
643
+ }
644
+
645
+ # Result will include:
646
+ # {
647
+ # "data": [{
648
+ # "_id": "123",
649
+ # "creation_user": "user_id_string",
650
+ # "creation_user_info": { // Automatically populated
651
+ # "_id": "user_id_string",
652
+ # "name": "John Doe",
653
+ # "person": "person_id"
654
+ # }
655
+ # }]
656
+ # }
657
+ ```
658
+
659
+ **Reserved fields:**
660
+ - `creation_user` → populated as `creation_user_info`
661
+ - `modification_user` → populated as `modification_user_info`
662
+
663
+ These fields are only populated when explicitly requested via the `populate` parameter, following the Strapi philosophy of on-demand loading.
664
+
626
665
  ### Custom Pipelines
627
666
 
628
667
  ```python
@@ -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.8",
5
+ version="1.2.0",
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",