Skip to content

Commit df8bbe4

Browse files
feat: add orphan and group rejections to spark backend (#174)
1 parent acaf522 commit df8bbe4

11 files changed

Lines changed: 422 additions & 33 deletions

File tree

‎src/dve/core_engine/backends/base/reader.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from dve.core_engine.backends.exceptions import (
1212
CriticalMessageBearingError,
1313
MessageBearingError,
14-
ReaderLacksEntityTypeSupport
14+
ReaderLacksEntityTypeSupport,
1515
)
1616
from dve.core_engine.backends.types import EntityName, EntityType
1717
from dve.core_engine.configuration.v1 import (

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,13 @@ def process_node(node: HierarchyNode) -> bool:
427427

428428
self.cache_entity(node.entity_name, entities)
429429

430-
return len(_messages) > 0
430+
_orph_count = len(_messages)
431+
432+
self.logger.info(
433+
f"Found {_orph_count} orphan records between {node.entity_name} and {node.parent_entity}" # pylint: disable=C0301
434+
)
435+
436+
return _orph_count > 0
431437

432438
entity_issues_found: dict[EntityName, bool] = {}
433439

@@ -454,7 +460,7 @@ def process_node(node: HierarchyNode) -> bool:
454460
return False
455461

456462
self.logger.info(
457-
f"Identifying that mandatory entity `{node.parent_entity}` has at least 1 valid child record" # pylint: disable=C0301
463+
f"Identifying that mandatory entity `{node.parent_entity}` has at least 1 valid child record in {node.entity_name}" # pylint: disable=C0301
458464
)
459465

460466
join_expr = " AND ".join(
@@ -493,7 +499,14 @@ def process_node(node: HierarchyNode) -> bool:
493499
]
494500
msg_writer.write_queue.put(_messages)
495501
self.cache_entity(node.parent_entity, entities)
496-
return len(_messages) > 0
502+
503+
_no_valid_child_records: int = len(_messages)
504+
505+
self.logger.info(
506+
f"Found {_no_valid_child_records} records with no valid children in {node.parent_entity}." # pylint: disable=C0301
507+
)
508+
509+
return _no_valid_child_records > 0
497510

498511
entity_issues_found: dict[EntityName, bool] = {}
499512

‎src/dve/core_engine/backends/exceptions.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def __init__(self, *args: object, messages: Messages) -> None:
3232
self.messages = messages
3333
"""The messages to be returned as part of the error."""
3434

35+
3536
class CriticalMessageBearingError(BackendError):
3637
"""
3738
A backend error that comes with a pre-created message.
@@ -44,6 +45,7 @@ def __init__(self, *args: object, message: FeedbackMessage) -> None:
4445
self.message = message
4546
"""The message to be returned as part of the error."""
4647

48+
4749
class UnableToParseCSVError(CriticalMessageBearingError):
4850
"""An error raised when unable to parse a CSV file"""
4951

@@ -60,7 +62,8 @@ def __init__(
6062
failure_type="submission",
6163
is_informational=False,
6264
error_type="csv read",
63-
error_message=error_message or "Unable to parse the CSV file. Please check the structure of your CSV.", # pylint: disable=C0301
65+
error_message=error_message
66+
or "Unable to parse the CSV file. Please check the structure of your CSV.", # pylint: disable=C0301
6467
error_code=error_code or "MalformedCSV",
6568
)
6669
)

‎src/dve/core_engine/backends/implementations/duckdb/readers/csv.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ def read_to_relation( # pylint: disable=unused-argument
123123
raise UnableToParseCSVError(
124124
entity_name="csv_structure",
125125
error_code=self.ft_error_code,
126-
error_message=self.ft_error_message or "Unable to parse CSV file. Structure is likely malformed.", # pylint: disable=C0301
126+
error_message=self.ft_error_message
127+
or "Unable to parse CSV file. Structure is likely malformed.", # pylint: disable=C0301
127128
) from exc
128129

129130
if self.null_empty_strings:
@@ -183,7 +184,8 @@ def read_to_relation( # pylint: disable=unused-argument
183184
raise UnableToParseCSVError(
184185
entity_name="csv_structure",
185186
error_code=self.ft_error_code,
186-
error_message=self.ft_error_message or "Unable to parse CSV file. Structure is likely malformed.", # pylint: disable=C0301
187+
error_message=self.ft_error_message
188+
or "Unable to parse CSV file. Structure is likely malformed.", # pylint: disable=C0301
187189
) from exc
188190

189191
if self.null_empty_strings:
@@ -200,7 +202,8 @@ def read_to_relation( # pylint: disable=unused-argument
200202
raise UnableToParseCSVError(
201203
entity_name="csv_structure",
202204
error_code=self.ft_error_code,
203-
error_message=self.ft_error_message or "Found zero records after loading CSV. File is likely malformed.", # pylint: disable=C0301
205+
error_message=self.ft_error_message
206+
or "Found zero records after loading CSV. File is likely malformed.", # pylint: disable=C0301
204207
)
205208

206209
return entity

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

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -421,12 +421,6 @@ def identify_orphans(
421421
.set_alias("orphan")
422422
)
423423

424-
if relation_is_empty(orphaned_rel):
425-
self.logger.info(
426-
f"Found 0 orphan records between {config.entity_name} and {config.target_name}"
427-
)
428-
return []
429-
430424
message_rel = (
431425
entities[config.entity_name]
432426
.set_alias(config.entity_name)
@@ -474,15 +468,6 @@ def check_mandatory_group(
474468
missing_children_rel = joined_rel.filter("fk IS NULL")
475469
filtered_rel = joined_rel.filter("fk IS NOT NULL").select(StarExpression(exclude=["fk"]))
476470

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-
486471
entities[config.entity_name] = filtered_rel
487472

488473
return duckdb_rel_to_dictionaries(missing_children_rel)

‎src/dve/core_engine/backends/implementations/spark/readers/xml.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,15 @@ def read_to_dataframe(
4444
resource: URI,
4545
entity_name: EntityName,
4646
schema: type[BaseModel],
47+
all_model_fields: Optional[set[str]] = None,
4748
) -> DataFrame:
4849
"""Stream an XML file into a Spark data frame"""
4950
if not self.spark:
5051
self.spark = SparkSession.builder.getOrCreate() # type: ignore
5152
spark_schema = get_type_from_annotation(schema)
5253
return self.add_record_index(
5354
self.spark.createDataFrame( # type: ignore
54-
list(self.read_to_py_iterator(resource, entity_name, schema)),
55+
list(self.read_to_py_iterator(resource, entity_name, schema, all_model_fields)),
5556
schema=spark_schema,
5657
)
5758
)

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

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

33
# pylint: disable=R0801
4-
from collections.abc import Callable, Iterator
4+
from collections.abc import Callable, Iterable, Iterator
55
from typing import Optional
66
from uuid import uuid4
77

@@ -13,6 +13,7 @@
1313
from dve.core_engine.backends.exceptions import ConstraintError
1414
from dve.core_engine.backends.implementations.spark.spark_helpers import (
1515
create_udf,
16+
df_is_empty,
1617
get_all_registered_udfs,
1718
object_to_spark_literal,
1819
spark_filter_contract_errors,
@@ -48,6 +49,7 @@
4849
SemiJoin,
4950
TableUnion,
5051
)
52+
from dve.core_engine.constants import RECORD_INDEX_COLUMN_NAME
5153
from dve.core_engine.functions import implementations as functions
5254
from dve.core_engine.message import FeedbackMessage
5355
from dve.core_engine.templating import template_object
@@ -341,16 +343,107 @@ def union(self, entities: SparkEntities, *, config: TableUnion) -> Messages:
341343
return []
342344

343345
def identify_orphans(
344-
self, entities: SparkEntities, *, config: OrphanIdentification
345-
) -> tuple[Messages, int]:
346-
# TODO - adjust this to new setup of identify and remove orphans
347-
raise NotImplementedError
346+
self,
347+
entities: SparkEntities,
348+
*,
349+
config: OrphanIdentification,
350+
) -> Iterable:
351+
"""Identify records in an entity which don't have at least one corresponding
352+
match in the target. A new boolean column will be added to `entity` ('IsOrphaned')
353+
indicating whether the condition matched.
354+
355+
If there is already an 'IsOrphaned' column in the entity, this will be set to the
356+
logical OR of its current value and the value it would have been set to otherwise.
357+
358+
"""
359+
source_df: DataFrame = entities[config.entity_name]
360+
source_df = source_df.alias(config.entity_name)
361+
362+
if df_is_empty(source_df):
363+
self.logger.info(f"{config.entity_name} is empty. Skipping orphan check.")
364+
return
365+
366+
target_df: DataFrame = entities[config.target_name]
367+
match_name = f"matched_{uuid4().hex}"
368+
target_df = target_df.select("*", sf.lit(1).alias(match_name)).alias(config.target_name)
369+
370+
orphaned_df: DataFrame = (
371+
source_df.join(target_df, on=sf.expr(config.join_condition), how="left")
372+
.groupBy(f"{config.entity_name}.{config.id}")
373+
.agg(
374+
sf.first(f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME}").alias(
375+
RECORD_INDEX_COLUMN_NAME
376+
), # pylint: disable=C0301
377+
(sf.coalesce(sf.count(match_name), sf.lit(0)) == sf.lit(0)).alias("IsOrphaned"),
378+
)
379+
.filter(sf.col("IsOrphaned"))
380+
.select(RECORD_INDEX_COLUMN_NAME)
381+
.alias("orphan")
382+
)
383+
384+
message_df = (
385+
entities[config.entity_name]
386+
.alias(config.entity_name)
387+
.join(
388+
orphaned_df,
389+
sf.expr(
390+
f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME} = orphan.{RECORD_INDEX_COLUMN_NAME}", # pylint: disable=C0301
391+
),
392+
"semi",
393+
)
394+
)
395+
filtered_rel = (
396+
entities[config.entity_name]
397+
.alias(config.entity_name)
398+
.join(
399+
orphaned_df,
400+
sf.expr(
401+
f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME} = orphan.{RECORD_INDEX_COLUMN_NAME}", # pylint: disable=C0301
402+
),
403+
"anti",
404+
)
405+
)
406+
407+
entities[config.entity_name] = filtered_rel
408+
409+
for r in message_df.toLocalIterator():
410+
yield r.asDict()
348411

349412
def check_mandatory_group(
350413
self, entities: SparkEntities, *, config: GroupIdentification
351414
) -> Iterator:
352-
# TODO - implement for spark
353-
raise NotImplementedError
415+
"""
416+
Check that a mandatory key in an entity has at least one valid entry in the all the
417+
child entities.
418+
"""
419+
source_df: DataFrame = entities[config.entity_name]
420+
source_df = source_df.alias(config.entity_name)
421+
target_df: DataFrame = entities[config.target_name]
422+
target_df = target_df.alias(config.target_name)
423+
424+
source_columns = [f"{config.entity_name}.{c.strip()}" for c in source_df.columns]
425+
_pk, fk = config.join_condition.split("=")
426+
427+
joined_df = source_df.join(target_df, sf.expr(config.join_condition), "left").select(
428+
*source_columns,
429+
sf.col(fk.strip()).alias("fk"),
430+
)
431+
432+
missing_children_df = joined_df.filter("fk IS NULL")
433+
filtered_df = joined_df.filter("fk IS NOT NULL").select("*").drop(sf.col("fk"))
434+
435+
if not df_is_empty(missing_children_df):
436+
_no_valid_children = missing_children_df.count()
437+
else:
438+
_no_valid_children = 0
439+
self.logger.info(
440+
f"Found {_no_valid_children} records with no valid children in {config.entity_name}."
441+
)
442+
443+
entities[config.entity_name] = filtered_df
444+
445+
for r in missing_children_df.toLocalIterator():
446+
yield r.asDict()
354447

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

‎src/dve/core_engine/backends/readers/utilities.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def raise_message_bearing_error_on_header_differences(
6666
reporting_field="csv_header",
6767
error_code=field_check_error_code,
6868
error_message=field_check_error_message,
69-
)
69+
),
7070
)
7171

7272

‎src/dve/pipeline/pipeline.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,8 @@ def apply_business_rules( # pylint: disable=R0914,R0915
764764
fh.remove_prefix(
765765
fh.joinuri(
766766
self.processed_files_path, submission_info.submission_id, "temp_business_rules"
767-
)
767+
),
768+
recursive=True,
768769
)
769770

770771
self.check_mandatory_entities_have_records(

‎tests/features/flights.feature‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,81 @@ Feature: Pipeline tests using the flights dataset
286286
And the latest audit record for the submission is marked with processing status error_report
287287
When I run the error report phase
288288
Then An error report is produced
289+
290+
Scenario: A flights submission with a rejection on a node with two mandatory nodes (spark)
291+
Given I submit the flights file multi_node_file_rejection.xml for processing
292+
And A spark pipeline is configured with schema file 'flights_spark.dischema.json'
293+
And I add initial audit entries for the submission
294+
Then the latest audit record for the submission is marked with processing status file_transformation
295+
When I run the file transformation phase
296+
Then the country entity is stored as a parquet after the file_transformation phase
297+
And the airport entity is stored as a parquet after the file_transformation phase
298+
And the flights entity is stored as a parquet after the file_transformation phase
299+
And the passengers entity is stored as a parquet after the file_transformation phase
300+
And the latest audit record for the submission is marked with processing status data_contract
301+
When I run the data contract phase
302+
Then there are no file rejections from the data_contract phase
303+
And there are no record rejections from the data_contract phase
304+
When I run the business rules phase
305+
Then there are errors with the following details and associated error_count from the business_rules phase
306+
| ErrorType | Status | ErrorCode | error_count |
307+
| record | error | StaffIDMissing | 7 |
308+
| record | error | AirportHasNoStaff | 1 |
309+
| record | error | FlightHasNoAirport | 1 |
310+
| record | error | PassengerHasNoFlight | 1 |
311+
And the final entities have the following row counts
312+
| entity_name | row_count |
313+
| country | 1 |
314+
| airport | 1 |
315+
| staff | 1 |
316+
| flights | 1 |
317+
| passengers | 1 |
318+
When I run the error report phase
319+
Then An error report is produced
320+
And The statistics entry for the submission shows the following information
321+
| parameter | value |
322+
| record_count | 1 |
323+
| number_submission_rejections | 0 |
324+
| number_record_rejections | 10 |
325+
| number_warnings | 0 |
326+
327+
Scenario: A flights submission with many types of rejections in a single submission (spark)
328+
Given I submit the flights file flights_full_regression.xml for processing
329+
And A spark pipeline is configured with schema file 'flights_spark.dischema.json'
330+
And I add initial audit entries for the submission
331+
Then the latest audit record for the submission is marked with processing status file_transformation
332+
When I run the file transformation phase
333+
Then the country entity is stored as a parquet after the file_transformation phase
334+
And the airport entity is stored as a parquet after the file_transformation phase
335+
And the flights entity is stored as a parquet after the file_transformation phase
336+
And the passengers entity is stored as a parquet after the file_transformation phase
337+
And the latest audit record for the submission is marked with processing status data_contract
338+
When I run the data contract phase
339+
Then there are errors with the following details and associated error_count from the data_contract phase
340+
| FailureType | ErrorCode | error_count |
341+
| record | AirportIdIsMissing | 1 |
342+
When I run the business rules phase
343+
Then there are errors with the following details and associated error_count from the business_rules phase
344+
| ErrorType | Status | ErrorCode | error_count |
345+
| record | error | InvalidFlightDestination | 1 |
346+
| record | error | PassengerNameMissing | 1 |
347+
| record | error | StaffIDMissing | 4 |
348+
| record | error | PassengerHasNoFlight | 3 |
349+
| record | error | StaffHasNoAirport | 1 |
350+
| record | error | FlightHasNoAirport | 2 |
351+
| record | error | AirportHasNoStaff | 1 |
352+
And the final entities have the following row counts
353+
| entity_name | row_count |
354+
| country | 1 |
355+
| airport | 3 |
356+
| staff | 3 |
357+
| flights | 3 |
358+
| passengers | 2 |
359+
When I run the error report phase
360+
Then An error report is produced
361+
And The statistics entry for the submission shows the following information
362+
| parameter | value |
363+
| record_count | 1 |
364+
| number_submission_rejections | 0 |
365+
| number_record_rejections | 14 |
366+
| number_warnings | 0 |

0 commit comments

Comments
 (0)