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 (
0 commit comments