Skip to content

Commit d55a63f

Browse files
feat: add orphan record identification and removal
1 parent 5c1e11b commit d55a63f

16 files changed

Lines changed: 785 additions & 125 deletions

File tree

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

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from abc import ABC, abstractmethod
55
from collections import defaultdict
6-
from collections.abc import Iterable
6+
from collections.abc import Iterable, Iterator
77
from typing import Any, ClassVar, Generic, NoReturn, Optional, TypeVar
88
from uuid import uuid4
99

@@ -17,6 +17,7 @@
1717
)
1818
from dve.core_engine.backends.base.core import get_entity_type
1919
from dve.core_engine.backends.exceptions import render_error
20+
from dve.core_engine.backends.metadata.reporting import ReportingConfig
2021
from dve.core_engine.backends.metadata.rules import (
2122
AbstractStep,
2223
Aggregation,
@@ -34,6 +35,7 @@
3435
Notification,
3536
OneToOneJoin,
3637
OrphanIdentification,
38+
OrphanRemoval,
3739
ParentMetadata,
3840
RenameEntity,
3941
Rule,
@@ -43,8 +45,15 @@
4345
TableUnion,
4446
)
4547
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+
)
53+
from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME
4654
from dve.core_engine.exceptions import CriticalProcessingError
4755
from dve.core_engine.loggers import get_logger
56+
from dve.core_engine.message import FeedbackMessage
4857
from dve.core_engine.type_hints import URI, DVEStageName, EntityName, Messages, TemplateVariables
4958

5059
T_contra = TypeVar("T_contra", bound=AbstractStep, contravariant=True)
@@ -307,7 +316,10 @@ def join_header(self, entities: Entities, *, config: HeaderJoin) -> Messages:
307316
"""
308317
raise NotImplementedError
309318

310-
def identify_orphans(self, entities: Entities, *, config: OrphanIdentification) -> Messages:
319+
@abstractmethod
320+
def identify_orphans(
321+
self, entities: Entities, *, config: OrphanIdentification
322+
) -> tuple[Messages, int]:
311323
"""Identify records in an entity which don't have at least one corresponding
312324
match in the target. A new boolean column will be added to `entity` ('IsOrphaned')
313325
indicating whether the condition matched.
@@ -320,6 +332,18 @@ def identify_orphans(self, entities: Entities, *, config: OrphanIdentification)
320332
"""
321333
raise NotImplementedError
322334

335+
@abstractmethod
336+
def remove_orphans(self, entities: Entities, *, config: OrphanRemoval) -> Iterator:
337+
"""
338+
Remove orphaned records from an entity based on the orphans found in
339+
identify_orphans method. Returns a generator objects with the records removed
340+
for generating feedback messages from.
341+
342+
This may not be implemented by some backends.
343+
344+
"""
345+
raise NotImplementedError
346+
323347
@abstractmethod
324348
def union(self, entities: Entities, *, config: TableUnion) -> Messages:
325349
"""Union two entities together, taking the columns from each by name.
@@ -352,6 +376,100 @@ def notify(self, entities: Entities, *, config: Notification) -> Messages:
352376
353377
"""
354378

379+
def identify_and_remove_orphans(
380+
self,
381+
working_directory: URI,
382+
entities: Entities,
383+
entity_hierarchy: EntityHierarchy,
384+
key_fields: Optional[dict[str, list[str]]] = None,
385+
) -> Messages:
386+
"""
387+
Identifies and removes orphan records by traversing the EntityHierarchy object.
388+
An orphan is a child record whose parent FK does not exist in the parent entity.
389+
Processes recursively: removes orphans at each level, then processes children.
390+
"""
391+
392+
def process_node(
393+
node: HierarchyNode | ChildHierarchyNode,
394+
parent_entity_name: Optional[EntityName],
395+
orph_messages: Messages | None = None,
396+
):
397+
"""Recursive helper to process a node and its children."""
398+
current_entity_name = node.entity_name
399+
400+
if orph_messages is None:
401+
orph_messages = []
402+
403+
if isinstance(node, ChildHierarchyNode) and parent_entity_name is not None:
404+
self.logger.info(f"Identifying orphans in {current_entity_name}")
405+
406+
join_expr = " AND ".join(
407+
f"{parent_entity_name}.{k} = {current_entity_name}.{v}"
408+
for k, v in node.join_fields.items()
409+
)
410+
411+
_, no_orphs = self.identify_orphans(
412+
entities=entities,
413+
config=OrphanIdentification(
414+
id=list(node.join_fields.values())[0],
415+
entity_name=current_entity_name,
416+
target_name=parent_entity_name,
417+
join_condition=join_expr,
418+
),
419+
)
420+
421+
if no_orphs > 0:
422+
self.logger.info(f"Removing orphan records from {current_entity_name}")
423+
location = list(node.join_fields.values())[0]
424+
with BackgroundMessageWriter(
425+
working_directory=working_directory,
426+
dve_stage=self.__stage_name__,
427+
key_fields=key_fields,
428+
logger=self.logger,
429+
) as msg_writer:
430+
_orph_records = self.remove_orphans(
431+
entities=entities,
432+
config=OrphanRemoval(
433+
entity_name=current_entity_name,
434+
reporting=ReportingConfig(
435+
emit="record_failure",
436+
code=node.orphaned_records_error_code,
437+
message=node.orphaned_records_error_message,
438+
location=location,
439+
)
440+
)
441+
)
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+
])
456+
457+
if node.children:
458+
for child_node in node.children:
459+
process_node(child_node, current_entity_name, orph_messages)
460+
461+
462+
for root_node in entity_hierarchy.entity_trees.values():
463+
process_node(root_node, parent_entity_name=None)
464+
465+
_orph_rel = entities.get(ORPHANED_RECORD_ENTITY_NAME)
466+
if _orph_rel:
467+
del entities[ORPHANED_RECORD_ENTITY_NAME]
468+
469+
entities.update(entities)
470+
471+
return []
472+
355473
# pylint: disable=R0912,R0914
356474
def apply_sync_filters(
357475
self,

src/dve/core_engine/backends/implementations/duckdb/rules.py

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Business rule definitions for duckdb backend"""
22

3-
from collections.abc import Callable
3+
from collections.abc import Callable, Iterator
44
from typing import get_type_hints
55
from uuid import uuid4
66

@@ -50,9 +50,11 @@
5050
Notification,
5151
OneToOneJoin,
5252
OrphanIdentification,
53+
OrphanRemoval,
5354
SemiJoin,
5455
TableUnion,
5556
)
57+
from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME, RECORD_INDEX_COLUMN_NAME
5658
from dve.core_engine.functions import implementations as functions
5759
from dve.core_engine.message import FeedbackMessage
5860
from dve.core_engine.templating import template_object
@@ -375,8 +377,11 @@ def join_header(self, entities: DuckDBEntities, *, config: HeaderJoin) -> Messag
375377
return []
376378

377379
def identify_orphans(
378-
self, entities: DuckDBEntities, *, config: OrphanIdentification
379-
) -> Messages:
380+
self,
381+
entities: DuckDBEntities,
382+
*,
383+
config: OrphanIdentification,
384+
) -> tuple[Messages, int]:
380385
"""Identify records in an entity which don't have at least one corresponding
381386
match in the target. A new boolean column will be added to `entity` ('IsOrphaned')
382387
indicating whether the condition matched.
@@ -390,41 +395,60 @@ def identify_orphans(
390395
target_rel: DuckDBPyRelation = entities[config.target_name]
391396
target_rel = target_rel.set_alias(config.target_name)
392397

393-
key_name = f"key_{uuid4().hex}"
394-
source_rel = source_rel.select(f"*, row_number() over () as {key_name}").set_alias(
395-
config.entity_name
396-
)
397398
match_name = f"matched_{uuid4().hex}"
398399
target_rel = target_rel.select(
399400
StarExpression(exclude=[]), ConstantExpression(1).alias(match_name)
400401
).set_alias(config.target_name)
401402

402-
joined_rel: DuckDBPyRelation = source_rel.join(
403-
target_rel, condition=config.join_condition, how="left"
404-
).aggregate(f"{key_name}, coalesce(count({match_name})==0, TRUE) AS IsOrphaned")
403+
pk, _fk = config.join_condition.split("=")
405404

406-
if "IsOrphaned" not in source_rel.columns:
407-
result: DuckDBPyRelation = source_rel.join(
408-
joined_rel, condition=key_name, how="left"
409-
).select(StarExpression(exclude=[key_name]))
410-
else:
411-
result = source_rel.set_alias("source").join(
412-
joined_rel.set_alias("joined"),
413-
condition=f"source.{key_name} = joined.{key_name}",
414-
how="left",
405+
orphaned_rel: DuckDBPyRelation = (
406+
source_rel.join(target_rel, condition=config.join_condition, how="left")
407+
.aggregate(
408+
f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME}, {config.entity_name}.{config.id}, coalesce(count({match_name}), 0)==0 AS IsOrphaned" # pylint: disable=C0301
415409
)
410+
.filter("IsOrphaned")
411+
.select(
412+
RECORD_INDEX_COLUMN_NAME,
413+
ConstantExpression(config.entity_name).alias("entity_name"),
414+
ConstantExpression(pk.strip().rsplit(".")[1]).alias("pk"),
415+
ColumnExpression(config.id).alias("pk_value"), # type: ignore
416+
)
417+
.unique("*")
418+
)
419+
_orph_records: tuple[int] = orphaned_rel.count(RECORD_INDEX_COLUMN_NAME).fetchone() # type: ignore # pylint: disable=C0301
420+
if _orph_records:
421+
_no_orphans = _orph_records[0]
422+
else:
423+
_no_orphans = 0
424+
self.logger.info(f"Found {_no_orphans} orphaned records in {config.entity_name}.")
416425

417-
columns = {name: f"source.{name}" for name in source_rel.columns}
418-
if "IsOrphaned" in source_rel.columns:
419-
columns["IsOrphaned"] = ColumnExpression("source.IsOrphaned") | ColumnExpression("joined.IsOrphaned") # type: ignore # pylint: disable=line-too-long
420-
columns.pop(key_name, None)
421-
422-
result = result.select(
423-
",".join([f"{column} as {name}" for name, column in columns.items()])
426+
if entities.get(ORPHANED_RECORD_ENTITY_NAME) is not None:
427+
entities[ORPHANED_RECORD_ENTITY_NAME] = entities[ORPHANED_RECORD_ENTITY_NAME].union(
428+
orphaned_rel
424429
)
430+
else:
431+
entities[ORPHANED_RECORD_ENTITY_NAME] = orphaned_rel
432+
return [], _no_orphans
433+
434+
def remove_orphans(self, entities: DuckDBEntities, *, config: OrphanRemoval) -> Iterator:
435+
"""Method to remove identified orphans in the orphan tracker entity."""
436+
orphan_rel = entities[ORPHANED_RECORD_ENTITY_NAME].set_alias("orphan")
437+
filtered_rel = (
438+
entities[config.entity_name]
439+
.set_alias(config.entity_name)
440+
.join(
441+
orphan_rel,
442+
f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME} = orphan.{RECORD_INDEX_COLUMN_NAME}", # pylint: disable=C0301
443+
"anti",
444+
)
445+
)
425446

426-
entities[config.new_entity_name or config.entity_name] = result
427-
return []
447+
entities[config.entity_name] = filtered_rel
448+
449+
return duckdb_rel_to_dictionaries(
450+
orphan_rel.filter(f"entity_name = '{config.entity_name}'")
451+
)
428452

429453
def union(self, entities: DuckDBEntities, *, config: TableUnion) -> Messages:
430454
"""Union two entities together, taking the columns from each by name.

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Step implementations in Spark."""
22

3-
from collections.abc import Callable
3+
from collections.abc import Callable, Iterator
44
from typing import Optional
55
from uuid import uuid4
66

@@ -41,6 +41,7 @@
4141
Notification,
4242
OneToOneJoin,
4343
OrphanIdentification,
44+
OrphanRemoval,
4445
SelectColumns,
4546
SemiJoin,
4647
TableUnion,
@@ -338,7 +339,8 @@ def union(self, entities: SparkEntities, *, config: TableUnion) -> Messages:
338339

339340
def identify_orphans(
340341
self, entities: SparkEntities, *, config: OrphanIdentification
341-
) -> Messages:
342+
) -> tuple[Messages, int]:
343+
# TODO - adjust this to new setup of identify and remove orphans
342344
source_df: DataFrame = entities[config.entity_name]
343345
source_df = source_df.alias(config.entity_name)
344346
target_df: DataFrame = entities[config.target_name]
@@ -371,7 +373,16 @@ def identify_orphans(
371373
result = result.select(*[column.alias(name) for name, column in columns.items()])
372374

373375
entities[config.new_entity_name or config.entity_name] = result
374-
return []
376+
return [], 0
377+
378+
def remove_orphans(
379+
self,
380+
entities: SparkEntities,
381+
*,
382+
config: OrphanRemoval,
383+
) -> Iterator:
384+
# TODO - implement for spark
385+
raise NotImplementedError
375386

376387
def filter(self, entities: SparkEntities, *, config: ImmediateFilter) -> Messages:
377388
"""Filter an entity immediately, and do not emit any messages.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,11 +553,17 @@ class OrphanIdentification(AbstractConditionalJoin):
553553
554554
"""
555555

556-
557556
Step = Union[AbstractStep, Literal["sync"]]
558557
"""A step within a rule. This is either a rule config or the literal string 'sync'."""
559558

560559

560+
class OrphanRemoval(BaseStep):
561+
"""Remove an orphan record from the `entity`."""
562+
563+
reporting: ReportingConfig
564+
"""The reporting information for the row removal."""
565+
566+
561567
class Rule(BaseModel):
562568
"""A rule, made up of multiple steps."""
563569

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

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

1818
entity_name: str
19-
children: list["HierarchyNode"] = Field(default_factory=list)
19+
children: Optional[list["ChildHierarchyNode"]] = Field(default_factory=list)
2020

2121
def get_descendents(self) -> list[str]:
2222
"""Recursively list all descendents of the node"""
2323
descendents = []
24-
for node in self.children:
24+
for node in self.children: # type: ignore
2525
descendents.append(node.entity_name)
2626
descendents.extend(node.get_descendents())
2727
return descendents
@@ -31,7 +31,7 @@ def get_node(self, entity_name: str) -> Union["HierarchyNode", None]:
3131
node = None
3232
if self.entity_name == entity_name:
3333
return self
34-
for child in self.children:
34+
for child in self.children: # type: ignore
3535
node = child.get_node(entity_name)
3636
if node:
3737
break
@@ -48,8 +48,8 @@ def add_child_node(self, parent_entity: str, child_info: "HierarchyNode") -> Non
4848

4949
def as_dict(self) -> dict[str, dict[str, Any]]:
5050
"""Get dictionary representation of entity hierarchy"""
51-
child_dict = {}
52-
for node in self.children:
51+
child_dict: dict[str, dict[str, Any]] = {}
52+
for node in self.children: # type: ignore
5353
child_dict.update(node.as_dict())
5454

5555
ret_dict = self.model_dump(exclude={"entity_name", "children"})

src/dve/core_engine/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@
66
CONTRACT_ERROR_VALUE_FIELD_NAME: str = "__error_value"
77
"""The name of the field that can be used to extract the field value that caused
88
a pydantic validation error"""
9+
10+
ORPHANED_RECORD_ENTITY_NAME: str = "orphaned_records_tracker"
11+
"""Name to keep track of identified orphaned records"""

0 commit comments

Comments
 (0)