Skip to content

Commit 36446d7

Browse files
feat: group rejections for mandatory primary keys
1 parent a3cafe7 commit 36446d7

11 files changed

Lines changed: 330 additions & 40 deletions

File tree

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

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
CopyEntity,
2929
DeferredFilter,
3030
EntityRemoval,
31+
GroupIdentification,
3132
HeaderJoin,
3233
ImmediateFilter,
3334
InnerJoin,
@@ -344,6 +345,14 @@ def remove_orphans(self, entities: Entities, *, config: OrphanRemoval) -> Iterat
344345
"""
345346
raise NotImplementedError
346347

348+
@abstractmethod
349+
def check_mandatory_group(self, entities: Entities, *, config: GroupIdentification) -> Iterator:
350+
"""
351+
Check that a mandatory key in an entity has at least one valid entry in the all the child
352+
entities.
353+
"""
354+
raise NotImplementedError
355+
347356
@abstractmethod
348357
def union(self, entities: Entities, *, config: TableUnion) -> Messages:
349358
"""Union two entities together, taking the columns from each by name.
@@ -436,36 +445,110 @@ def process_node(
436445
code=node.orphaned_records_error_code,
437446
message=node.orphaned_records_error_message,
438447
location=location,
439-
)
440-
)
448+
),
449+
),
441450
)
442451
for record in _orph_records:
443-
msg_writer.write_queue.put([
452+
msg_writer.write_queue.put(
453+
[
454+
FeedbackMessage(
455+
entity=current_entity_name,
456+
record=record, # type: ignore
457+
error_location=location,
458+
error_message=node.orphaned_records_error_message,
459+
failure_type="record",
460+
error_type="record",
461+
error_code=node.orphaned_records_error_code,
462+
reporting_field=location,
463+
category="Parent Missing",
464+
)
465+
]
466+
)
467+
468+
if node.children:
469+
for child_node in node.children:
470+
process_node(child_node, current_entity_name, orph_messages)
471+
472+
for root_node in entity_hierarchy.entity_trees.values():
473+
process_node(root_node, parent_entity_name=None)
474+
475+
_orph_rel = entities.get(ORPHANED_RECORD_ENTITY_NAME)
476+
if _orph_rel is not None:
477+
del entities[ORPHANED_RECORD_ENTITY_NAME]
478+
479+
entities.update(entities)
480+
481+
return []
482+
483+
def identify_and_remove_missing_mandatory_groups(
484+
self,
485+
working_directory: URI,
486+
entities: Entities,
487+
entity_hierarchy: EntityHierarchy,
488+
key_fields: Optional[dict[str, list[str]]] = None,
489+
) -> Messages:
490+
"""
491+
Identify that an entity with a mandatory key has at least one valid child record.
492+
"""
493+
494+
def process_node(
495+
node: HierarchyNode | ChildHierarchyNode,
496+
parent_entity_name: Optional[EntityName],
497+
):
498+
"""Recursive helper to process a node and its children."""
499+
current_entity_name = node.entity_name
500+
501+
if isinstance(node, ChildHierarchyNode) and parent_entity_name is not None:
502+
self.logger.info(
503+
f"Identifying that {current_entity_name} has at least 1 valid child record"
504+
) # pylint: disable=C0301
505+
506+
join_expr = " AND ".join(
507+
f"{parent_entity_name}.{k} = {current_entity_name}.{v}"
508+
for k, v in node.join_fields.items()
509+
)
510+
511+
with BackgroundMessageWriter(
512+
working_directory=working_directory,
513+
dve_stage=self.__stage_name__,
514+
key_fields=key_fields,
515+
logger=self.logger,
516+
) as msg_writer:
517+
location = list(node.join_fields.values())[0]
518+
missing_children_records = self.check_mandatory_group(
519+
entities=entities,
520+
config=GroupIdentification(
521+
entity_name=parent_entity_name,
522+
target_name=node.entity_name,
523+
join_condition=join_expr,
524+
mandatory=node.mandatory, # type: ignore
525+
),
526+
)
527+
for record in missing_children_records:
528+
msg_writer.write_queue.put(
529+
[
444530
FeedbackMessage(
445-
entity=current_entity_name,
531+
entity=parent_entity_name,
446532
record=record, # type: ignore
447533
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,
534+
error_message=node.no_valid_records_error_message,
535+
failure_type="submission" if node.mandatory else "record",
536+
error_type="submission" if node.mandatory else "record",
537+
error_code=node.no_valid_records_error_code,
452538
reporting_field=location,
453-
category="Parent Missing",
539+
category="Children missing",
540+
is_informational=not node.mandatory, # type: ignore
454541
)
455-
])
542+
]
543+
)
456544

457545
if node.children:
458546
for child_node in node.children:
459-
process_node(child_node, current_entity_name, orph_messages)
460-
547+
process_node(child_node, current_entity_name)
461548

462549
for root_node in entity_hierarchy.entity_trees.values():
463550
process_node(root_node, parent_entity_name=None)
464551

465-
_orph_rel = entities.get(ORPHANED_RECORD_ENTITY_NAME)
466-
if _orph_rel:
467-
del entities[ORPHANED_RECORD_ENTITY_NAME]
468-
469552
entities.update(entities)
470553

471554
return []

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
Aggregation,
4444
AntiJoin,
4545
ConfirmJoinHasMatch,
46+
GroupIdentification,
4647
HeaderJoin,
4748
ImmediateFilter,
4849
InnerJoin,
@@ -450,6 +451,45 @@ def remove_orphans(self, entities: DuckDBEntities, *, config: OrphanRemoval) ->
450451
orphan_rel.filter(f"entity_name = '{config.entity_name}'")
451452
)
452453

454+
def check_mandatory_group(
455+
self, entities: DuckDBEntities, *, config: GroupIdentification
456+
) -> Iterator:
457+
"""
458+
Check that a mandatory key in an entity has at least one valid entry in the all the
459+
child entities.
460+
"""
461+
source_rel: DuckDBPyRelation = entities[config.entity_name]
462+
source_rel = source_rel.set_alias(config.entity_name)
463+
target_rel: DuckDBPyRelation = entities[config.target_name]
464+
target_rel = target_rel.set_alias(config.target_name)
465+
466+
source_columns = [f"{config.entity_name}.{c.strip()}" for c in source_rel.columns]
467+
_pk, fk = config.join_condition.split("=")
468+
469+
joined_rel = source_rel.join(target_rel, config.join_condition, "left").select(
470+
*source_columns,
471+
ColumnExpression(fk.strip()).alias("fk"),
472+
ConstantExpression(config.mandatory).alias("mandatory"),
473+
)
474+
475+
missing_children_rel = joined_rel.filter("fk IS NULL")
476+
filtered_rel = joined_rel.filter("fk IS NOT NULL and not mandatory").select(
477+
StarExpression(exclude=["fk", "mandatory"])
478+
)
479+
480+
_no_valid_child_records: tuple[int] = missing_children_rel.count("*").fetchone() # type: ignore # pylint: disable=C0301
481+
if _no_valid_child_records:
482+
_no_valid_children = _no_valid_child_records[0]
483+
else:
484+
_no_valid_children = 0
485+
self.logger.info(
486+
f"Found {_no_valid_children} records with no valid children in {config.entity_name}."
487+
) # pylint: disable=C0301
488+
489+
entities[config.entity_name] = filtered_rel
490+
491+
return duckdb_rel_to_dictionaries(missing_children_rel)
492+
453493
def union(self, entities: DuckDBEntities, *, config: TableUnion) -> Messages:
454494
"""Union two entities together, taking the columns from each by name.
455495

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
ColumnAddition,
3535
ColumnRemoval,
3636
ConfirmJoinHasMatch,
37+
GroupIdentification,
3738
HeaderJoin,
3839
ImmediateFilter,
3940
InnerJoin,
@@ -384,6 +385,12 @@ def remove_orphans(
384385
# TODO - implement for spark
385386
raise NotImplementedError
386387

388+
def check_mandatory_group(
389+
self, entities: SparkEntities, *, config: GroupIdentification
390+
) -> Iterator:
391+
# TODO - implement for spark
392+
raise NotImplementedError
393+
387394
def filter(self, entities: SparkEntities, *, config: ImmediateFilter) -> Messages:
388395
"""Filter an entity immediately, and do not emit any messages.
389396

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
"CopyEntity",
3434
"DeferredFilter",
3535
"EntityRemoval",
36+
"GroupIdentification",
3637
"HeaderJoin",
3738
"ImmediateFilter",
3839
"InnerJoin",
3940
"LeftJoin",
4041
"OneToOneJoin",
4142
"OneToOneJoin",
4243
"OrphanIdentification",
44+
"OrphanRemoval",
4345
"ParentMetadata",
4446
"RenameEntity",
4547
"Rule",
@@ -553,6 +555,7 @@ class OrphanIdentification(AbstractConditionalJoin):
553555
554556
"""
555557

558+
556559
Step = Union[AbstractStep, Literal["sync"]]
557560
"""A step within a rule. This is either a rule config or the literal string 'sync'."""
558561

@@ -564,6 +567,13 @@ class OrphanRemoval(BaseStep):
564567
"""The reporting information for the row removal."""
565568

566569

570+
class GroupIdentification(AbstractConditionalJoin):
571+
"""Identify mandatory records which do not have any valid child records"""
572+
573+
mandatory: bool
574+
"""Whether the primary key is mandatory and whether the record should be stripped."""
575+
576+
567577
class Rule(BaseModel):
568578
"""A rule, made up of multiple steps."""
569579

src/dve/core_engine/type_hints.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@
133133
"""A string indicating the field that the error pertains to."""
134134
FieldValue = Optional[Any]
135135
"""The value that caused the error."""
136-
ErrorCategory = Literal["Blank", "Wrong format", "Bad value", "Bad file", "Parent Missing"]
136+
ErrorCategory = Literal[
137+
"Blank", "Wrong format", "Bad value", "Bad file", "Parent Missing", "Children missing"
138+
]
137139
"""A string indicating the category of the error."""
138140
RecordIndex = Optional[int]
139141
"""The record index that the error relates to (if applicable)"""

src/dve/pipeline/pipeline.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# pylint: disable=protected-access,too-many-instance-attributes,too-many-arguments,line-too-long
1+
# pylint: disable=protected-access,too-many-instance-attributes,too-many-arguments,line-too-long,too-many-lines
22
"""Generic Pipeline object to define how DVE should be interacted with."""
33

44
import json
@@ -629,18 +629,23 @@ def apply_business_rules( # pylint: disable=R0914
629629
else:
630630
self._logger.info(f"Skipping {entity_name}. Marked original.")
631631
filtered_entity = entity
632-
projected = self._step_implementations.write_parquet( # type: ignore
633-
filtered_entity,
634-
fh.joinuri(
635-
self.processed_files_path,
636-
submission_info.submission_id,
637-
"business_rules",
638-
entity_name,
639-
),
640-
)
641-
entity_manager.entities[entity_name] = self.step_implementations.read_parquet( # type: ignore
642-
projected
643-
)
632+
# todo - Removing for now as double write causing spark write to crash - look into fix
633+
# todo - main benefit of double write is that the execution plan to be truncated before
634+
# todo - complex joins and checks performed in the orphan and group rejection.
635+
# todo - ideally should only write twice if those steps are actually required.
636+
# projected = self._step_implementations.write_parquet( # type: ignore
637+
# filtered_entity,
638+
# fh.joinuri(
639+
# self.processed_files_path,
640+
# submission_info.submission_id,
641+
# "business_rules",
642+
# entity_name,
643+
# ),
644+
# )
645+
# entity_manager.entities[entity_name] = self.step_implementations.read_parquet( # type: ignore
646+
# projected
647+
# )
648+
entity_manager.entities[entity_name] = filtered_entity
644649

645650
self.step_implementations.identify_and_remove_orphans( # type: ignore
646651
working_directory,
@@ -649,6 +654,25 @@ def apply_business_rules( # pylint: disable=R0914
649654
key_fields,
650655
)
651656

657+
self.step_implementations.identify_and_remove_missing_mandatory_groups( # type: ignore
658+
working_directory,
659+
entity_manager.entities,
660+
entity_hierarchy,
661+
key_fields,
662+
)
663+
664+
for entity_name, entity in entity_manager.entities.items():
665+
self._logger.info(f"Writing {entity_name} out to disk.")
666+
self._step_implementations.write_parquet( # type: ignore
667+
entity,
668+
fh.joinuri(
669+
self.processed_files_path,
670+
submission_info.submission_id,
671+
"business_rules",
672+
entity_name,
673+
),
674+
)
675+
652676
submission_status.number_of_records = self.get_entity_count(
653677
entity=entity_manager.entities[f"""Original{rules.global_variables.get(
654678
'entity',

0 commit comments

Comments
 (0)