data-validation-engine 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/PKG-INFO +9 -7
  2. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/README.md +3 -1
  3. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/pyproject.toml +13 -10
  4. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/common/error_utils.py +1 -1
  5. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/contract.py +0 -4
  6. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/reader.py +71 -24
  7. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/rules.py +194 -3
  8. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/exceptions.py +28 -16
  9. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/duckdb_helpers.py +26 -6
  10. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/readers/csv.py +20 -17
  11. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/readers/json.py +2 -0
  12. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/readers/xml.py +8 -4
  13. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/rules.py +107 -38
  14. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/contract.py +3 -2
  15. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/readers/csv.py +6 -4
  16. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/readers/json.py +2 -0
  17. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/readers/xml.py +9 -5
  18. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/rules.py +127 -25
  19. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/spark_helpers.py +24 -4
  20. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/metadata/contract.py +1 -1
  21. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/metadata/rules.py +15 -0
  22. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/readers/csv.py +6 -8
  23. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/readers/utilities.py +12 -13
  24. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/readers/xml.py +10 -12
  25. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/readers/xml_linting.py +3 -2
  26. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/utilities.py +1 -2
  27. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/v1/__init__.py +97 -6
  28. data_validation_engine-0.10.0/src/dve/core_engine/configuration/v1/hierarchy.py +210 -0
  29. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/constants.py +5 -0
  30. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/functions/implementations.py +0 -110
  31. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/message.py +4 -2
  32. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/models.py +3 -1
  33. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/type_hints.py +10 -1
  34. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/models.py +1 -0
  35. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/service.py +5 -2
  36. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/pipeline/pipeline.py +188 -28
  37. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/pipeline/utils.py +28 -2
  38. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/reporting/__init__.py +2 -0
  39. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/reporting/error_report.py +1 -1
  40. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/reporting/excel_report.py +11 -2
  41. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/LICENSE +0 -0
  42. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/__init__.py +0 -0
  43. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/common/__init__.py +0 -0
  44. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/__init__.py +0 -0
  45. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/__init__.py +0 -0
  46. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/__init__.py +0 -0
  47. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/auditing.py +0 -0
  48. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/backend.py +0 -0
  49. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/core.py +0 -0
  50. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/reference_data.py +0 -0
  51. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/base/utilities.py +0 -0
  52. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/__init__.py +0 -0
  53. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/__init__.py +0 -0
  54. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/auditing.py +0 -0
  55. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/contract.py +0 -0
  56. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/readers/__init__.py +0 -0
  57. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/reference_data.py +0 -0
  58. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/types.py +0 -0
  59. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/duckdb/utilities.py +0 -0
  60. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/__init__.py +0 -0
  61. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/auditing.py +0 -0
  62. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/backend.py +0 -0
  63. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/readers/__init__.py +0 -0
  64. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/reference_data.py +0 -0
  65. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/types.py +0 -0
  66. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/implementations/spark/utilities.py +0 -0
  67. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/metadata/__init__.py +0 -0
  68. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/metadata/reporting.py +0 -0
  69. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/readers/__init__.py +0 -0
  70. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/backends/types.py +0 -0
  71. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/__init__.py +0 -0
  72. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/base.py +0 -0
  73. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/v1/filters.py +0 -0
  74. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/v1/rule_stores/__init__.py +0 -0
  75. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/v1/rule_stores/models.py +0 -0
  76. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/configuration/v1/steps.py +0 -0
  77. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/engine.py +0 -0
  78. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/exceptions.py +0 -0
  79. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/functions/__init__.py +0 -0
  80. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/loggers.py +0 -0
  81. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/templating.py +0 -0
  82. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/core_engine/validation.py +0 -0
  83. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/__init__.py +0 -0
  84. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/domain_types.py +0 -0
  85. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/exc.py +0 -0
  86. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/function_library.py +0 -0
  87. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/function_wrapper.py +0 -0
  88. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/model_generator.py +0 -0
  89. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/metadata_parser/utilities.py +0 -0
  90. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/__init__.py +0 -0
  91. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/exceptions.py +0 -0
  92. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/__init__.py +0 -0
  93. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/helpers.py +0 -0
  94. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/implementations/__init__.py +0 -0
  95. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/implementations/base.py +0 -0
  96. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/implementations/dbfs.py +0 -0
  97. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/implementations/file.py +0 -0
  98. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/implementations/s3.py +0 -0
  99. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/log_handler.py +0 -0
  100. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/file_handling/utilities.py +0 -0
  101. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/type_hints.py +0 -0
  102. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/parser/utilities.py +0 -0
  103. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/pipeline/__init__.py +0 -0
  104. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/pipeline/duckdb_pipeline.py +0 -0
  105. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/pipeline/foundry_ddb_pipeline.py +0 -0
  106. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/pipeline/spark_pipeline.py +0 -0
  107. {data_validation_engine-0.9.0 → data_validation_engine-0.10.0}/src/dve/reporting/constants.py +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-validation-engine
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: `nhs data validation engine` is a framework used to validate data
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Author: NHS England
8
8
  Author-email: england.contactus@nhs.net
9
- Requires-Python: >=3.10,<3.13
9
+ Requires-Python: >=3.11,<3.13
10
+ Classifier: Development Status :: 4 - Beta
10
11
  Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Operating System :: POSIX :: Linux
@@ -18,7 +18,7 @@ Requires-Dist: Jinja2 (==3.1.6)
18
18
  Requires-Dist: boto3 (>=1.34.162,<1.36)
19
19
  Requires-Dist: botocore (>=1.34.162,<1.36)
20
20
  Requires-Dist: delta-spark (>=3.0.0,<=3.2.0)
21
- Requires-Dist: duckdb (==1.1.3)
21
+ Requires-Dist: duckdb (>=1.4,<1.4.5)
22
22
  Requires-Dist: lxml (==6.1.1)
23
23
  Requires-Dist: numpy (==1.26.4)
24
24
  Requires-Dist: openpyxl (==3.1.5)
@@ -27,8 +27,8 @@ Requires-Dist: polars (==0.20.31)
27
27
  Requires-Dist: pyarrow (==23.0.1)
28
28
  Requires-Dist: pydantic (==2.13.4)
29
29
  Requires-Dist: pyspark (>=3.5.0,<=3.5.5)
30
- Requires-Dist: typing_extensions (==4.15.0)
31
- Requires-Dist: urllib3 (==2.7.0)
30
+ Requires-Dist: typing_extensions (==4.16.0)
31
+ Requires-Dist: urllib3 (==2.8.0)
32
32
  Project-URL: Changelog, https://github.com/NHSDigital/data-validation-engine/blob/main/CHANGELOG.md
33
33
  Project-URL: Documentation, https://nhsdigital.github.io/data-validation-engine/
34
34
  Project-URL: Homepage, https://github.com/NHSDigital/data-validation-engine
@@ -55,6 +55,7 @@ __The DVE offers__:
55
55
  - Format normalization to Parquet for a unified data representation
56
56
  - Data modelling and typecasting
57
57
  - Business-rule validations executed on supported backends such as Spark and DuckDB, with the option to add custom backends
58
+ - Validate and enforce referential integrity checks with minimal configuration
58
59
  - Deriving new fields and entities
59
60
  - Clear validation reporting, including summary insights and record-level error messages
60
61
 
@@ -79,7 +80,8 @@ Below is a list of features that we would like to implement or have been request
79
80
  | Uplift to Python 3.11 | 0.2.0 | Yes |
80
81
  | Uplift Pyspark to 3.5 | 0.8.0 | Yes |
81
82
  | Allow DVE to run on Python 3.12+ | 0.8.0 | Yes |
82
- | Upgrade to Pydantic 2.0 | 0.9.0 | Yes |
83
+ | Upgrade to Pydantic 2.0 | 0.9.0 | Yes |
84
+ | Upgrade DuckDB to v1.4 | 0.10.0 | Yes |
83
85
  | Uplift Pyspark to 4.0+ | TBA | No |
84
86
  | Polars upgrade to v1+ | TBA | No |
85
87
  | DuckDB upgrade to v1.5+ | TBA | No |
@@ -17,6 +17,7 @@ __The DVE offers__:
17
17
  - Format normalization to Parquet for a unified data representation
18
18
  - Data modelling and typecasting
19
19
  - Business-rule validations executed on supported backends such as Spark and DuckDB, with the option to add custom backends
20
+ - Validate and enforce referential integrity checks with minimal configuration
20
21
  - Deriving new fields and entities
21
22
  - Clear validation reporting, including summary insights and record-level error messages
22
23
 
@@ -41,7 +42,8 @@ Below is a list of features that we would like to implement or have been request
41
42
  | Uplift to Python 3.11 | 0.2.0 | Yes |
42
43
  | Uplift Pyspark to 3.5 | 0.8.0 | Yes |
43
44
  | Allow DVE to run on Python 3.12+ | 0.8.0 | Yes |
44
- | Upgrade to Pydantic 2.0 | 0.9.0 | Yes |
45
+ | Upgrade to Pydantic 2.0 | 0.9.0 | Yes |
46
+ | Upgrade DuckDB to v1.4 | 0.10.0 | Yes |
45
47
  | Uplift Pyspark to 4.0+ | TBA | No |
46
48
  | Polars upgrade to v1+ | TBA | No |
47
49
  | DuckDB upgrade to v1.5+ | TBA | No |
@@ -7,8 +7,8 @@ authors = [
7
7
  ]
8
8
  readme = "README.md"
9
9
  classifiers = [
10
+ "Development Status :: 4 - Beta",
10
11
  "Programming Language :: Python :: 3",
11
- "Programming Language :: Python :: 3.10",
12
12
  "Programming Language :: Python :: 3.11",
13
13
  "Programming Language :: Python :: 3.12",
14
14
  "Operating System :: POSIX :: Linux",
@@ -25,17 +25,17 @@ Issues = "https://github.com/NHSDigital/data-validation-engine/issues"
25
25
  Changelog = "https://github.com/NHSDigital/data-validation-engine/blob/main/CHANGELOG.md"
26
26
 
27
27
  [tool.poetry]
28
- version = "0.9.0"
28
+ version = "0.10.0"
29
29
  packages = [
30
30
  { include = "dve", from = "src" },
31
31
  ]
32
32
 
33
33
  [tool.poetry.dependencies]
34
- python = ">=3.10,<3.13" # breaking changes beyond 3.12
34
+ python = ">=3.11,<3.13" # breaking changes beyond 3.12
35
35
  boto3 = ">=1.34.162,<1.36" # breaking change beyond 1.36
36
36
  botocore = ">=1.34.162,<1.36" # breaking change beyond 1.36
37
37
  delta-spark = ">=3.0.0,<=3.2.0"
38
- duckdb = "1.1.3" # breaking changes beyond 1.1
38
+ duckdb = ">=1.4,<1.4.5"
39
39
  Jinja2 = "3.1.6"
40
40
  lxml = "6.1.1"
41
41
  numpy = "1.26.4"
@@ -45,8 +45,8 @@ polars = "0.20.31"
45
45
  pyarrow = "23.0.1"
46
46
  pydantic = "2.13.4"
47
47
  pyspark = ">=3.5.0,<=3.5.5"
48
- typing_extensions = "4.15.0"
49
- urllib3 = "2.7.0" # dependency of boto3 & botocore
48
+ typing_extensions = "4.16.0"
49
+ urllib3 = "2.8.0" # dependency of boto3 & botocore
50
50
 
51
51
  [tool.poetry.group.dev]
52
52
  optional = true
@@ -59,8 +59,11 @@ include-groups = [
59
59
  commitizen = "4.9.1"
60
60
  pre-commit = "4.3.0"
61
61
  charset-normalizer = "3.4.6"
62
- python-discovery = "1.2.0"
62
+ python-discovery = "1.6.1"
63
63
  idna = "3.15" # dependency of requests
64
+ virtualenv = "21.14.9"
65
+ certifi = "2026.10.23"
66
+ filelock = "4.2.1"
64
67
 
65
68
  [tool.poetry.group.test]
66
69
  optional = true
@@ -71,7 +74,7 @@ coverage = "7.11.0"
71
74
  moto = {extras = ["s3"], version = "4.2.14"}
72
75
  cryptography = "50.0.0" # dependency of `moto`
73
76
  requests = "2.33.0" # dependency of `moto`
74
- Werkzeug = "3.1.6"
77
+ Werkzeug = "3.1.9"
75
78
  pytest = "9.0.3"
76
79
  pytest-lazy-fixtures = "1.4.0" # switched from https://github.com/TvoroG/pytest-lazy-fixture as it's no longer supported
77
80
  xlsx2csv = "0.8.4" # polars requirement
@@ -80,7 +83,7 @@ xlsx2csv = "0.8.4" # polars requirement
80
83
  optional = true
81
84
 
82
85
  [tool.poetry.group.lint.dependencies]
83
- black = "26.3.1"
86
+ black = "26.10.1"
84
87
  astroid = "3.3.9"
85
88
  isort = "5.13.2"
86
89
  pylint = "3.3.9"
@@ -108,7 +111,7 @@ mkdocs = "1.6.1"
108
111
  mkdocstrings = { version = "1.0.3", extras = ["python"] }
109
112
  griffelib = "2.0.1"
110
113
  pymdown-extensions = "11.0.1"
111
- zensical = "0.0.46"
114
+ zensical = "0.0.63"
112
115
 
113
116
  [tool.ruff]
114
117
  line-length = 100
@@ -5,7 +5,7 @@ import json
5
5
  import logging
6
6
  from collections.abc import Iterable
7
7
  from itertools import chain
8
- from multiprocessing import Queue
8
+ from queue import Queue
9
9
  from threading import Thread
10
10
  from typing import Optional, Union
11
11
 
@@ -443,10 +443,6 @@ class BaseDataContract(Generic[EntityType], ABC):
443
443
  ],
444
444
  )
445
445
 
446
- if contract_metadata.cache_originals:
447
- for entity_name in list(entities):
448
- entities[f"Original{entity_name}"] = entities[entity_name]
449
-
450
446
  return entities, feedback_errors_uri, successful, processing_errors_uri
451
447
 
452
448
  def read_parquet(self, path: URI, **kwargs) -> EntityType:
@@ -8,8 +8,16 @@ from typing import Any, ClassVar, Optional, TypeVar
8
8
  from pydantic import BaseModel
9
9
  from typing_extensions import Protocol
10
10
 
11
- from dve.core_engine.backends.exceptions import MessageBearingError, ReaderLacksEntityTypeSupport
11
+ from dve.core_engine.backends.exceptions import (
12
+ CriticalMessageBearingError,
13
+ MessageBearingError,
14
+ ReaderLacksEntityTypeSupport,
15
+ )
12
16
  from dve.core_engine.backends.types import EntityName, EntityType
17
+ from dve.core_engine.configuration.v1 import (
18
+ AllowedAdditionalReaderChecks,
19
+ _ReaderAdditionalChecksConfig,
20
+ )
13
21
  from dve.core_engine.message import FeedbackMessage
14
22
  from dve.core_engine.type_hints import URI, ArbitraryFunction, WrapDecorator
15
23
  from dve.parser.file_handling.service import open_stream
@@ -65,6 +73,10 @@ class BaseFileReader(ABC):
65
73
  decorated with the '@read_function' decorator, and is used in `read_entity_type`.
66
74
 
67
75
  """
76
+ ft_error_code: Optional[str] = "MalformedFile"
77
+ """Default error code for when a file/submission cannot be parsed succesfully."""
78
+ ft_error_message: Optional[str] = "The resource doesn't seem to be a valid text file"
79
+ """Default error message for when a file/submission cannot be parsed succesfully."""
68
80
 
69
81
  def __init_subclass__(cls, *_, **__) -> None:
70
82
  """When this class is subclassed, create and populate the `__read_methods__`
@@ -109,7 +121,10 @@ class BaseFileReader(ABC):
109
121
  entity_name: EntityName,
110
122
  schema: type[BaseModel],
111
123
  all_model_fields: Optional[set[str]] = None,
112
- ) -> EntityType:
124
+ additional_checks: Optional[
125
+ dict[AllowedAdditionalReaderChecks, _ReaderAdditionalChecksConfig]
126
+ ] = None,
127
+ ):
113
128
  """Read to the specified entity type, if supported.
114
129
 
115
130
  NOTE: Simple types should either be returned as strings (if present) or
@@ -117,21 +132,47 @@ class BaseFileReader(ABC):
117
132
  data contract.
118
133
 
119
134
  """
120
- if entity_name == Iterator[dict[str, Any]]:
121
- return self.read_to_py_iterator(
135
+ additional_checks = additional_checks or {}
136
+
137
+ self.raise_if_not_sensible_file(resource, entity_name)
138
+
139
+ if entity_type == Iterator[dict[str, Any]]:
140
+ entity = self.read_to_py_iterator(
122
141
  resource, entity_name, schema, all_model_fields # type: ignore
123
142
  )
124
143
 
125
- self.raise_if_not_sensible_file(resource, entity_name)
144
+ else:
126
145
 
127
- try:
128
- reader_func = self.__read_methods__[entity_type]
129
- except KeyError as err:
130
- raise ReaderLacksEntityTypeSupport(entity_type=entity_type) from err
146
+ try:
147
+ reader_func = self.__read_methods__[entity_type]
148
+ except KeyError as err:
149
+ raise ReaderLacksEntityTypeSupport(entity_type=entity_type) from err
131
150
 
132
- return reader_func(
133
- self, resource, entity_name, schema, all_model_fields=all_model_fields # type: ignore
134
- )
151
+ entity = reader_func(
152
+ self,
153
+ resource,
154
+ entity_name,
155
+ schema,
156
+ all_model_fields=all_model_fields, # type: ignore
157
+ )
158
+
159
+ if config := additional_checks.get("check_empty"):
160
+ if self.check_entity_empty(entity):
161
+ raise MessageBearingError(
162
+ f"The mandatory entity {entity_name} is empty",
163
+ messages=[
164
+ FeedbackMessage(
165
+ entity=entity_name,
166
+ record=None,
167
+ failure_type="submission",
168
+ error_location=entity_name,
169
+ error_code=config.error_code,
170
+ error_message=config.error_message,
171
+ )
172
+ ],
173
+ )
174
+
175
+ return entity
135
176
 
136
177
  def add_record_index(self, entity: EntityType, **kwargs) -> EntityType:
137
178
  """Add a record index to the entity"""
@@ -141,6 +182,10 @@ class BaseFileReader(ABC):
141
182
  """Drop a record index to the entity"""
142
183
  raise NotImplementedError(f"drop_record_index not implemented in {self.__class__}")
143
184
 
185
+ def check_entity_empty(self, entity: EntityType) -> bool:
186
+ """Determine if the entity supplied is empty"""
187
+ raise NotImplementedError(f"check_entity_empty not implemented in {self.__class__}")
188
+
144
189
  def write_parquet(
145
190
  self,
146
191
  entity: EntityType,
@@ -171,20 +216,22 @@ class BaseFileReader(ABC):
171
216
  return False
172
217
  return True
173
218
 
174
- def raise_if_not_sensible_file(self, resource: URI, entity_name: str):
219
+ def raise_if_not_sensible_file(
220
+ self,
221
+ resource: URI,
222
+ entity_name: str,
223
+ ):
175
224
  """Sense check that the file is a text file. Raise error if doesn't
176
225
  appear to be the case."""
177
226
  if not self._check_likely_text_file(resource):
178
- raise MessageBearingError(
227
+ raise CriticalMessageBearingError(
179
228
  "The submitted file doesn't appear to be text",
180
- messages=[
181
- FeedbackMessage(
182
- entity=entity_name,
183
- record=None,
184
- failure_type="submission",
185
- error_location="Whole File",
186
- error_code="MalformedFile",
187
- error_message="The resource doesn't seem to be a valid text file",
188
- )
189
- ],
229
+ message=FeedbackMessage(
230
+ entity=entity_name,
231
+ record=None,
232
+ failure_type="submission",
233
+ error_location="Whole File",
234
+ error_code=self.ft_error_code,
235
+ error_message=self.ft_error_message,
236
+ ),
190
237
  )
@@ -3,8 +3,8 @@
3
3
  import logging
4
4
  from abc import ABC, abstractmethod
5
5
  from collections import defaultdict
6
- from collections.abc import Iterable
7
- from typing import Any, ClassVar, Generic, NoReturn, Optional, TypeVar
6
+ from collections.abc import Iterable, Iterator
7
+ from typing import Any, ClassVar, Generic, MutableMapping, NoReturn, Optional, TypeVar
8
8
  from uuid import uuid4
9
9
 
10
10
  from typing_extensions import Literal, Protocol, get_type_hints
@@ -27,6 +27,7 @@ from dve.core_engine.backends.metadata.rules import (
27
27
  CopyEntity,
28
28
  DeferredFilter,
29
29
  EntityRemoval,
30
+ GroupIdentification,
30
31
  HeaderJoin,
31
32
  ImmediateFilter,
32
33
  InnerJoin,
@@ -43,8 +44,11 @@ from dve.core_engine.backends.metadata.rules import (
43
44
  TableUnion,
44
45
  )
45
46
  from dve.core_engine.backends.types import Entities, EntityType, StageSuccessful
47
+ from dve.core_engine.configuration.v1.hierarchy import EntityHierarchy, HierarchyNode
46
48
  from dve.core_engine.exceptions import CriticalProcessingError
47
49
  from dve.core_engine.loggers import get_logger
50
+ from dve.core_engine.message import FeedbackMessage
51
+ from dve.core_engine.templating import template_object
48
52
  from dve.core_engine.type_hints import URI, DVEStageName, EntityName, Messages, TemplateVariables
49
53
 
50
54
  T_contra = TypeVar("T_contra", bound=AbstractStep, contravariant=True)
@@ -55,6 +59,8 @@ _StepFunctions = dict[type[T], "_UnboundStepFunction[T]"]
55
59
  """A convenience type indicating a mapping from config type to step method."""
56
60
  Stage = Literal["Pre-filter", "Filter", "Post-filter"]
57
61
  """The name of a stage within a rule."""
62
+ TempTableName = str
63
+ """temp tables to cache intermediate results"""
58
64
 
59
65
 
60
66
  class _UnboundStepFunction(Generic[T_contra], Protocol): # pylint: disable=too-few-public-methods
@@ -128,6 +134,7 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
128
134
  ):
129
135
  self.logger = logger or get_logger(type(self).__name__)
130
136
  """The `logging.Logger instance for the data contract config."""
137
+ self.entity_cache_tracker: MutableMapping[EntityName, TempTableName] = {}
131
138
 
132
139
  @classmethod
133
140
  @abstractmethod
@@ -307,7 +314,8 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
307
314
  """
308
315
  raise NotImplementedError
309
316
 
310
- def identify_orphans(self, entities: Entities, *, config: OrphanIdentification) -> Messages:
317
+ @abstractmethod
318
+ def identify_orphans(self, entities: Entities, *, config: OrphanIdentification) -> Iterable:
311
319
  """Identify records in an entity which don't have at least one corresponding
312
320
  match in the target. A new boolean column will be added to `entity` ('IsOrphaned')
313
321
  indicating whether the condition matched.
@@ -320,6 +328,14 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
320
328
  """
321
329
  raise NotImplementedError
322
330
 
331
+ @abstractmethod
332
+ def check_mandatory_group(self, entities: Entities, *, config: GroupIdentification) -> Iterator:
333
+ """
334
+ Check that a mandatory key in an entity has at least one valid entry in the all the child
335
+ entities.
336
+ """
337
+ raise NotImplementedError
338
+
323
339
  @abstractmethod
324
340
  def union(self, entities: Entities, *, config: TableUnion) -> Messages:
325
341
  """Union two entities together, taking the columns from each by name.
@@ -352,6 +368,157 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
352
368
 
353
369
  """
354
370
 
371
+ def identify_and_remove_orphans(
372
+ self,
373
+ working_directory: URI,
374
+ entities: Entities,
375
+ entity_hierarchy: EntityHierarchy,
376
+ key_fields: Optional[dict[str, list[str]]] = None,
377
+ ) -> tuple[Messages, dict[EntityName, bool]]:
378
+ """
379
+ Identifies and removes orphan records by traversing the EntityHierarchy object.
380
+ An orphan is a child record whose parent FK does not exist in the parent entity.
381
+ Processes recursively: removes orphans at each level, then processes children.
382
+ """
383
+
384
+ def process_node(node: HierarchyNode) -> bool:
385
+ """Identify orphans and remove in a given node"""
386
+ issues_found: bool = False
387
+ if node.parent_entity is None:
388
+ return issues_found
389
+
390
+ self.logger.info(f"Checking for orphan records in {node.entity_name}")
391
+
392
+ join_expr = " AND ".join(
393
+ f"{node.parent_entity}.{k} = {node.entity_name}.{v}"
394
+ for k, v in node.join_fields.items()
395
+ )
396
+ location = list(node.join_fields.values())[0]
397
+ with BackgroundMessageWriter(
398
+ working_directory=working_directory,
399
+ dve_stage=self.__stage_name__,
400
+ key_fields=key_fields,
401
+ logger=self.logger,
402
+ ) as msg_writer:
403
+ _orph_records = self.identify_orphans(
404
+ entities=entities,
405
+ config=OrphanIdentification(
406
+ id=list(node.join_fields.values())[0],
407
+ entity_name=node.entity_name,
408
+ target_name=node.parent_entity,
409
+ join_condition=join_expr,
410
+ ),
411
+ )
412
+ _messages = [
413
+ FeedbackMessage(
414
+ entity=node.entity_name,
415
+ record=record, # type: ignore
416
+ error_location=location,
417
+ error_message=template_object(node.missing_parent_id_error_message, record),
418
+ failure_type="record",
419
+ error_type="record",
420
+ error_code=node.missing_parent_id_error_code,
421
+ reporting_field=location,
422
+ category="Parent Missing",
423
+ )
424
+ for record in _orph_records
425
+ ]
426
+ msg_writer.write_queue.put(_messages)
427
+
428
+ self.cache_entity(node.entity_name, entities)
429
+
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
437
+
438
+ entity_issues_found: dict[EntityName, bool] = {}
439
+
440
+ for tree in entity_hierarchy.entity_trees.values():
441
+ for node in tree.iterate_root_down():
442
+ entity_issues_found[node.entity_name] = process_node(node)
443
+
444
+ return [], entity_issues_found
445
+
446
+ def identify_and_remove_missing_mandatory_groups(
447
+ self,
448
+ working_directory: URI,
449
+ entities: Entities,
450
+ entity_hierarchy: EntityHierarchy,
451
+ key_fields: Optional[dict[str, list[str]]] = None,
452
+ ) -> tuple[Messages, dict[EntityName, bool]]:
453
+ """
454
+ Identify that an entity with a mandatory key has at least one valid child record.
455
+ """
456
+
457
+ def process_node(node: HierarchyNode) -> bool:
458
+ """Identify at least one valid child for a mandatory entity at a given node."""
459
+ if node.parent_entity is None or not node.mandatory:
460
+ return False
461
+
462
+ self.logger.info(
463
+ f"Identifying that mandatory entity `{node.parent_entity}` has at least 1 valid child record in {node.entity_name}" # pylint: disable=C0301
464
+ )
465
+
466
+ join_expr = " AND ".join(
467
+ f"{node.parent_entity}.{k} = {node.entity_name}.{v}"
468
+ for k, v in node.join_fields.items()
469
+ )
470
+
471
+ with BackgroundMessageWriter(
472
+ working_directory=working_directory,
473
+ dve_stage=self.__stage_name__,
474
+ key_fields=key_fields,
475
+ logger=self.logger,
476
+ ) as msg_writer:
477
+ location = next(iter(node.join_fields.values()))
478
+ missing_children_records = self.check_mandatory_group(
479
+ entities=entities,
480
+ config=GroupIdentification(
481
+ entity_name=node.parent_entity,
482
+ target_name=node.entity_name,
483
+ join_condition=join_expr,
484
+ ),
485
+ )
486
+ _messages = [
487
+ FeedbackMessage(
488
+ entity=node.parent_entity,
489
+ record=record, # type: ignore
490
+ error_location=location,
491
+ error_message=template_object(node.no_valid_records_error_message, record),
492
+ failure_type="record",
493
+ error_type="record",
494
+ error_code=node.no_valid_records_error_code,
495
+ reporting_field=location,
496
+ category="Children missing",
497
+ )
498
+ for record in missing_children_records
499
+ ]
500
+ msg_writer.write_queue.put(_messages)
501
+ self.cache_entity(node.parent_entity, entities)
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
510
+
511
+ entity_issues_found: dict[EntityName, bool] = {}
512
+
513
+ for tree in entity_hierarchy.entity_trees.values():
514
+ for node in tree.iterate_lowest_descendent_up():
515
+ if node.parent_entity and node.mandatory:
516
+ result = process_node(node)
517
+ if result:
518
+ entity_issues_found[node.parent_entity] = result
519
+
520
+ return [], entity_issues_found
521
+
355
522
  # pylint: disable=R0912,R0914
356
523
  def apply_sync_filters(
357
524
  self,
@@ -428,6 +595,7 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
428
595
  excluded_columns=filter_column_names,
429
596
  reporting=rule.reporting,
430
597
  parent=rule.parent,
598
+ error_on_null=True,
431
599
  ),
432
600
  )
433
601
  if not success:
@@ -459,6 +627,7 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
459
627
  expression=f"NOT ({rule.expression})",
460
628
  reporting=rule.reporting,
461
629
  parent=rule.parent,
630
+ error_on_null=True,
462
631
  ),
463
632
  )
464
633
  if not success:
@@ -691,3 +860,25 @@ class BaseStepImplementations(Generic[EntityType], ABC): # pylint: disable=too-
691
860
  ):
692
861
  """Method to filter out record rejection errors from the data contract for a given entity"""
693
862
  raise NotImplementedError()
863
+
864
+ @staticmethod
865
+ def get_entity_count(entity: EntityType) -> int:
866
+ """Method to get count of records in entity"""
867
+ raise NotImplementedError()
868
+
869
+ def cache_entity(self, entity_name: EntityName, entities: Entities):
870
+ """Store the materialised query in memory and update entity to query directly.
871
+ If the entity is already cached, the new cache should be created first, then the old one
872
+ removed as part of the function (in case the newer cache depends on the older one)."""
873
+ raise NotImplementedError()
874
+
875
+ def _remove_cached_artifact(self, entity_name: EntityName):
876
+ """Delete artifact in memory and clear from the entity cache keeping track.
877
+ This should not be used directly as removing artifacts from memory, may lead to some
878
+ entities being unable to be processed as their execution plans depend on these artifacts."""
879
+ raise NotImplementedError()
880
+
881
+ def clear_entity_cache(self):
882
+ """Helper method to remove all artifacts and cache trackers at end of processing."""
883
+ for entity_name in list(self.entity_cache_tracker):
884
+ self._remove_cached_artifact(entity_name)
@@ -33,27 +33,39 @@ class MessageBearingError(BackendError):
33
33
  """The messages to be returned as part of the error."""
34
34
 
35
35
 
36
- class UnableToParseCSVError(MessageBearingError):
36
+ class CriticalMessageBearingError(BackendError):
37
+ """
38
+ A backend error that comes with a pre-created message.
39
+ The intention of this exception vs MessageBearingError is that
40
+ this should be used to halt the processing of a submission.
41
+ """
42
+
43
+ def __init__(self, *args: object, message: FeedbackMessage) -> None:
44
+ super().__init__(*args)
45
+ self.message = message
46
+ """The message to be returned as part of the error."""
47
+
48
+
49
+ class UnableToParseCSVError(CriticalMessageBearingError):
37
50
  """An error raised when unable to parse a CSV file"""
38
51
 
39
52
  def __init__(
40
- self, entity_name: str, field_check_error_message: str, field_check_error_code: str
53
+ self,
54
+ entity_name: Optional[str],
55
+ error_message: Optional[str],
56
+ error_code: Optional[str],
41
57
  ):
42
58
  super().__init__(
43
- messages=[
44
- FeedbackMessage(
45
- entity="csv_structure",
46
- record={
47
- entity_name: "Unable to parse file. Please check the structure of the file."
48
- },
49
- failure_type="submission",
50
- is_informational=False,
51
- error_type="csv read",
52
- error_location=entity_name,
53
- error_message=field_check_error_message,
54
- error_code=field_check_error_code,
55
- )
56
- ]
59
+ message=FeedbackMessage(
60
+ entity=entity_name,
61
+ record=None,
62
+ failure_type="submission",
63
+ is_informational=False,
64
+ error_type="csv read",
65
+ error_message=error_message
66
+ or "Unable to parse the CSV file. Please check the structure of your CSV.", # pylint: disable=C0301
67
+ error_code=error_code or "MalformedCSV",
68
+ )
57
69
  )
58
70
 
59
71