Skip to content

Commit 2e81dab

Browse files
Merge branch 'release_v083' of https://github.com/NHSDigital/data-validation-engine into docs/gr-ndit-1731-fix_issues_in_docs
2 parents f13edb9 + c3e07b2 commit 2e81dab

12 files changed

Lines changed: 497 additions & 314 deletions

File tree

poetry.lock

Lines changed: 247 additions & 231 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ pandas = "2.3.3"
4444
polars = "0.20.31"
4545
pyarrow = "23.0.1"
4646
pydantic = "1.10.19"
47-
pyspark = ">=3.0.0,<=3.5.2"
47+
pyspark = ">=3.5.0,<=3.5.5"
4848
typing_extensions = "4.15.0"
4949
urllib3 = "2.7.0" # dependency of boto3 & botocore
5050

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

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from dve.common.error_utils import get_feedback_errors_uri
2323
from dve.core_engine.backends.base.utilities import _get_non_heterogenous_type
24+
from dve.core_engine.backends.utilities import DEFAULT_ISO_FORMATS, datetime_format_to_regex
2425
from dve.core_engine.constants import RECORD_INDEX_COLUMN_NAME
2526
from dve.core_engine.type_hints import URI, EntityName
2627
from dve.parser.file_handling.service import LocalFilesystemImplementation, _get_implementation
@@ -361,7 +362,7 @@ def duckdb_record_index(cls):
361362

362363
def _cast_as_ddb_type(field_expr: str, type_annotation: Any) -> str:
363364
"""Cast to Duck DB type"""
364-
return f"""try_cast({field_expr} as {get_duckdb_type_from_annotation(type_annotation)})"""
365+
return f"""TRY_CAST({field_expr} as {get_duckdb_type_from_annotation(type_annotation)})"""
365366

366367

367368
def _ddb_safely_quote_name(field_name: str) -> str:
@@ -378,9 +379,6 @@ def get_duckdb_cast_statement_from_annotation(
378379
element_name: str,
379380
type_annotation: Any,
380381
parent_element: bool = True,
381-
date_regex: str = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
382-
timestamp_regex: str = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}((\+|\-)[0-9]{2}:[0-9]{2})?$", # pylint: disable=C0301
383-
time_regex: str = r"^[0-9]{2}:[0-9]{2}:[0-9]{2}$",
384382
) -> str:
385383
"""Generate casting statements for duckdb relations from type annotations"""
386384
type_origin = get_origin(type_annotation)
@@ -391,19 +389,23 @@ def get_duckdb_cast_statement_from_annotation(
391389
if type_origin is Union:
392390
python_type = _get_non_heterogenous_type(get_args(type_annotation))
393391
return get_duckdb_cast_statement_from_annotation(
394-
element_name, python_type, parent_element, date_regex, timestamp_regex
392+
element_name,
393+
python_type,
394+
parent_element,
395395
)
396396

397397
# Type hint is e.g. `List[str]`, check to ensure non-heterogenity.
398398
if type_origin is list or (isinstance(type_origin, type) and issubclass(type_origin, list)):
399399
element_type = _get_non_heterogenous_type(get_args(type_annotation))
400-
stmt = f"list_transform({quoted_name}, x -> {get_duckdb_cast_statement_from_annotation('x',element_type, False, date_regex, timestamp_regex)})" # pylint: disable=C0301
400+
stmt = f"LIST_TRANSFORM({quoted_name}, x -> {get_duckdb_cast_statement_from_annotation('x',element_type, False,)})" # pylint: disable=C0301
401401
return stmt if not parent_element else _cast_as_ddb_type(stmt, type_annotation)
402402

403403
if type_origin is Annotated:
404404
python_type, *other_args = get_args(type_annotation) # pylint: disable=unused-variable
405405
return get_duckdb_cast_statement_from_annotation(
406-
element_name, python_type, parent_element, date_regex, timestamp_regex
406+
element_name,
407+
python_type,
408+
parent_element,
407409
) # add other expected params here
408410
# Ensure that we have a concrete type at this point.
409411
if not isinstance(type_annotation, type):
@@ -428,15 +430,17 @@ def get_duckdb_cast_statement_from_annotation(
428430
continue
429431

430432
fields[field_name] = get_duckdb_cast_statement_from_annotation(
431-
f"{element_name}.{field_name}", field_annotation, False, date_regex, timestamp_regex
433+
f"{element_name}.{field_name}",
434+
field_annotation,
435+
False,
432436
)
433437

434438
if not fields:
435439
raise ValueError(
436440
f"No type annotations in dict/dataclass type (got {type_annotation!r})"
437441
)
438442
cast_exprs = ",".join([f'"{nme}":= {stmt}' for nme, stmt in fields.items()])
439-
stmt = f"struct_pack({cast_exprs})"
443+
stmt = f"STRUCT_PACK({cast_exprs})"
440444
return stmt if not parent_element else _cast_as_ddb_type(stmt, type_annotation)
441445

442446
if type_annotation is list:
@@ -447,18 +451,23 @@ def get_duckdb_cast_statement_from_annotation(
447451
raise ValueError(f"dict must be `typing.TypedDict` subclass, got {type_annotation!r}")
448452

449453
for type_ in type_annotation.mro():
454+
_date_format: str = getattr( # type: ignore
455+
type_, "DATE_FORMAT", DEFAULT_ISO_FORMATS.get(type_, DEFAULT_ISO_FORMATS.get(datetime))
456+
)
457+
dt_cast_statement = rf"CASE WHEN REGEXP_FULL_MATCH(TRIM({quoted_name}), '{datetime_format_to_regex(_date_format)}') THEN TRY_STRPTIME(TRIM({quoted_name}), '{_date_format}') ELSE NULL END" # pylint: disable=C0301
458+
450459
# datetime is subclass of date, so needs to be handled first
451460
if issubclass(type_, datetime):
452-
stmt = rf"CASE WHEN REGEXP_MATCHES(TRIM({quoted_name}), '{timestamp_regex}') THEN TRY_CAST(TRIM({quoted_name}) as TIMESTAMP) ELSE NULL END" # pylint: disable=C0301
461+
stmt = rf"TRY_CAST({dt_cast_statement} as TIMESTAMP)"
453462
return stmt
454463
if issubclass(type_, date):
455-
stmt = rf"CASE WHEN REGEXP_MATCHES(TRIM({quoted_name}), '{date_regex}') THEN TRY_CAST(TRIM({quoted_name}) as DATE) ELSE NULL END" # pylint: disable=C0301
464+
stmt = rf"TRY_CAST({dt_cast_statement} as DATE)"
456465
return stmt
457466
if issubclass(type_, time):
458-
stmt = rf"CASE WHEN REGEXP_MATCHES(TRIM({quoted_name}), '{time_regex}') THEN TRY_CAST(TRIM({quoted_name}) as TIME) ELSE NULL END" # pylint: disable=C0301
467+
stmt = rf"TRY_CAST({dt_cast_statement} as TIME)"
459468
return stmt
460469
duck_type = get_duckdb_type_from_annotation(type_)
461470
if duck_type:
462-
stmt = f"trim({quoted_name})"
471+
stmt = f"TRIM({quoted_name})"
463472
return _cast_as_ddb_type(stmt, type_) if parent_element else stmt
464473
raise ValueError(f"No equivalent DuckDB type for {type_annotation!r}")

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from uuid import uuid4
88

99
from pydantic import BaseModel
10+
from pydantic.fields import ModelField
1011
from pyspark.sql import DataFrame, SparkSession
1112
from pyspark.sql import functions as sf
1213
from pyspark.sql.functions import col, lit
@@ -26,6 +27,7 @@
2627
)
2728
from dve.core_engine.backends.implementations.spark.spark_helpers import (
2829
df_is_empty,
30+
get_spark_cast_statement_from_annotation,
2931
get_type_from_annotation,
3032
spark_read_parquet,
3133
spark_record_index,
@@ -86,7 +88,7 @@ def create_entity_from_py_iterator(
8688
schema=get_type_from_annotation(schema),
8789
)
8890

89-
# pylint: disable=R0915
91+
# pylint: disable=R0914,R0915
9092
def apply_data_contract(
9193
self,
9294
working_dir: URI,
@@ -102,6 +104,7 @@ def apply_data_contract(
102104

103105
successful = True
104106
for entity_name, record_df in entities.items():
107+
entity_fields: dict[str, ModelField] = contract_metadata.schemas[entity_name].__fields__
105108
spark_schema = get_type_from_annotation(contract_metadata.schemas[entity_name])
106109
spark_schema.add(StructField(RECORD_INDEX_COLUMN_NAME, LongType()))
107110
if df_is_empty(record_df):
@@ -144,12 +147,21 @@ def apply_data_contract(
144147
self.logger.info(f"Data contract found {msg_count} issues in {entity_name}")
145148

146149
try:
150+
# TODO: will need to revisit in pydantic v2 bump as model field no longer available
147151
record_df = record_df.select(
148-
[
149-
col(column.name).cast(column.dataType)
150-
for column in spark_schema
151-
if column.name in record_df.columns
152-
]
152+
*[
153+
(
154+
get_spark_cast_statement_from_annotation(
155+
fld, mdl_field.annotation
156+
).alias(fld)
157+
if fld in record_df.columns
158+
else lit(None).cast(
159+
get_type_from_annotation(mdl_field.annotation).alias(fld)
160+
)
161+
)
162+
for fld, mdl_field in entity_fields.items()
163+
],
164+
col(RECORD_INDEX_COLUMN_NAME).cast(LongType()).alias(RECORD_INDEX_COLUMN_NAME),
153165
)
154166
except Exception as err: # pylint: disable=broad-except
155167
successful = False

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

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from dve.common.error_utils import get_feedback_errors_uri
3030
from dve.core_engine.backends.base.utilities import _get_non_heterogenous_type
31+
from dve.core_engine.backends.utilities import DEFAULT_ISO_FORMATS, datetime_format_to_regex
3132
from dve.core_engine.constants import RECORD_INDEX_COLUMN_NAME
3233
from dve.core_engine.type_hints import URI, EntityName
3334

@@ -99,6 +100,21 @@ def __post_init__(self):
99100
}
100101
"""A mapping of Python types to the equivalent Spark types."""
101102

103+
PYTHON_TO_JAVA_DT_FORMAT_MAP: dict[str, str] = {
104+
"%Y": "yyyy",
105+
"%y": "yy",
106+
"%m": "MM",
107+
"%d": "dd",
108+
"%H": "HH",
109+
"%M": "mm",
110+
"%S": "ss",
111+
"%z": "XX",
112+
"%Z": "z",
113+
}
114+
"""A mapping of python to java datetime component formats. Not exhaustive but will hopefully support
115+
all requirements.
116+
"""
117+
102118

103119
PydanticModel = TypeVar("PydanticModel", bound=BaseModel)
104120
"""An Pydantic model."""
@@ -272,6 +288,13 @@ def create_udf(function: Callable) -> Callable:
272288
return udf(function, returnType=return_type)
273289

274290

291+
def python_to_java_datetime_format(datetime_fmt: str) -> str:
292+
"Helper to convert python datetime formats to Java datetime formats"
293+
for pt, jt in PYTHON_TO_JAVA_DT_FORMAT_MAP.items():
294+
datetime_fmt = datetime_fmt.replace(pt, jt)
295+
return datetime_fmt.replace("T", "'T'")
296+
297+
275298
SupportedBaseType = Union[str, int, bool, float, Decimal, dt.date, dt.datetime]
276299
"""Supported base types for Spark literals."""
277300
SparkLiteralType = Union[ # type: ignore
@@ -506,11 +529,7 @@ def _spark_safely_quote_name(field_name: str) -> str:
506529

507530
# pylint: disable=R0801
508531
def get_spark_cast_statement_from_annotation(
509-
element_name: str,
510-
type_annotation: Any,
511-
parent_element: bool = True,
512-
date_regex: str = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
513-
timestamp_regex: str = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}((\\+|\\-)[0-9]{2}:[0-9]{2})?$", # pylint: disable=C0301
532+
element_name: str, type_annotation: Any, parent_element: bool = True
514533
):
515534
"""Generate casting statements for spark dataframes based on type annotations"""
516535
type_origin = get_origin(type_annotation)
@@ -520,20 +539,18 @@ def get_spark_cast_statement_from_annotation(
520539
# An `Optional` or `Union` type, check to ensure non-heterogenity.
521540
if type_origin is Union:
522541
python_type = _get_non_heterogenous_type(get_args(type_annotation))
523-
return get_spark_cast_statement_from_annotation(
524-
element_name, python_type, parent_element, date_regex, timestamp_regex
525-
)
542+
return get_spark_cast_statement_from_annotation(element_name, python_type, parent_element)
526543

527544
# Type hint is e.g. `List[str]`, check to ensure non-heterogenity.
528545
if type_origin is list or (isinstance(type_origin, type) and issubclass(type_origin, list)):
529546
element_type = _get_non_heterogenous_type(get_args(type_annotation))
530-
stmt = f"transform({quoted_name}, x -> {get_spark_cast_statement_from_annotation('x',element_type, False, date_regex, timestamp_regex)})" # pylint: disable=C0301
547+
stmt = f"TRANSFORM({quoted_name}, x -> {get_spark_cast_statement_from_annotation('x',element_type, False)})" # pylint: disable=C0301
531548
return stmt if not parent_element else _cast_as_spark_type(stmt, type_annotation)
532549

533550
if type_origin is Annotated:
534551
python_type, *_ = get_args(type_annotation) # pylint: disable=unused-variable
535552
return get_spark_cast_statement_from_annotation(
536-
element_name, python_type, parent_element, date_regex, timestamp_regex
553+
element_name, python_type, parent_element
537554
) # add other expected params here
538555
# Ensure that we have a concrete type at this point.
539556
if not isinstance(type_annotation, type):
@@ -558,15 +575,15 @@ def get_spark_cast_statement_from_annotation(
558575
continue
559576

560577
fields[field_name] = get_spark_cast_statement_from_annotation(
561-
f"{element_name}.{field_name}", field_annotation, False, date_regex, timestamp_regex
578+
f"{element_name}.{field_name}", field_annotation, False
562579
)
563580

564581
if not fields:
565582
raise ValueError(
566583
f"No type annotations in dict/dataclass type (got {type_annotation!r})"
567584
)
568585
cast_exprs = ",".join([f"{stmt} AS `{nme}`" for nme, stmt in fields.items()])
569-
stmt = f"struct({cast_exprs})"
586+
stmt = f"STRUCT({cast_exprs})"
570587
return stmt if not parent_element else _cast_as_spark_type(stmt, type_annotation)
571588
if type_annotation is list:
572589
raise ValueError(
@@ -576,15 +593,29 @@ def get_spark_cast_statement_from_annotation(
576593
raise ValueError(f"dict must be `typing.TypedDict` subclass, got {type_annotation!r}")
577594

578595
for type_ in type_annotation.mro():
596+
_date_format: str = getattr( # type: ignore
597+
type_,
598+
"DATE_FORMAT",
599+
DEFAULT_ISO_FORMATS.get(type_, DEFAULT_ISO_FORMATS.get(dt.datetime)),
600+
)
601+
602+
# pylint: disable=C0301
603+
dt_cast_statement = f"CASE WHEN REGEXP(TRIM({quoted_name}), '{datetime_format_to_regex(_date_format)}') THEN TRY_TO_TIMESTAMP(TRIM({quoted_name}), \"{python_to_java_datetime_format(_date_format)}\") ELSE NULL END" # pylint: disable=C0301
579604
# datetime is subclass of date, so needs to be handled first
580605
if issubclass(type_, dt.datetime):
581-
stmt = rf"CASE WHEN REGEXP(TRIM({quoted_name}), '{timestamp_regex}') THEN TRIM({quoted_name}) ELSE NULL END" # pylint: disable=C0301
582-
return _cast_as_spark_type(stmt, type_) if parent_element else stmt
606+
return (
607+
_cast_as_spark_type(dt_cast_statement, type_)
608+
if parent_element
609+
else dt_cast_statement
610+
)
583611
if issubclass(type_, dt.date):
584-
stmt = rf"CASE WHEN REGEXP(TRIM({quoted_name}), '{date_regex}') THEN TRIM({quoted_name}) ELSE NULL END" # pylint: disable=C0301
585-
return _cast_as_spark_type(stmt, type_) if parent_element else stmt
612+
return (
613+
_cast_as_spark_type(dt_cast_statement, type_)
614+
if parent_element
615+
else dt_cast_statement
616+
)
586617
spark_type = get_type_from_annotation(type_)
587618
if spark_type:
588-
stmt = f"trim({quoted_name})"
619+
stmt = f"TRIM({quoted_name})"
589620
return _cast_as_spark_type(stmt, type_) if parent_element else stmt
590621
raise ValueError(f"No equivalent Spark type for {type_annotation!r}")

src/dve/core_engine/backends/utilities.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,49 @@
2323
else:
2424
from typing import Annotated, get_args, get_origin, get_type_hints
2525

26+
DEFAULT_ISO_FORMATS: dict[type, str] = {
27+
date: "%Y-%m-%d",
28+
datetime: "%Y-%m-%dT%H:%M:%S",
29+
time: "%H:%M:%S",
30+
}
31+
"""Mapping of default ISO formats to use when date format not supplied"""
32+
33+
PYTHON_DATE_FORMAT_REGEX_HELPER: dict[str, str] = {
34+
"Y": r"[0-9]{4}",
35+
"y": r"[0-9]{2}",
36+
"m": r"[0-9]{2}",
37+
"d": r"[0-9]{2}",
38+
"H": r"[0-9]{2}",
39+
"M": r"[0-9]{2}",
40+
"S": r"[0-9]{2}",
41+
"z": r"(\+|\-)?[0-9]+(\.[0-9]*)?",
42+
"Z": r"[A-Z]{0,3}",
43+
}
44+
45+
REGEXP_NEED_ESCAPE_CHARS: tuple[str, str, str] = ("+", "-", ".")
46+
"""Helper to map python date format to regexp expression. Not exhaustive, but aims to cover
47+
all foreseen use cases."""
48+
49+
50+
def datetime_format_to_regex(format_str: str) -> str:
51+
"""
52+
Helper function to convert python datetime formats to regexp string checks for casting
53+
purposes.
54+
"""
55+
56+
tokens = list(format_str.replace("%", ""))
57+
58+
for idx, tkn in enumerate(tokens):
59+
if rpl := PYTHON_DATE_FORMAT_REGEX_HELPER.get(tkn):
60+
tokens[idx] = rpl
61+
elif tkn in REGEXP_NEED_ESCAPE_CHARS:
62+
tokens[idx] = rf"\{tkn}"
63+
else:
64+
continue
65+
tokens = [PYTHON_DATE_FORMAT_REGEX_HELPER.get(tkn, tkn) for tkn in tokens]
66+
return "".join(["^", *tokens, "$"])
67+
68+
2669
PYTHON_TYPE_TO_POLARS_TYPE: dict[type, PolarsType] = {
2770
# issue with decimal conversion at the moment...
2871
str: pl.Utf8, # type: ignore

tests/features/books.feature

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,30 @@ Feature: Pipeline tests using the books dataset
4949
Then the latest audit record for the submission is marked with processing status error_report
5050
When I run the error report phase
5151
Then An error report is produced
52+
53+
Scenario: Validate complex nested XML data (spark)
54+
Given I submit the books file nested_books.XML for processing
55+
And A spark pipeline is configured with schema file 'nested_books.dischema.json'
56+
And I add initial audit entries for the submission
57+
Then the latest audit record for the submission is marked with processing status file_transformation
58+
When I run the file transformation phase
59+
Then the header entity is stored as a parquet after the file_transformation phase
60+
And the nested_books entity is stored as a parquet after the file_transformation phase
61+
And the latest audit record for the submission is marked with processing status data_contract
62+
When I run the data contract phase
63+
Then there is 1 record rejection from the data_contract phase
64+
And the header entity is stored as a parquet after the data_contract phase
65+
And the nested_books entity is stored as a parquet after the data_contract phase
66+
And the latest audit record for the submission is marked with processing status business_rules
67+
When I run the business rules phase
68+
Then The rules restrict "nested_books" to 3 qualifying records
69+
And The entity "nested_books" contains an entry for "17.85" in column "total_value_of_books"
70+
And the nested_books entity is stored as a parquet after the business_rules phase
71+
And the latest audit record for the submission is marked with processing status error_report
72+
When I run the error report phase
73+
Then An error report is produced
74+
And The statistics entry for the submission shows the following information
75+
| parameter | value |
76+
| record_count | 4 |
77+
| number_record_rejections | 2 |
78+
| number_warnings | 0 |

0 commit comments

Comments
 (0)