Skip to content

Commit dff7e0b

Browse files
committed
feat: added proposals for new models and objects to store entity hierarchy information
1 parent 01cfa16 commit dff7e0b

5 files changed

Lines changed: 249 additions & 75 deletions

File tree

src/dve/core_engine/backends/implementations/spark/contract.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,9 @@ def apply_data_contract(
156156
fld, fld_info.annotation
157157
).alias(fld)
158158
if fld in record_df.columns
159-
else lit(None).cast(
160-
get_type_from_annotation(fld_info.annotation)).alias(fld)
159+
else lit(None)
160+
.cast(get_type_from_annotation(fld_info.annotation))
161+
.alias(fld)
161162
)
162163
for fld, fld_info in entity_fields.items()
163164
],

src/dve/core_engine/configuration/v1/__init__.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@
2020
BusinessFilterSpecConfig,
2121
BusinessRuleSpecConfig,
2222
)
23-
from dve.core_engine.configuration.v1.hierarchy import HierarchyNode, ChildHierarchyNode
2423
from dve.core_engine.configuration.v1.steps import StepConfigUnion
2524
from dve.core_engine.message import DataContractErrorDetail
26-
from dve.core_engine.type_hints import EntityName, ErrorCategory, ErrorType, TemplateVariables
25+
from dve.core_engine.type_hints import (
26+
EntityName,
27+
ErrorCategory,
28+
ErrorCode,
29+
ErrorMessage,
30+
ErrorType,
31+
TemplateVariables,
32+
)
2733
from dve.core_engine.validation import RowValidator
28-
from dve.metadata_parser.exc import EntityNotFoundError
2934
from dve.parser.file_handling import joinuri, open_stream, resolve_location
3035
from dve.parser.type_hints import URI, Extension
3136

@@ -40,8 +45,8 @@
4045

4146
FieldName = str
4247
"""The name of a field within a model/schema."""
43-
JoinFields = Optional[list[str]]
44-
"""The fields required to join a child entity back to the parent"""
48+
JoinFields = Optional[dict[str, str]]
49+
"""The fields required ( parent > child ) to join a child entity back to the parent"""
4550
TypeOrDef = Union[ # pylint: disable=C0103
4651
TypeName, "_CallableTypeDefinition", "_ModelTypeDefinition", "_TypeAliasDefinition"
4752
]
@@ -87,12 +92,23 @@ class _TypeAliasDefinition(_BaseTypeDefintion):
8792

8893
class _LinkageConfig(BaseModel):
8994
"""Specify how to link entities back to parents if required"""
95+
9096
parent_entity: EntityName
9197
"""The name of the parent entity"""
9298
join_fields: JoinFields
9399
"""The fields that can be used to link back to the parent entity"""
94100
mandatory: Optional[bool] = False
95101
"""If the entity is a child, is it a mandatory field of the parent"""
102+
no_valid_records_error_code: Optional[ErrorCode] = "NoValidRecords"
103+
"""The error code to emit if the entity has no valid records and is mandatory in the parent entity""" # pylint: disable=C0301
104+
no_valid_records_error_message: Optional[ErrorMessage] = (
105+
"parent record removed as no valid child records"
106+
)
107+
"""The error message to emit if the entity has no valid records and is mandatory in the parent entity""" # pylint: disable=C0301
108+
orphaned_records_error_code: Optional[ErrorCode] = "OrphanedRecords"
109+
"""The error code to emit if the entity contains records that are orphaned by parent record rejections""" # pylint: disable=C0301
110+
orphaned_records_error_message: Optional[ErrorMessage] = "Orphaned records removed"
111+
"""The error code to emit if the entity contains records that are orphaned by parent record rejections""" # pylint: disable=C0301
96112

97113

98114
class _SchemaConfig(BaseModel):
@@ -345,7 +361,7 @@ def get_contract_metadata(self) -> DataContractMetadata:
345361
reader_metadata=reader_metadata,
346362
validators=validators,
347363
reporting_fields=reporting_fields,
348-
cache_originals=self.contract.cache_originals
364+
cache_originals=self.contract.cache_originals,
349365
)
350366

351367
def load_error_message_info(self, uri):
@@ -367,24 +383,3 @@ def get_rule_metadata(self) -> RuleMetadata:
367383
global_variables=self.transformations.parameters, # pylint: disable=E1101
368384
reference_data_config=self.get_reference_data_config(),
369385
)
370-
371-
def get_entity_hierarchy(self) -> dict[str, HierarchyNode]:
372-
"""Determine the linkage hierarchy using contact config"""
373-
top_level_parents = {
374-
entity_name: HierarchyNode(entity_name=entity_name)
375-
for entity_name in self.contract.datasets
376-
if not entity_name in self.entity_relationships
377-
}
378-
379-
for name, linkage_detail in self.entity_relationships.items():
380-
for main_entity, details in top_level_parents.items():
381-
if (linkage_detail.parent_entity == main_entity
382-
or linkage_detail.parent_entity in details.get_descendents()):
383-
top_level_parents[main_entity].add_child_node(linkage_detail.parent_entity,
384-
ChildHierarchyNode(entity_name=name,
385-
join_fields=linkage_detail.join_fields,
386-
mandatory=linkage_detail.mandatory))
387-
break
388-
else:
389-
raise EntityNotFoundError(f"Can't find parent entity {linkage_detail.parent_entity} defined to establish hierarchy for {name} - please ensure it is defined above any child entities in the dischema.")
390-
return top_level_parents
Lines changed: 99 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,131 @@
11
"""Classes to help determine and store entity hierarchy information."""
22

3-
from typing import Any, Optional, Union
3+
import json
4+
from typing import Any, Iterable, Optional, Union
5+
46
from pydantic import BaseModel, Field
7+
8+
from dve.core_engine.configuration.v1 import V1EngineConfig, _LinkageConfig
9+
from dve.core_engine.type_hints import EntityName, ErrorCode, ErrorMessage
510
from dve.metadata_parser.exc import EntityNotFoundError
11+
from dve.parser.file_handling.service import open_stream
12+
from dve.parser.type_hints import URI
13+
614

715
class HierarchyNode(BaseModel):
816
"""Stores entity hierarchy information"""
17+
918
entity_name: str
10-
mandatory: Optional[bool] = False
11-
children: Optional[list["HierarchyNode"]] = Field(default_factory=list)
12-
19+
children: list["HierarchyNode"] = Field(default_factory=list)
20+
1321
def get_descendents(self) -> list[str]:
1422
"""Recursively list all descendents of the node"""
1523
descendents = []
1624
for node in self.children:
1725
descendents.append(node.entity_name)
1826
descendents.extend(node.get_descendents())
1927
return descendents
20-
21-
def get_node(self, entity_name:str) -> Union["HierarchyNode", None]:
28+
29+
def get_node(self, entity_name: str) -> Union["HierarchyNode", None]:
2230
"""Recursively search for node and return if found"""
2331
node = None
2432
if self.entity_name == entity_name:
2533
return self
26-
else:
27-
for child in self.children:
28-
node = child.get_node(entity_name)
29-
if node:
30-
break
34+
for child in self.children:
35+
node = child.get_node(entity_name)
36+
if node:
37+
break
3138
return node
32-
39+
3340
def add_child_node(self, parent_entity: str, child_info: "HierarchyNode") -> None:
3441
"""Add a child node if the parent exists in the hierarchy"""
3542
try:
36-
self.get_node(parent_entity).children.append(child_info)
37-
except AttributeError:
38-
raise EntityNotFoundError(f"Can't find parent node {parent_entity} in {self.entity_name}")
39-
43+
self.get_node(parent_entity).children.append(child_info) # type: ignore
44+
except AttributeError as exc:
45+
raise EntityNotFoundError(
46+
f"Can't find parent node {parent_entity} in {self.entity_name}"
47+
) from exc
48+
4049
def as_dict(self) -> dict[str, dict[str, Any]]:
4150
"""Get dictionary representation of entity hierarchy"""
4251
child_dict = {}
4352
for node in self.children:
44-
child_dict.update(node.as_dict())
45-
46-
ret_dict = {"children": child_dict,
47-
"mandatory": self.mandatory}
48-
if hasattr(self, "join_fields"):
49-
ret_dict |= {"join_fields": self.join_fields}
50-
53+
child_dict.update(node.as_dict())
54+
55+
ret_dict = self.model_dump(exclude={"entity_name", "children"})
56+
ret_dict.update({"children": child_dict})
57+
5158
return {self.entity_name: ret_dict}
5259

5360

5461
class ChildHierarchyNode(HierarchyNode):
5562
"""Stores child entity hierarchy information"""
56-
join_fields: list[str]
63+
64+
join_fields: dict[str, str]
65+
mandatory: Optional[bool] = False
66+
no_valid_records_error_code: Optional[ErrorCode] = "NoValidRecords"
67+
no_valid_records_error_message: Optional[ErrorMessage] = (
68+
"parent record removed as no valid child records"
69+
)
70+
orphaned_records_error_code: Optional[ErrorCode] = "OrphanedRecords"
71+
orphaned_records_error_message: Optional[ErrorMessage] = "Orphaned records removed"
72+
73+
74+
class EntityHierarchy:
75+
"""Determines and stores entity hierarchy information from config"""
76+
77+
def __init__(self, entity_trees: dict[EntityName, HierarchyNode]):
78+
self.entity_trees = entity_trees
79+
80+
@staticmethod
81+
def determine_trees(
82+
all_datasets: Iterable[str], entity_relationships: dict[str, _LinkageConfig]
83+
) -> dict[EntityName, HierarchyNode]:
84+
"""Determine the entity hierarchy trees and store as HierarchyNodes"""
85+
top_level_parents: dict[EntityName, HierarchyNode] = {
86+
entity_name: HierarchyNode(entity_name=entity_name)
87+
for entity_name in all_datasets
88+
if not entity_name in entity_relationships
89+
}
90+
91+
for name, linkage_detail in entity_relationships.items():
92+
for main_entity, parent_node in top_level_parents.items():
93+
if (
94+
linkage_detail.parent_entity == main_entity
95+
or linkage_detail.parent_entity in parent_node.get_descendents()
96+
):
97+
parent_node.add_child_node(
98+
linkage_detail.parent_entity,
99+
ChildHierarchyNode(
100+
entity_name=name, **linkage_detail.model_dump(exclude={"parent_entity"})
101+
),
102+
)
103+
break
104+
else:
105+
raise EntityNotFoundError(
106+
f"Can't find parent entity {linkage_detail.parent_entity} defined to "
107+
+ f"establish hierarchy for {name} - please ensure it is defined above "
108+
+ "any child entities in the dischema."
109+
)
110+
return top_level_parents
111+
112+
@classmethod
113+
def from_dischema(cls, dischema_uri: URI):
114+
"""Create entity hierarchy direct from dischema"""
115+
with open_stream(dischema_uri) as dischema:
116+
config_dict = json.load(dischema)
117+
all_datasets = config_dict.get("contract", {}).get("datasets", {}).keys()
118+
entity_relationships = {
119+
k: _LinkageConfig(**v) for k, v in config_dict.get("entity_relationships", {}).items()
120+
}
121+
return cls(entity_trees=cls.determine_trees(all_datasets, entity_relationships))
122+
123+
@classmethod
124+
def from_engine_config(cls, engine_config: V1EngineConfig):
125+
"""Create entity hierarchy direct from engine config"""
126+
return cls(
127+
entity_trees=cls.determine_trees(
128+
all_datasets=engine_config.contract.datasets.keys(),
129+
entity_relationships=engine_config.entity_relationships,
130+
)
131+
)

src/dve/metadata_parser/exc.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33

44
class EntityNotFoundError(KeyError):
55
"""Error for missing entities"""
6-
6+
7+
78
class LocWarning(UserWarning):
89
"""Warning class with optional location parameter"""
910

0 commit comments

Comments
 (0)