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.
- {oplnk_python_utils-1.1.9/oplnk_python_utils.egg-info → oplnk_python_utils-1.2.1}/PKG-INFO +88 -1
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/README.md +87 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/__init__.py +1 -1
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/query/query_builder.py +223 -74
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1/oplnk_python_utils.egg-info}/PKG-INFO +88 -1
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/setup.py +1 -1
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/LICENSE +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/MANIFEST.in +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/cache/__init__.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/cache/cache_utils.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/__init__.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/data.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/parsing.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/serialization.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/query/__init__.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/reports/__init__.py +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/requires.txt +0 -0
- {oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/top_level.txt +0 -0
- {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
|
|
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
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/query/query_builder.py
RENAMED
|
@@ -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
|
-
|
|
772
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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"${
|
|
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":
|
|
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"${
|
|
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
|
|
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
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/cache/cache_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/core/serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils/reports/__init__.py
RENAMED
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/not-zip-safe
RENAMED
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/requires.txt
RENAMED
|
File without changes
|
{oplnk_python_utils-1.1.9 → oplnk_python_utils-1.2.1}/oplnk_python_utils.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|