Skip to content

Commit 7dc4e63

Browse files
committed
refactor: unify HierarchyNode and ChildHierarchyNode also tweaked background writer queue type and use in orphaned records implementation
1 parent a3cafe7 commit 7dc4e63

9 files changed

Lines changed: 125 additions & 83 deletions

File tree

‎docs/advanced_guidance/json_schemas/entity_relationships.schema.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
"mandatory": {
2121
"type": "boolean"
2222
},
23-
"orphaned_records_error_code": {
23+
"missing_parent_id_error_code": {
2424
"type": "string"
2525
},
26-
"orphaned_records_error_message": {
26+
"missing_parent_id_error_message": {
2727
"type": "string"
2828
}
2929
},

‎src/dve/common/error_utils.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66
from collections.abc import Iterable
77
from itertools import chain
8-
from multiprocessing import Queue
8+
from queue import Queue
99
from threading import Thread
1010
from typing import Optional, Union
1111

‎src/dve/core_engine/backends/base/rules.py‎

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
)
4747
from dve.core_engine.backends.types import Entities, EntityType, StageSuccessful
4848
from dve.core_engine.configuration.v1.hierarchy import (
49-
ChildHierarchyNode,
5049
EntityHierarchy,
5150
HierarchyNode,
5251
)
@@ -390,7 +389,7 @@ def identify_and_remove_orphans(
390389
"""
391390

392391
def process_node(
393-
node: HierarchyNode | ChildHierarchyNode,
392+
node: HierarchyNode,
394393
parent_entity_name: Optional[EntityName],
395394
orph_messages: Messages | None = None,
396395
):
@@ -400,7 +399,7 @@ def process_node(
400399
if orph_messages is None:
401400
orph_messages = []
402401

403-
if isinstance(node, ChildHierarchyNode) and parent_entity_name is not None:
402+
if parent_entity_name is not None:
404403
self.logger.info(f"Identifying orphans in {current_entity_name}")
405404

406405
join_expr = " AND ".join(
@@ -419,7 +418,7 @@ def process_node(
419418
)
420419

421420
if no_orphs > 0:
422-
self.logger.info(f"Removing orphan records from {current_entity_name}")
421+
self.logger.info(f"Removing records with missing parent from {current_entity_name}")
423422
location = list(node.join_fields.values())[0]
424423
with BackgroundMessageWriter(
425424
working_directory=working_directory,
@@ -433,32 +432,32 @@ def process_node(
433432
entity_name=current_entity_name,
434433
reporting=ReportingConfig(
435434
emit="record_failure",
436-
code=node.orphaned_records_error_code,
437-
message=node.orphaned_records_error_message,
435+
code=node.missing_parent_id_error_code,
436+
message=node.missing_parent_id_error_message,
438437
location=location,
439438
)
440439
)
441440
)
442-
for record in _orph_records:
443-
msg_writer.write_queue.put([
444-
FeedbackMessage(
445-
entity=current_entity_name,
446-
record=record, # type: ignore
447-
error_location=location,
448-
error_message=node.orphaned_records_error_message,
449-
failure_type="record",
450-
error_type="record",
451-
error_code=node.orphaned_records_error_code,
452-
reporting_field=location,
453-
category="Parent Missing",
454-
)
455-
])
441+
# moved to batch the write - risky if large number of
442+
msg_writer.write_queue.put([
443+
FeedbackMessage(
444+
entity=current_entity_name,
445+
record=record, # type: ignore
446+
error_location=location,
447+
error_message=node.missing_parent_id_error_message,
448+
failure_type="record",
449+
error_type="record",
450+
error_code=node.missing_parent_id_error_code,
451+
reporting_field=location,
452+
category="Parent Missing",
453+
)
454+
for record in _orph_records ])
456455

457456
if node.children:
458457
for child_node in node.children:
459458
process_node(child_node, current_entity_name, orph_messages)
460459

461-
460+
# would a root ever be orphaned?
462461
for root_node in entity_hierarchy.entity_trees.values():
463462
process_node(root_node, parent_entity_name=None)
464463

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

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import json
44
from typing import Any, Optional, Type, Union
55

6-
from pydantic import BaseModel, Field, PrivateAttr, validate_call
6+
from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator, validate_call
7+
from pydantic_core.core_schema import FieldValidationInfo
78
from typing_extensions import Literal
89

910
from dve.core_engine.backends.base.reference_data import ReferenceConfig, ReferenceConfigUnion
@@ -93,22 +94,47 @@ class _TypeAliasDefinition(_BaseTypeDefintion):
9394
class _LinkageConfig(BaseModel):
9495
"""Specify how to link entities back to parents if required"""
9596

96-
parent_entity: EntityName
97+
parent_entity: Optional[EntityName] = None
9798
"""The name of the parent entity"""
98-
join_fields: JoinFields
99+
join_fields: JoinFields = Field(default_factory=dict)
99100
"""The fields that can be used to link back to the parent entity"""
100-
mandatory: Optional[bool] = False
101+
is_root_entity: bool = False
102+
"""Whether the entity is the highest level parent in a tree"""
103+
mandatory: bool = False
101104
"""If the entity is a child, is it a mandatory field of the parent"""
102105
no_valid_records_error_code: Optional[ErrorCode] = "NoValidRecords"
103106
"""The error code to emit if the entity has no valid records and is mandatory in the parent entity""" # pylint: disable=C0301
104107
no_valid_records_error_message: Optional[ErrorMessage] = (
105108
"parent record removed as no valid child records"
106-
)
109+
)
107110
"""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"
111+
missing_parent_id_error_code: Optional[ErrorCode] = "MissingParentRecord"
109112
"""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"
113+
missing_parent_id_error_message: Optional[ErrorMessage] = (
114+
"Records removed due to no valid parent record"
115+
)
111116
"""The error code to emit if the entity contains records that are orphaned by parent record rejections""" # pylint: disable=C0301
117+
118+
@model_validator(mode="after")
119+
def _check_root_no_parent_or_join_keys(self):
120+
if self.is_root_entity:
121+
if self.parent_entity or self.join_fields:
122+
raise ValueError("If entity is root, neither parent_entity nor join keys should be specified")
123+
return self
124+
125+
@model_validator(mode="after")
126+
def _check_root_mandatory(self):
127+
if self.is_root_entity:
128+
if not self.mandatory:
129+
raise ValueError("If entity is root, it must be labelled mandatory")
130+
return self
131+
132+
@model_validator(mode="after")
133+
def _check_parent_entity_with_join_keys(self):
134+
if self.parent_entity or self.join_fields:
135+
if not (self.parent_entity and self.join_fields):
136+
raise ValueError("Both parent_entity and join_fields must be supplied if one is")
137+
return self
112138

113139

114140
class _SchemaConfig(BaseModel):

‎src/dve/core_engine/configuration/v1/hierarchy.py‎

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
from typing import Any, Iterable, Optional, Union
55

6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, Field, field_validator
77

88
from dve.core_engine.configuration.v1 import V1EngineConfig, _LinkageConfig
99
from dve.core_engine.type_hints import EntityName, ErrorCode, ErrorMessage
@@ -16,8 +16,18 @@ class HierarchyNode(BaseModel):
1616
"""Stores entity hierarchy information"""
1717

1818
entity_name: str
19-
children: Optional[list["ChildHierarchyNode"]] = Field(default_factory=list)
20-
19+
children: Optional[list["HierarchyNode"]] = Field(default_factory=list)
20+
mandatory: Optional[bool] = False
21+
join_fields: Optional[dict[str, str]] = Field(default_factory=dict)
22+
no_valid_records_error_code: Optional[ErrorCode] = "NoValidRecords"
23+
no_valid_records_error_message: Optional[ErrorMessage] = (
24+
"parent record removed as no valid child records"
25+
)
26+
missing_parent_id_error_code: Optional[ErrorCode] = "MissingParentRecord"
27+
missing_parent_id_error_message: Optional[ErrorMessage] = (
28+
"Records removed due to no valid parent record"
29+
)
30+
2131
def get_descendents(self) -> list[str]:
2232
"""Recursively list all descendents of the node"""
2333
descendents = []
@@ -58,19 +68,6 @@ def as_dict(self) -> dict[str, dict[str, Any]]:
5868
return {self.entity_name: ret_dict}
5969

6070

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-
7471
class EntityHierarchy:
7572
"""Determines and stores entity hierarchy information from config"""
7673

@@ -82,11 +79,24 @@ def determine_trees(
8279
all_datasets: Iterable[str], entity_relationships: dict[str, _LinkageConfig]
8380
) -> dict[EntityName, HierarchyNode]:
8481
"""Determine the entity hierarchy trees and store as HierarchyNodes"""
82+
root_entities: dict[str, _LinkageConfig] = dict(filter(lambda x: x[1].is_root_entity,
83+
entity_relationships.items()))
8584
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
85+
entity_name: HierarchyNode(entity_name=entity_name,
86+
**config.model_dump(exclude={"parent_entity",
87+
"missing_parent_id_error_code",
88+
"missing_parent_id_error_message"}),
89+
missing_parent_id_error_code=None,
90+
missing_parent_id_error_message=None)
91+
for entity_name, config in root_entities.items()
8992
}
93+
94+
if default_roots := [ entity_name for entity_name in all_datasets
95+
if not entity_name in entity_relationships]:
96+
for entity_name in default_roots:
97+
top_level_parents[entity_name] = HierarchyNode(entity_name=entity_name,
98+
missing_parent_id_error_code=None,
99+
missing_parent_id_error_message=None)
90100

91101
for name, linkage_detail in entity_relationships.items():
92102
for main_entity, parent_node in top_level_parents.items():
@@ -96,7 +106,7 @@ def determine_trees(
96106
):
97107
parent_node.add_child_node(
98108
linkage_detail.parent_entity,
99-
ChildHierarchyNode(
109+
HierarchyNode(
100110
entity_name=name, **linkage_detail.model_dump(exclude={"parent_entity"})
101111
),
102112
)

‎src/dve/core_engine/constants.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"""The name of the field that can be used to extract the field value that caused
88
a pydantic validation error"""
99

10-
ORPHANED_RECORD_ENTITY_NAME: str = "orphaned_records_tracker"
11-
"""Name to keep track of identified orphaned records"""
10+
ORPHANED_RECORD_ENTITY_NAME: str = "orphaned_record_tracker"
11+
"""Name of entity to keep track of records where there is a missing parent record"""

‎tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@
4040
SemiJoin,
4141
TableUnion,
4242
)
43+
from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME
4344
from dve.core_engine.configuration.v1.hierarchy import (
44-
ChildHierarchyNode, EntityHierarchy, HierarchyNode
45+
EntityHierarchy, HierarchyNode
4546
)
4647
from dve.core_engine.type_hints import MultipleExpressions
4748
from tests.test_core_engine.test_backends.fixtures import (
@@ -630,7 +631,7 @@ def test_identify_orphan_record_single_entity(self):
630631
join_condition="passengers.flight_id = flights.flight_id"
631632
)
632633
)
633-
result = mod_entities["orphaned_records_tracker"]
634+
result = mod_entities[ORPHANED_RECORD_ENTITY_NAME]
634635
assert result.count("*").fetchone()[0] == 1 # type: ignore
635636
assert result.select("entity_name").unique("*").count("*").fetchone()[0] == 1 # type: ignore
636637

@@ -653,10 +654,10 @@ def test_identify_and_remove_orphans(self):
653654
"flights": HierarchyNode(
654655
entity_name="flights",
655656
children=[
656-
ChildHierarchyNode(
657+
HierarchyNode(
657658
entity_name="passengers",
658659
children=[
659-
ChildHierarchyNode(
660+
HierarchyNode(
660661
entity_name="food",
661662
children=[],
662663
join_fields={"passenger_id": "passenger_id"},

0 commit comments

Comments
 (0)