Skip to content

Commit 7792d41

Browse files
feat: group rejections for mandatory primary keys (#152)
* docs: update jsonschema for entity relationships to include group rej code+message
1 parent a6b6154 commit 7792d41

20 files changed

Lines changed: 715 additions & 395 deletions

docs/advanced_guidance/json_schemas/entity_relationships.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
},
2626
"missing_parent_id_error_message": {
2727
"type": "string"
28+
},
29+
"no_valid_records_error_code": {
30+
"type": "string"
31+
},
32+
"no_valid_records_error_message": {
33+
"type": "string"
2834
}
2935
},
3036
"required": [

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

Lines changed: 93 additions & 4 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,
@@ -340,6 +341,14 @@ def remove_orphans(self, entities: Entities, *, config: OrphanRemoval) -> Iterat
340341
"""
341342
raise NotImplementedError
342343

344+
@abstractmethod
345+
def check_mandatory_group(self, entities: Entities, *, config: GroupIdentification) -> Iterator:
346+
"""
347+
Check that a mandatory key in an entity has at least one valid entry in the all the child
348+
entities.
349+
"""
350+
raise NotImplementedError
351+
343352
@abstractmethod
344353
def union(self, entities: Entities, *, config: TableUnion) -> Messages:
345354
"""Union two entities together, taking the columns from each by name.
@@ -378,7 +387,7 @@ def identify_and_remove_orphans(
378387
entities: Entities,
379388
entity_hierarchy: EntityHierarchy,
380389
key_fields: Optional[dict[str, list[str]]] = None,
381-
) -> Messages:
390+
) -> tuple[Messages, bool]:
382391
"""
383392
Identifies and removes orphan records by traversing the EntityHierarchy object.
384393
An orphan is a child record whose parent FK does not exist in the parent entity.
@@ -388,6 +397,7 @@ def identify_and_remove_orphans(
388397
def process_node(
389398
node: HierarchyNode,
390399
orph_messages: Messages | None = None,
400+
processed: bool = False,
391401
):
392402
"""Identify orphans and remove in a given node"""
393403

@@ -416,6 +426,7 @@ def process_node(
416426
self.logger.info(
417427
f"Removing records with missing parent from {node.entity_name}"
418428
)
429+
processed = True
419430
location = list(node.join_fields.values())[0]
420431
with BackgroundMessageWriter(
421432
working_directory=working_directory,
@@ -453,17 +464,95 @@ def process_node(
453464
]
454465
)
455466

467+
return processed
468+
469+
processed = False
470+
456471
for tree in entity_hierarchy.entity_trees.values():
457472
for node in tree.iterate_root_down():
458-
process_node(node)
473+
processed = process_node(node)
459474

460475
_orph_rel = entities.get(ORPHANED_RECORD_ENTITY_NAME)
461-
if _orph_rel:
476+
if _orph_rel is not None:
462477
del entities[ORPHANED_RECORD_ENTITY_NAME]
463478

464479
entities.update(entities)
465480

466-
return []
481+
return [], processed
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+
) -> tuple[Messages, bool]:
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,
496+
processed: bool = False,
497+
) -> bool:
498+
"""Identify at least one valid child for a mandatory entity at a given node."""
499+
if node.parent_entity is None or not node.mandatory:
500+
return processed
501+
502+
processed = True
503+
504+
self.logger.info(
505+
f"Identifying that mandatory entity `{node.parent_entity}` has at least 1 valid child record" # pylint: disable=C0301
506+
)
507+
508+
join_expr = " AND ".join(
509+
f"{node.parent_entity}.{k} = {node.entity_name}.{v}"
510+
for k, v in node.join_fields.items()
511+
)
512+
513+
with BackgroundMessageWriter(
514+
working_directory=working_directory,
515+
dve_stage=self.__stage_name__,
516+
key_fields=key_fields,
517+
logger=self.logger,
518+
) as msg_writer:
519+
location = next(iter(node.join_fields.values()))
520+
missing_children_records = self.check_mandatory_group(
521+
entities=entities,
522+
config=GroupIdentification(
523+
entity_name=node.parent_entity,
524+
target_name=node.entity_name,
525+
join_condition=join_expr,
526+
),
527+
)
528+
for record in missing_children_records:
529+
msg_writer.write_queue.put(
530+
[
531+
FeedbackMessage(
532+
entity=node.parent_entity,
533+
record=record, # type: ignore
534+
error_location=location,
535+
error_message=node.no_valid_records_error_message,
536+
failure_type="record",
537+
error_type="record",
538+
error_code=node.no_valid_records_error_code,
539+
reporting_field=location,
540+
category="Children missing",
541+
)
542+
]
543+
)
544+
545+
return processed
546+
547+
processed = False
548+
549+
for tree in entity_hierarchy.entity_trees.values():
550+
for node in tree.iterate_lowest_descendent_up():
551+
processed = process_node(node, processed)
552+
553+
entities.update(entities)
554+
555+
return [], processed
467556

468557
# pylint: disable=R0912,R0914
469558
def apply_sync_filters(

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

Lines changed: 37 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,42 @@ 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+
)
473+
474+
missing_children_rel = joined_rel.filter("fk IS NULL")
475+
filtered_rel = joined_rel.filter("fk IS NOT NULL").select(StarExpression(exclude=["fk"]))
476+
477+
_no_valid_child_records: tuple[int] = missing_children_rel.count("*").fetchone() # type: ignore # pylint: disable=C0301
478+
if _no_valid_child_records:
479+
_no_valid_children = _no_valid_child_records[0]
480+
else:
481+
_no_valid_children = 0
482+
self.logger.info(
483+
f"Found {_no_valid_children} records with no valid children in {config.entity_name}."
484+
) # pylint: disable=C0301
485+
486+
entities[config.entity_name] = filtered_rel
487+
488+
return duckdb_rel_to_dictionaries(missing_children_rel)
489+
453490
def union(self, entities: DuckDBEntities, *, config: TableUnion) -> Messages:
454491
"""Union two entities together, taking the columns from each by name.
455492

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: 6 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",
@@ -565,6 +567,10 @@ class OrphanRemoval(BaseStep):
565567
"""The reporting information for the row removal."""
566568

567569

570+
class GroupIdentification(AbstractConditionalJoin):
571+
"""Identify mandatory records which do not have any valid child records"""
572+
573+
568574
class Rule(BaseModel):
569575
"""A rule, made up of multiple steps."""
570576

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/parser/file_handling/service.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,12 @@ def copy_resource(source_uri: URI, target_uri: URI, overwrite: bool = False) ->
273273
_transfer_resource(source_uri, target_uri, overwrite, "copy")
274274

275275

276-
def move_resource(source_uri: URI, target_uri: URI, overwrite: bool = False) -> None:
277-
"""Move a resource from one location to another."""
276+
def move_resource(source_uri: URI, target_uri: URI, overwrite: bool = False) -> URI:
277+
"""
278+
Move a resource from one location to another. Returns the target_uri.
279+
"""
278280
_transfer_resource(source_uri, target_uri, overwrite, "move")
281+
return target_uri
279282

280283

281284
def create_directory(target_uri: URI):

src/dve/pipeline/pipeline.py

Lines changed: 52 additions & 4 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
@@ -545,7 +545,7 @@ def data_contract_step(
545545

546546
return processed_files, failed_processing
547547

548-
def apply_business_rules( # pylint: disable=R0914
548+
def apply_business_rules( # pylint: disable=R0914,R0915
549549
self, submission_info: SubmissionInfo, submission_status: Optional[SubmissionStatus] = None
550550
) -> tuple[SubmissionInfo, SubmissionStatus]:
551551
"""Apply the business rules to a given submission, the submission may have failed at the
@@ -635,21 +635,69 @@ def apply_business_rules( # pylint: disable=R0914
635635
fh.joinuri(
636636
self.processed_files_path,
637637
submission_info.submission_id,
638-
"business_rules",
638+
"temp_business_rules",
639639
entity_name,
640640
),
641641
)
642642
entity_manager.entities[entity_name] = self.step_implementations.read_parquet( # type: ignore
643643
projected
644644
)
645645

646-
self.step_implementations.identify_and_remove_orphans( # type: ignore
646+
_, orph_or_group = self.step_implementations.identify_and_remove_orphans( # type: ignore
647+
working_directory,
648+
entity_manager.entities,
649+
entity_hierarchy,
650+
key_fields,
651+
)
652+
653+
_, orph_or_group = self.step_implementations.identify_and_remove_missing_mandatory_groups( # type: ignore
647654
working_directory,
648655
entity_manager.entities,
649656
entity_hierarchy,
650657
key_fields,
651658
)
652659

660+
# Perform a second time incase the mandatory groups result in new orphans
661+
_, orph_or_group = self.step_implementations.identify_and_remove_orphans( # type: ignore
662+
working_directory,
663+
entity_manager.entities,
664+
entity_hierarchy,
665+
key_fields,
666+
)
667+
668+
for entity_name, entity in entity_manager.entities.items():
669+
if orph_or_group:
670+
self._logger.info(f"Writing {entity_name} out to disk.")
671+
final_projection = self._step_implementations.write_parquet( # type: ignore
672+
entity,
673+
fh.joinuri(
674+
self.processed_files_path,
675+
submission_info.submission_id,
676+
"business_rules",
677+
entity_name,
678+
),
679+
)
680+
else:
681+
self._logger.info(f"Moving {entity_name} from temp_business_rules to business_rules")
682+
final_projection = fh.move_resource(
683+
source_uri=fh.joinuri(
684+
self.processed_files_path,
685+
submission_info.submission_id,
686+
"temp_business_rules",
687+
entity_name
688+
),
689+
target_uri=fh.joinuri(
690+
self.processed_files_path,
691+
submission_info.submission_id,
692+
"business_rules",
693+
entity_name
694+
)
695+
)
696+
697+
entity_manager.entities[entity_name] = self.step_implementations.read_parquet( # type: ignore
698+
final_projection
699+
)
700+
653701
submission_status.number_of_records = self.get_entity_count(
654702
entity=entity_manager.entities[f"""Original{rules.global_variables.get(
655703
'entity',

src/dve/pipeline/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def unpersist_all_rdds(spark: SparkSession):
9494
rdd.unpersist()
9595

9696

97-
def deadletter_file(source_uri: URI) -> None:
97+
def deadletter_file(source_uri: URI) -> URI | None:
9898
"""Move files that can't be processed to a deadletter location"""
9999
try:
100100
source_parent: URI = source_uri.rsplit("/", 1)[0]

0 commit comments

Comments
 (0)