Skip to content

Commit 4bc4295

Browse files
Merge branch 'release_v010' of https://github.com/NHSDigital/data-validation-engine into feature/gr-ndit-1761-add_parquet_reader
2 parents b492072 + de08575 commit 4bc4295

5 files changed

Lines changed: 564 additions & 2 deletions

File tree

docs/advanced_guidance/json_schemas/dataset.schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
},
1111
"transformations": {
1212
"$ref": "transformations/transformations.schema.json"
13+
},
14+
"entity_relationships": {
15+
"$ref": "entity_relationships.schema.json"
1316
}
1417
},
1518
"required": [
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "data-ingest:entity_relationships.schema.json",
4+
"title": "entity_relationships",
5+
"description": "Description of relationships to link normalised entities back to parent entities.",
6+
"type": "object",
7+
"patternProperties": {
8+
"^[A-Za-z0-9_]+.$": {
9+
"type": "object",
10+
"properties": {
11+
"parent_entity": {
12+
"type": "string"
13+
},
14+
"join_fields": {
15+
"type": "object",
16+
"additionalProperties": {
17+
"type": "string"
18+
}
19+
},
20+
"mandatory": {
21+
"type": "boolean"
22+
},
23+
"orphaned_records_error_code": {
24+
"type": "string"
25+
},
26+
"orphaned_records_error_message": {
27+
"type": "string"
28+
}
29+
},
30+
"required": [
31+
"parent_entity",
32+
"join_fields"
33+
],
34+
"additionalProperties": false
35+
}
36+
}
37+
}

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""The loader for the first JSON-based dataset configuration."""
22

33
import json
4-
from typing import Any, Optional, Union
4+
from typing import Any, Optional, Type, Union
55

66
from pydantic import BaseModel, Field, PrivateAttr, validate_call
77
from typing_extensions import Literal
@@ -22,7 +22,14 @@
2222
)
2323
from dve.core_engine.configuration.v1.steps import StepConfigUnion
2424
from dve.core_engine.message import DataContractErrorDetail
25-
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+
)
2633
from dve.core_engine.validation import RowValidator
2734
from dve.parser.file_handling import joinuri, open_stream, resolve_location
2835
from dve.parser.type_hints import URI, Extension
@@ -38,6 +45,8 @@
3845

3946
FieldName = str
4047
"""The name of a field within a model/schema."""
48+
JoinFields = Optional[dict[str, str]]
49+
"""The fields required ( parent > child ) to join a child entity back to the parent"""
4150
TypeOrDef = Union[ # pylint: disable=C0103
4251
TypeName, "_CallableTypeDefinition", "_ModelTypeDefinition", "_TypeAliasDefinition"
4352
]
@@ -81,6 +90,27 @@ class _TypeAliasDefinition(_BaseTypeDefintion):
8190
"""The name of the Python type."""
8291

8392

93+
class _LinkageConfig(BaseModel):
94+
"""Specify how to link entities back to parents if required"""
95+
96+
parent_entity: EntityName
97+
"""The name of the parent entity"""
98+
join_fields: JoinFields
99+
"""The fields that can be used to link back to the parent entity"""
100+
mandatory: Optional[bool] = False
101+
"""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
112+
113+
84114
class _SchemaConfig(BaseModel):
85115
"""Configuration for a component schema within a dataset."""
86116

@@ -177,6 +207,8 @@ class V1EngineConfig(BaseEngineConfig):
177207
default_factory=dict
178208
)
179209
"""Rule store rules from the loaded rule stores."""
210+
entity_relationships: dict[EntityName, _LinkageConfig] = Field(default_factory=dict)
211+
"""The parent-child relationships linking the defined entities"""
180212

181213
@validate_call
182214
def _update_rule_store(self, rule_store: dict[RuleName, BusinessComponentSpecConfigUnion]):
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Classes to help determine and store entity hierarchy information."""
2+
3+
import json
4+
from typing import Any, Iterable, Optional, Union
5+
6+
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
10+
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+
14+
15+
class HierarchyNode(BaseModel):
16+
"""Stores entity hierarchy information"""
17+
18+
entity_name: str
19+
children: list["HierarchyNode"] = Field(default_factory=list)
20+
21+
def get_descendents(self) -> list[str]:
22+
"""Recursively list all descendents of the node"""
23+
descendents = []
24+
for node in self.children:
25+
descendents.append(node.entity_name)
26+
descendents.extend(node.get_descendents())
27+
return descendents
28+
29+
def get_node(self, entity_name: str) -> Union["HierarchyNode", None]:
30+
"""Recursively search for node and return if found"""
31+
node = None
32+
if self.entity_name == entity_name:
33+
return self
34+
for child in self.children:
35+
node = child.get_node(entity_name)
36+
if node:
37+
break
38+
return node
39+
40+
def add_child_node(self, parent_entity: str, child_info: "HierarchyNode") -> None:
41+
"""Add a child node if the parent exists in the hierarchy"""
42+
try:
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+
49+
def as_dict(self) -> dict[str, dict[str, Any]]:
50+
"""Get dictionary representation of entity hierarchy"""
51+
child_dict = {}
52+
for node in self.children:
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+
58+
return {self.entity_name: ret_dict}
59+
60+
61+
class ChildHierarchyNode(HierarchyNode):
62+
"""Stores child entity hierarchy information"""
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 entity_name not 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+
)

0 commit comments

Comments
 (0)