Skip to content

Commit de4e44d

Browse files
authored
refactor: unify HierarchyNode and ChildHierarchyNode also tweaked background writer queue type and use in orphaned records implementation (#153)
1 parent a3cafe7 commit de4e44d

10 files changed

Lines changed: 129 additions & 77 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: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,7 @@
4545
TableUnion,
4646
)
4747
from dve.core_engine.backends.types import Entities, EntityType, StageSuccessful
48-
from dve.core_engine.configuration.v1.hierarchy import (
49-
ChildHierarchyNode,
50-
EntityHierarchy,
51-
HierarchyNode,
52-
)
48+
from dve.core_engine.configuration.v1.hierarchy import EntityHierarchy, HierarchyNode
5349
from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME
5450
from dve.core_engine.exceptions import CriticalProcessingError
5551
from dve.core_engine.loggers import get_logger
@@ -390,7 +386,7 @@ def identify_and_remove_orphans(
390386
"""
391387

392388
def process_node(
393-
node: HierarchyNode | ChildHierarchyNode,
389+
node: HierarchyNode,
394390
parent_entity_name: Optional[EntityName],
395391
orph_messages: Messages | None = None,
396392
):
@@ -400,7 +396,7 @@ def process_node(
400396
if orph_messages is None:
401397
orph_messages = []
402398

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

406402
join_expr = " AND ".join(
@@ -419,7 +415,9 @@ def process_node(
419415
)
420416

421417
if no_orphs > 0:
422-
self.logger.info(f"Removing orphan records from {current_entity_name}")
418+
self.logger.info(
419+
f"Removing records with missing parent from {current_entity_name}"
420+
)
423421
location = list(node.join_fields.values())[0]
424422
with BackgroundMessageWriter(
425423
working_directory=working_directory,
@@ -433,32 +431,34 @@ def process_node(
433431
entity_name=current_entity_name,
434432
reporting=ReportingConfig(
435433
emit="record_failure",
436-
code=node.orphaned_records_error_code,
437-
message=node.orphaned_records_error_message,
434+
code=node.missing_parent_id_error_code,
435+
message=node.missing_parent_id_error_message,
438436
location=location,
439-
)
440-
)
437+
),
438+
),
441439
)
442-
for record in _orph_records:
443-
msg_writer.write_queue.put([
440+
# moved to batch the write - risky if large number of
441+
msg_writer.write_queue.put(
442+
[
444443
FeedbackMessage(
445444
entity=current_entity_name,
446445
record=record, # type: ignore
447446
error_location=location,
448-
error_message=node.orphaned_records_error_message,
447+
error_message=node.missing_parent_id_error_message,
449448
failure_type="record",
450449
error_type="record",
451-
error_code=node.orphaned_records_error_code,
450+
error_code=node.missing_parent_id_error_code,
452451
reporting_field=location,
453452
category="Parent Missing",
454453
)
455-
])
454+
for record in _orph_records
455+
]
456+
)
456457

457458
if node.children:
458459
for child_node in node.children:
459460
process_node(child_node, current_entity_name, orph_messages)
460461

461-
462462
for root_node in entity_hierarchy.entity_trees.values():
463463
process_node(root_node, parent_entity_name=None)
464464

src/dve/core_engine/backends/metadata/rules.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ class OrphanIdentification(AbstractConditionalJoin):
553553
554554
"""
555555

556+
556557
Step = Union[AbstractStep, Literal["sync"]]
557558
"""A step within a rule. This is either a rule config or the literal string 'sync'."""
558559

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

Lines changed: 32 additions & 6 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,23 +94,48 @@ 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"
106109
)
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
112117

118+
@model_validator(mode="after")
119+
def _check_root_no_parent_or_join_keys(self):
120+
if self.is_root_entity and (self.parent_entity or self.join_fields):
121+
raise ValueError(
122+
"If entity is root, neither parent_entity nor join keys should be specified"
123+
)
124+
return self
125+
126+
@model_validator(mode="after")
127+
def _check_root_mandatory(self):
128+
if self.is_root_entity and 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
138+
113139

114140
class _SchemaConfig(BaseModel):
115141
"""Configuration for a component schema within a dataset."""

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

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ class HierarchyNode(BaseModel):
1616
"""Stores entity hierarchy information"""
1717

1818
entity_name: str
19-
children: Optional[list["ChildHierarchyNode"]] = Field(default_factory=list)
19+
children: list["HierarchyNode"] = Field(default_factory=list)
20+
mandatory: bool = False
21+
join_fields: dict[str, str] = Field(default_factory=dict)
22+
no_valid_records_error_code: ErrorCode = "NoValidRecords"
23+
no_valid_records_error_message: ErrorMessage = "parent record removed as no valid child records"
24+
missing_parent_id_error_code: Optional[ErrorCode] = "MissingParentRecord"
25+
missing_parent_id_error_message: Optional[ErrorMessage] = (
26+
"Records removed due to no valid parent record"
27+
)
2028

2129
def get_descendents(self) -> list[str]:
2230
"""Recursively list all descendents of the node"""
@@ -58,19 +66,6 @@ def as_dict(self) -> dict[str, dict[str, Any]]:
5866
return {self.entity_name: ret_dict}
5967

6068

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-
7469
class EntityHierarchy:
7570
"""Determines and stores entity hierarchy information from config"""
7671

@@ -82,12 +77,35 @@ def determine_trees(
8277
all_datasets: Iterable[str], entity_relationships: dict[str, _LinkageConfig]
8378
) -> dict[EntityName, HierarchyNode]:
8479
"""Determine the entity hierarchy trees and store as HierarchyNodes"""
80+
root_entities: dict[str, _LinkageConfig] = dict(
81+
filter(lambda x: x[1].is_root_entity, entity_relationships.items())
82+
)
8583
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
84+
entity_name: HierarchyNode(
85+
entity_name=entity_name,
86+
**config.model_dump(
87+
exclude={
88+
"parent_entity",
89+
"missing_parent_id_error_code",
90+
"missing_parent_id_error_message",
91+
}
92+
),
93+
missing_parent_id_error_code=None,
94+
missing_parent_id_error_message=None,
95+
)
96+
for entity_name, config in root_entities.items()
8997
}
9098

99+
if default_roots := [
100+
entity_name for entity_name in all_datasets if entity_name not in entity_relationships
101+
]:
102+
for entity_name in default_roots:
103+
top_level_parents[entity_name] = HierarchyNode(
104+
entity_name=entity_name,
105+
missing_parent_id_error_code=None,
106+
missing_parent_id_error_message=None,
107+
)
108+
91109
for name, linkage_detail in entity_relationships.items():
92110
for main_entity, parent_node in top_level_parents.items():
93111
if (
@@ -96,7 +114,7 @@ def determine_trees(
96114
):
97115
parent_node.add_child_node(
98116
linkage_detail.parent_entity,
99-
ChildHierarchyNode(
117+
HierarchyNode(
100118
entity_name=name, **linkage_detail.model_dump(exclude={"parent_entity"})
101119
),
102120
)

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)