Skip to content

Commit f8cd7e8

Browse files
fix: fix issue with non iso date/datetime values in duck db casting after data contract validations
1 parent e00a88c commit f8cd7e8

6 files changed

Lines changed: 73 additions & 22 deletions

File tree

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ def __call__(self):
9393
time: ddbtyp.TIME,
9494
}
9595
"""A mapping of Python types to the equivalent DuckDB types."""
96-
96+
DEFAULT_ISO_FORMATS: dict[type, str] = {
97+
date: "YYYY-MM-DD",
98+
datetime: "YYYY-MM-DDTHH:MM:SS",
99+
time: "HH:MM:SS"
100+
}
101+
"""Mapping of default ISO formats to use when date format not supplied"""
97102

98103
def table_exists(connection: DuckDBPyConnection, table_name: str) -> bool:
99104
"""check if a table exists in a given DuckDBPyConnection"""
@@ -378,9 +383,6 @@ def get_duckdb_cast_statement_from_annotation(
378383
element_name: str,
379384
type_annotation: Any,
380385
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}$",
384386
) -> str:
385387
"""Generate casting statements for duckdb relations from type annotations"""
386388
type_origin = get_origin(type_annotation)
@@ -391,19 +393,19 @@ def get_duckdb_cast_statement_from_annotation(
391393
if type_origin is Union:
392394
python_type = _get_non_heterogenous_type(get_args(type_annotation))
393395
return get_duckdb_cast_statement_from_annotation(
394-
element_name, python_type, parent_element, date_regex, timestamp_regex
396+
element_name, python_type, parent_element,
395397
)
396398

397399
# Type hint is e.g. `List[str]`, check to ensure non-heterogenity.
398400
if type_origin is list or (isinstance(type_origin, type) and issubclass(type_origin, list)):
399401
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
402+
stmt = f"list_transform({quoted_name}, x -> {get_duckdb_cast_statement_from_annotation('x',element_type, False,)})" # pylint: disable=C0301
401403
return stmt if not parent_element else _cast_as_ddb_type(stmt, type_annotation)
402404

403405
if type_origin is Annotated:
404406
python_type, *other_args = get_args(type_annotation) # pylint: disable=unused-variable
405407
return get_duckdb_cast_statement_from_annotation(
406-
element_name, python_type, parent_element, date_regex, timestamp_regex
408+
element_name, python_type, 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,7 +430,7 @@ 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}", field_annotation, False,
432434
)
433435

434436
if not fields:
@@ -447,15 +449,21 @@ def get_duckdb_cast_statement_from_annotation(
447449
raise ValueError(f"dict must be `typing.TypedDict` subclass, got {type_annotation!r}")
448450

449451
for type_ in type_annotation.mro():
452+
_date_format = getattr(type_, "DATE_FORMAT", None)
453+
if _date_format:
454+
dt_cast_statement = f"try_strptime(TRIM({quoted_name}), '{_date_format}')"
455+
else:
456+
dt_cast_statement = f"try_strptime(TRIM({quoted_name}), '{DEFAULT_ISO_FORMATS.get(type_)}')"
457+
450458
# datetime is subclass of date, so needs to be handled first
451459
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
460+
stmt = rf"TRY_CAST({dt_cast_statement} as TIMESTAMP)"
453461
return stmt
454462
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
463+
stmt = rf"TRY_CAST({dt_cast_statement} as DATE)"
456464
return stmt
457465
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
466+
stmt = rf"TRY_CAST({dt_cast_statement} as TIME)"
459467
return stmt
460468
duck_type = get_duckdb_type_from_annotation(type_)
461469
if duck_type:

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 |

tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_duckdb_helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,11 @@ def test_duckdb_rel_to_dictionaries(temp_ddb_conn: DuckDBPyConnection,
216216
@pytest.mark.parametrize("field_name,field_type,cast_statement",
217217
[("str_test", str, "try_cast(trim(\"str_test\") as VARCHAR)"),
218218
("int_test", int, "try_cast(trim(\"int_test\") as BIGINT)"),
219-
("date_test", datetime.date,"CASE WHEN REGEXP_MATCHES(TRIM(\"date_test\"), '^[0-9]{4}-[0-9]{2}-[0-9]{2}$') THEN TRY_CAST(TRIM(\"date_test\") as DATE) ELSE NULL END"),
220-
("timestamp_test", datetime.datetime,"CASE WHEN REGEXP_MATCHES(TRIM(\"timestamp_test\"), '^[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})?$') THEN TRY_CAST(TRIM(\"timestamp_test\") as TIMESTAMP) ELSE NULL END"),
219+
("date_test", datetime.date,"TRY_CAST(try_strptime(TRIM(\"date_test\"), 'YYYY-MM-DD') as DATE)"),
220+
("timestamp_test", datetime.datetime, "TRY_CAST(try_strptime(TRIM(\"timestamp_test\"), 'YYYY-MM-DDTHH:MM:SS') as TIMESTAMP)"),
221221
("list_int_field", list[int], "try_cast(list_transform(\"list_int_field\", x -> trim(\"x\")) as BIGINT[])"),
222-
("basic_model", BasicModel, "try_cast(struct_pack(\"str_field\":= trim(\"basic_model\".str_field),\"date_field\":= CASE WHEN REGEXP_MATCHES(TRIM(\"basic_model\".date_field), '^[0-9]{4}-[0-9]{2}-[0-9]{2}$') THEN TRY_CAST(TRIM(\"basic_model\".date_field) as DATE) ELSE NULL END) as STRUCT(str_field VARCHAR, date_field DATE))"),
223-
("another_model", AnotherModel, "try_cast(struct_pack(\"unique_id\":= trim(\"another_model\".unique_id),\"basic_models\":= list_transform(\"another_model\".basic_models, x -> struct_pack(\"str_field\":= trim(\"x\".str_field),\"date_field\":= CASE WHEN REGEXP_MATCHES(TRIM(\"x\".date_field), '^[0-9]{4}-[0-9]{2}-[0-9]{2}$') THEN TRY_CAST(TRIM(\"x\".date_field) as DATE) ELSE NULL END))) as STRUCT(unique_id BIGINT, basic_models STRUCT(str_field VARCHAR, date_field DATE)[]))")])
222+
("basic_model", BasicModel, "try_cast(struct_pack(\"str_field\":= trim(\"basic_model\".str_field),\"date_field\":= TRY_CAST(try_strptime(TRIM(\"basic_model\".date_field), 'YYYY-MM-DD') as DATE)) as STRUCT(str_field VARCHAR, date_field DATE))"),
223+
("another_model", AnotherModel, "try_cast(struct_pack(\"unique_id\":= trim(\"another_model\".unique_id),\"basic_models\":= list_transform(\"another_model\".basic_models, x -> struct_pack(\"str_field\":= trim(\"x\".str_field),\"date_field\":= TRY_CAST(try_strptime(TRIM(\"x\".date_field), 'YYYY-MM-DD') as DATE)))) as STRUCT(unique_id BIGINT, basic_models STRUCT(str_field VARCHAR, date_field DATE)[]))")])
224224
def test_get_duckdb_cast_statement_from_annotation(field_name, field_type, cast_statement):
225225
assert get_duckdb_cast_statement_from_annotation(field_name, field_type) == cast_statement
226226

tests/testdata/books/nested_books.XML

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<title>XML Developer's Guide</title>
1010
<genre>Computer</genre>
1111
<price>44.95</price>
12-
<publish_date>2000-10-01</publish_date>
12+
<publish_date>01-10-2000</publish_date>
1313
<description>An in-depth look at creating applications
1414
with XML.</description>
1515
</book>
@@ -20,7 +20,7 @@
2020
<title>Midnight Rain</title>
2121
<genre>Fantasy</genre>
2222
<price>5.95</price>
23-
<publish_date>2000-12-16</publish_date>
23+
<publish_date>16-12-2000</publish_date>
2424
<description>A former architect battles corporate zombies,
2525
an evil sorceress, and her own childhood to become queen
2626
of the world.</description>
@@ -35,7 +35,7 @@
3535
<title>Maeve Ascendant</title>
3636
<genre>Fantasy</genre>
3737
<price>5.95</price>
38-
<publish_date>2000-11-17</publish_date>
38+
<publish_date>17-11-2000</publish_date>
3939
<description>After the collapse of a nanotechnology
4040
society in England, the young survivors lay the
4141
foundation for a new society.</description>
@@ -44,7 +44,7 @@
4444
<title>Oberon's Legacy</title>
4545
<genre>Fantasy</genre>
4646
<price>5.95</price>
47-
<publish_date>2001-03-10</publish_date>
47+
<publish_date>10-03-2001</publish_date>
4848
<description>In post-apocalypse England, the mysterious
4949
agent known only as Oberon helps to create a new life
5050
for the inhabitants of London. Sequel to Maeve
@@ -54,7 +54,7 @@
5454
<title>The Sundered Grail</title>
5555
<genre>Fantasy</genre>
5656
<price>5.95</price>
57-
<publish_date>2001-09-10</publish_date>
57+
<publish_date>10-09-2001</publish_date>
5858
<description>The two daughters of Maeve, half-sisters,
5959
battle one another for control of England. Sequel to
6060
Oberon's Legacy.</description>

tests/testdata/books/nested_books.dischema.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
{
22
"contract": {
3+
"types": {
4+
"non_iso_date": {
5+
"callable": "conformatteddate",
6+
"constraints": {
7+
"date_format": "%d-%m-%Y"
8+
}
9+
}
10+
},
311
"schemas": {
412
"book": {
513
"fields": {
614
"title": "str",
715
"genre": "str",
816
"price": "str",
9-
"publish_date": "date",
17+
"publish_date": "non_iso_date",
1018
"description": "str"
1119
},
1220
"mandatory_fields": [

tests/testdata/books/nested_books_ddb.dischema.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
{
22
"contract": {
3+
"types": {
4+
"non_iso_date": {
5+
"callable": "conformatteddate",
6+
"constraints": {
7+
"date_format": "%d-%m-%Y"
8+
}
9+
}
10+
},
311
"schemas": {
412
"book": {
513
"fields": {
614
"title": "str",
715
"genre": "str",
816
"price": "str",
9-
"publish_date": "date",
17+
"publish_date": "non_iso_date",
1018
"description": "str"
1119
},
1220
"mandatory_fields": [

0 commit comments

Comments
 (0)