oplnk-python-utils 1.1.9__tar.gz → 1.2.1__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.9/oplnk_python_utils.egg-info → oplnk_python_utils-1.2.1}/PKG-INFO +88 -1
  2. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/README.md +87 -0
  3. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/__init__.py +1 -1
  4. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/query/query_builder.py +223 -74
  5. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1/oplnk_python_utils.egg-info}/PKG-INFO +88 -1
  6. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/setup.py +1 -1
  7. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/LICENSE +0 -0
  8. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/MANIFEST.in +0 -0
  9. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/cache/__init__.py +0 -0
  10. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/cache/cache_utils.py +0 -0
  11. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/__init__.py +0 -0
  12. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/data.py +0 -0
  13. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/parsing.py +0 -0
  14. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/serialization.py +0 -0
  15. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/query/__init__.py +0 -0
  16. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/reports/__init__.py +0 -0
  17. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
  18. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
  19. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
  20. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/requires.txt +0 -0
  21. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/top_level.txt +0 -0
  22. {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.1.9
3
+ Version: 1.2.1
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,93 @@ relations = [
623
623
  ]
624
624
  ```
625
625
 
626
+ ### Reserved Audit Fields (v1.2.1)
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 (original ID automatically removed, replaced by populated object):
646
+ # {
647
+ # "data": [{
648
+ # "_id": "123",
649
+ # "creation_user": { // Replaces the original ID field
650
+ # "_id": "user_id_string",
651
+ # "name": "John Doe",
652
+ # "person": "person_id"
653
+ # }
654
+ # }]
655
+ # }
656
+ ```
657
+
658
+ **Reserved fields:**
659
+
660
+ - `creation_user` → automatically populated, original ID is excluded
661
+ - `modification_user` → automatically populated, original ID is excluded
662
+
663
+ These fields are only populated when explicitly requested via the `populate` parameter, following the Strapi philosophy of on-demand loading.
664
+
665
+ ### Populate Field Sanitization (v1.2.1)
666
+
667
+ By default, when you populate a relation, the original ID field is automatically removed from the response (Strapi-like behavior):
668
+
669
+ ```python
670
+ # Request with populate - establishment_id will be automatically excluded:
671
+ query = {
672
+ "populate": ["establishment"]
673
+ }
674
+
675
+ # Response - establishment_id is not present:
676
+ # {
677
+ # "data": [{
678
+ # "_id": "123",
679
+ # "name": "Product",
680
+ # "establishment": { // Populated object replaces the ID
681
+ # "_id": "est_123",
682
+ # "name": "Main Office"
683
+ # }
684
+ # // establishment_id is NOT here
685
+ # }]
686
+ # }
687
+ ```
688
+
689
+ **To keep both the ID and populated object, explicitly request it in `fields`:**
690
+
691
+ ```python
692
+ query = {
693
+ "populate": ["establishment"],
694
+ "fields": ["name", "establishment_id"] # Request the ID explicitly
695
+ }
696
+
697
+ # Response - both ID and populated object are present:
698
+ # {
699
+ # "data": [{
700
+ # "_id": "123",
701
+ # "name": "Product",
702
+ # "establishment_id": "est_123", // Now included
703
+ # "establishment": {
704
+ # "_id": "est_123",
705
+ # "name": "Main Office"
706
+ # }
707
+ # }]
708
+ # }
709
+ ```
710
+
711
+ **For audit fields:** The original ID is always excluded (no option to keep it, since there's no `creation_user_id` field).
712
+
626
713
  ### Custom Pipelines
627
714
 
628
715
  ```python
@@ -581,6 +581,93 @@ relations = [
581
581
  ]
582
582
  ```
583
583
 
584
+ ### Reserved Audit Fields (v1.2.1)
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 (original ID automatically removed, replaced by populated object):
604
+ # {
605
+ # "data": [{
606
+ # "_id": "123",
607
+ # "creation_user": { // Replaces the original ID field
608
+ # "_id": "user_id_string",
609
+ # "name": "John Doe",
610
+ # "person": "person_id"
611
+ # }
612
+ # }]
613
+ # }
614
+ ```
615
+
616
+ **Reserved fields:**
617
+
618
+ - `creation_user` → automatically populated, original ID is excluded
619
+ - `modification_user` → automatically populated, original ID is excluded
620
+
621
+ These fields are only populated when explicitly requested via the `populate` parameter, following the Strapi philosophy of on-demand loading.
622
+
623
+ ### Populate Field Sanitization (v1.2.1)
624
+
625
+ By default, when you populate a relation, the original ID field is automatically removed from the response (Strapi-like behavior):
626
+
627
+ ```python
628
+ # Request with populate - establishment_id will be automatically excluded:
629
+ query = {
630
+ "populate": ["establishment"]
631
+ }
632
+
633
+ # Response - establishment_id is not present:
634
+ # {
635
+ # "data": [{
636
+ # "_id": "123",
637
+ # "name": "Product",
638
+ # "establishment": { // Populated object replaces the ID
639
+ # "_id": "est_123",
640
+ # "name": "Main Office"
641
+ # }
642
+ # // establishment_id is NOT here
643
+ # }]
644
+ # }
645
+ ```
646
+
647
+ **To keep both the ID and populated object, explicitly request it in `fields`:**
648
+
649
+ ```python
650
+ query = {
651
+ "populate": ["establishment"],
652
+ "fields": ["name", "establishment_id"] # Request the ID explicitly
653
+ }
654
+
655
+ # Response - both ID and populated object are present:
656
+ # {
657
+ # "data": [{
658
+ # "_id": "123",
659
+ # "name": "Product",
660
+ # "establishment_id": "est_123", // Now included
661
+ # "establishment": {
662
+ # "_id": "est_123",
663
+ # "name": "Main Office"
664
+ # }
665
+ # }]
666
+ # }
667
+ ```
668
+
669
+ **For audit fields:** The original ID is always excluded (no option to keep it, since there's no `creation_user_id` field).
670
+
584
671
  ### Custom Pipelines
585
672
 
586
673
  ```python
@@ -7,7 +7,7 @@ from . import core
7
7
  from . import cache
8
8
  from . import query
9
9
 
10
- __version__ = "1.1.9"
10
+ __version__ = "1.2.1"
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",
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",
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
  """
@@ -719,6 +778,48 @@ class QueryBuilder:
719
778
 
720
779
  return pipeline_copy
721
780
 
781
+ def _get_populated_field_mappings(self, query_populate: Any) -> Dict[str, str]:
782
+ """
783
+ Build a mapping of populated field names to their original ID field names.
784
+
785
+ For example: {"establishment": "establishment_id", "creation_user": "_original_creation_user"}
786
+
787
+ Args:
788
+ query_populate: The populate parameter (list or dict)
789
+
790
+ Returns:
791
+ Dict mapping populated field names to their original ID fields
792
+ """
793
+ mappings = {}
794
+
795
+ if isinstance(query_populate, list):
796
+ for field in query_populate:
797
+ if field in RESERVED_AUDIT_FIELDS:
798
+ # Audit fields: map to the temporary original field
799
+ mappings[field] = f"_original_{field}"
800
+ else:
801
+ # Normal relations: try to find the ID field
802
+ for relation in self.relations:
803
+ relation_name = relation[1]
804
+ # Remove _id or _ids suffix to get base name
805
+ base_name = relation_name.replace("_ids", "").replace("_id", "")
806
+ if base_name == field:
807
+ mappings[field] = relation_name
808
+ break
809
+ elif isinstance(query_populate, dict):
810
+ for field in query_populate.keys():
811
+ if field in RESERVED_AUDIT_FIELDS:
812
+ mappings[field] = f"_original_{field}"
813
+ else:
814
+ for relation in self.relations:
815
+ relation_name = relation[1]
816
+ base_name = relation_name.replace("_ids", "").replace("_id", "")
817
+ if base_name == field:
818
+ mappings[field] = relation_name
819
+ break
820
+
821
+ return mappings
822
+
722
823
  def sanitize_result(
723
824
  self, query: Dict[str, Any], data_result: Any, meta_result: Any
724
825
  ) -> Dict[str, Any]:
@@ -741,8 +842,76 @@ class QueryBuilder:
741
842
  if not meta_result or "totalCount" not in meta_result:
742
843
  meta_result = {"totalCount": 0}
743
844
 
845
+ # Sanitize populate fields: remove original ID fields when populated
846
+ if "populate" in query and isinstance(data_result, list):
847
+ fields_requested = query.get("fields", [])
848
+ populated_mappings = self._get_populated_field_mappings(query["populate"])
849
+
850
+ for doc in data_result:
851
+ for populated_field, original_id_field in populated_mappings.items():
852
+ # If it's a reserved audit field (starts with _original_), always remove the temp field
853
+ if original_id_field.startswith("_original_"):
854
+ if original_id_field in doc:
855
+ del doc[original_id_field]
856
+ # For normal relations, only remove if not explicitly requested in fields
857
+ else:
858
+ if original_id_field not in fields_requested:
859
+ if original_id_field in doc:
860
+ del doc[original_id_field]
861
+
744
862
  return {"data": data_result, "meta": meta_result}
745
863
 
864
+ def get_reserved_field_lookup(self, field_name: str) -> List[Dict[str, Any]]:
865
+ """
866
+ Generate lookup pipeline for reserved audit fields (creation_user, modification_user).
867
+
868
+ This allows automatic population of audit fields without needing to define them
869
+ in the relations list, following the Strapi philosophy of on-demand loading.
870
+
871
+ Args:
872
+ field_name: Name of the reserved field (e.g., "creation_user")
873
+
874
+ Returns:
875
+ List of aggregation stages for the lookup, or None if not a reserved field
876
+ """
877
+ if field_name not in RESERVED_AUDIT_FIELDS:
878
+ return None
879
+
880
+ field_config = RESERVED_AUDIT_FIELDS[field_name]
881
+
882
+ lookup_pipeline = [
883
+ # Save the original ID before lookup (will be removed during sanitization)
884
+ {
885
+ "$addFields": {
886
+ f"_original_{field_config['field_name']}": f"${field_config['field_name']}"
887
+ }
888
+ },
889
+ {
890
+ "$lookup": {
891
+ "from": field_config["collection"],
892
+ "let": {"userId": f"${field_config['field_name']}"},
893
+ "pipeline": [
894
+ {
895
+ "$match": {
896
+ "$expr": {"$eq": ["$_id", {"$toObjectId": "$$userId"}]}
897
+ }
898
+ },
899
+ {"$project": field_config["project_exclude"]},
900
+ {"$addFields": {"_id": {"$toString": "$_id"}}},
901
+ ],
902
+ "as": field_config["alias"],
903
+ }
904
+ },
905
+ {
906
+ "$unwind": {
907
+ "path": f"${field_config['alias']}",
908
+ "preserveNullAndEmptyArrays": True,
909
+ }
910
+ },
911
+ ]
912
+
913
+ return lookup_pipeline
914
+
746
915
  def get_lookup_relation(self, relation_field: List[str]) -> tuple:
747
916
  """Get lookup relation configuration for a specific relation field"""
748
917
  _prefix = self.collection_prefix + "_"
@@ -768,85 +937,44 @@ class QueryBuilder:
768
937
  if len(relation_field) > 4 and relation_field[4]:
769
938
  _as = relation_field[4]
770
939
 
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
- ]
940
+ # Agregar protección $ifNull para arrays
941
+ if "_ids" in relation_field_name:
942
+ let_value = {"lid": {"$ifNull": [f"${relation_field_name}", []]}}
822
943
  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
- },
944
+ let_value = {"lid": f"${relation_field_name}"}
945
+
946
+ # Save the original ID field before lookup (will be sanitized unless requested in fields)
947
+ lookup_relation = [
948
+ {
949
+ "$addFields": {
950
+ f"_original_{relation_field_name}": f"${relation_field_name}"
951
+ }
952
+ },
953
+ {
954
+ "$lookup": {
955
+ "from": f"{_prefix}{collection_name_relation}",
956
+ "let": let_value,
957
+ "pipeline": [
958
+ {"$addFields": {"_id": {"$toString": "$_id"}}},
959
+ match_stage,
960
+ {
961
+ "$project": {
962
+ "creation_user": 0,
963
+ "creation_date": 0,
964
+ "disable": 0,
965
+ }
966
+ },
967
+ ],
968
+ "as": _as,
841
969
  },
842
- {"$unwind": {"path": f"${_as}", "preserveNullAndEmptyArrays": True}},
843
- ]
970
+ },
971
+ ]
844
972
 
845
973
  if not "_ids" in relation_field_name:
846
974
  lookup_relation.append(
847
975
  {
848
976
  "$unwind": {
849
- "path": f"${collection_name_relation}",
977
+ "path": f"${_as}",
850
978
  "preserveNullAndEmptyArrays": True,
851
979
  }
852
980
  }
@@ -860,6 +988,13 @@ class QueryBuilder:
860
988
 
861
989
  if isinstance(query_relations, list):
862
990
  for query_relation in query_relations:
991
+ # Check if it's a reserved field first
992
+ if query_relation in RESERVED_AUDIT_FIELDS:
993
+ reserved_lookup = self.get_reserved_field_lookup(query_relation)
994
+ if reserved_lookup:
995
+ query_lookup_relations.extend(reserved_lookup)
996
+ continue
997
+
863
998
  for relation in self.relations:
864
999
  relation_name = relation[1]
865
1000
  if re.search(r"_ids$", relation_name):
@@ -876,6 +1011,14 @@ class QueryBuilder:
876
1011
  ]
877
1012
  if isinstance(query_relations, dict):
878
1013
  for query_relation in query_relations.keys():
1014
+ # Check if it's a reserved field first
1015
+ if query_relation in RESERVED_AUDIT_FIELDS:
1016
+ if query_relations[query_relation] == True:
1017
+ reserved_lookup = self.get_reserved_field_lookup(query_relation)
1018
+ if reserved_lookup:
1019
+ query_lookup_relations.extend(reserved_lookup)
1020
+ continue
1021
+
879
1022
  for relation in self.relations:
880
1023
  relation_name = relation[1]
881
1024
  if re.search(r"_ids$", relation_name):
@@ -927,11 +1070,17 @@ class QueryBuilder:
927
1070
  if len(relation_field) > 4 and relation_field[4]:
928
1071
  _as = relation_field[4]
929
1072
 
1073
+ # Agregar protección $ifNull para arrays
1074
+ if "_ids" in relation_field_name:
1075
+ let_value = {"lid": {"$ifNull": [f"${relation_field_name}", []]}}
1076
+ else:
1077
+ let_value = {"lid": f"${relation_field_name}"}
1078
+
930
1079
  lookup_relation = [
931
1080
  {
932
1081
  "$lookup": {
933
1082
  "from": f"{_prefix}{collection_name_relation}",
934
- "let": {"lid": f"${relation_field_name}"},
1083
+ "let": let_value,
935
1084
  "pipeline": [
936
1085
  {"$addFields": {"_id": {"$toString": "$_id"}}},
937
1086
  match_stage,
@@ -945,7 +1094,7 @@ class QueryBuilder:
945
1094
  lookup_relation.append(
946
1095
  {
947
1096
  "$unwind": {
948
- "path": f"${collection_name_relation}",
1097
+ "path": f"${_as}",
949
1098
  "preserveNullAndEmptyArrays": True,
950
1099
  }
951
1100
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oplnk-python-utils
3
- Version: 1.1.9
3
+ Version: 1.2.1
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,93 @@ relations = [
623
623
  ]
624
624
  ```
625
625
 
626
+ ### Reserved Audit Fields (v1.2.1)
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 (original ID automatically removed, replaced by populated object):
646
+ # {
647
+ # "data": [{
648
+ # "_id": "123",
649
+ # "creation_user": { // Replaces the original ID field
650
+ # "_id": "user_id_string",
651
+ # "name": "John Doe",
652
+ # "person": "person_id"
653
+ # }
654
+ # }]
655
+ # }
656
+ ```
657
+
658
+ **Reserved fields:**
659
+
660
+ - `creation_user` → automatically populated, original ID is excluded
661
+ - `modification_user` → automatically populated, original ID is excluded
662
+
663
+ These fields are only populated when explicitly requested via the `populate` parameter, following the Strapi philosophy of on-demand loading.
664
+
665
+ ### Populate Field Sanitization (v1.2.1)
666
+
667
+ By default, when you populate a relation, the original ID field is automatically removed from the response (Strapi-like behavior):
668
+
669
+ ```python
670
+ # Request with populate - establishment_id will be automatically excluded:
671
+ query = {
672
+ "populate": ["establishment"]
673
+ }
674
+
675
+ # Response - establishment_id is not present:
676
+ # {
677
+ # "data": [{
678
+ # "_id": "123",
679
+ # "name": "Product",
680
+ # "establishment": { // Populated object replaces the ID
681
+ # "_id": "est_123",
682
+ # "name": "Main Office"
683
+ # }
684
+ # // establishment_id is NOT here
685
+ # }]
686
+ # }
687
+ ```
688
+
689
+ **To keep both the ID and populated object, explicitly request it in `fields`:**
690
+
691
+ ```python
692
+ query = {
693
+ "populate": ["establishment"],
694
+ "fields": ["name", "establishment_id"] # Request the ID explicitly
695
+ }
696
+
697
+ # Response - both ID and populated object are present:
698
+ # {
699
+ # "data": [{
700
+ # "_id": "123",
701
+ # "name": "Product",
702
+ # "establishment_id": "est_123", // Now included
703
+ # "establishment": {
704
+ # "_id": "est_123",
705
+ # "name": "Main Office"
706
+ # }
707
+ # }]
708
+ # }
709
+ ```
710
+
711
+ **For audit fields:** The original ID is always excluded (no option to keep it, since there's no `creation_user_id` field).
712
+
626
713
  ### Custom Pipelines
627
714
 
628
715
  ```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.9",
5
+ version="1.2.1",
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",