From 674575dd2524ecd92bce490711f96d0a76f3af38 Mon Sep 17 00:00:00 2001 From: George Robertson <50412379+georgeRobertson@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:44:22 +0000 Subject: [PATCH 1/5] docs: add Zensical autodocs and update docs --- .github/workflows/ci_docs_publish.yml | 49 +++ .github/workflows/ci_linting.yml | 4 +- .github/workflows/ci_testing.yml | 4 +- docs/README.md | 304 -------------- docs/advanced_guidance/index.md | 21 + docs/advanced_guidance/json_schemas.md | 35 ++ .../components/base_entity.schema.json | 0 .../contact_error_details.schema.json | 0 .../contract/components/entity.schema.json | 0 .../contract/components/field.schema.json | 0 .../components/field_error_detail.schema.json | 0 .../field_error_type.schema copy.json | 0 .../components/field_error_type.schema.json | 0 .../field_specification.schema.json | 0 .../components/readable_entity.schema.json | 0 .../contract/components/type_name.schema.json | 0 .../validation_function.schema.json | 0 .../contract/contract.schema.json | 0 .../json_schemas/dataset.schema.json | 0 .../json_schemas/rule_store.schema.json | 0 .../components/business_filter.schema.json | 0 .../components/business_rule.schema.json | 0 .../components/concrete_filter.schema.json | 0 .../components/core_filter.schema.json | 0 .../components/filter.schema.json | 0 .../multiple_expressions.schema.json | 0 .../components/rule.schema.json | 0 .../transformations.schema.json | 0 docs/advanced_guidance/new_backend.md | 2 + .../package_documentation/auditing.md | 2 + .../package_documentation/index.md | 15 + .../package_documentation/pipeline.md | 18 + .../package_documentation/refdata_loaders.md | 18 + docs/assets/images/favicon.ico | Bin 0 -> 15086 bytes docs/assets/images/favicon.svg | 4 + docs/assets/images/nhsuk-icon-180.png | Bin 0 -> 1079 bytes docs/assets/images/nhsuk-icon-192.png | Bin 0 -> 1164 bytes docs/assets/images/nhsuk-icon-512.png | Bin 0 -> 3308 bytes docs/assets/images/nhsuk-icon-mask.svg | 3 + docs/assets/images/nhsuk-opengraph-image.png | Bin 0 -> 4585 bytes docs/assets/stylesheets/extra.css | 57 +++ docs/detailed_guidance/business_rules.md | 363 ----------------- docs/detailed_guidance/data_contract.md | 315 --------------- docs/detailed_guidance/domain_types.md | 27 -- docs/detailed_guidance/feedback_messages.md | 1 - docs/detailed_guidance/file_transformation.md | 1 - docs/index.md | 39 ++ docs/json_schemas/README.md | 30 -- docs/user_guidance/auditing.md | 2 + docs/user_guidance/business_rules.md | 2 + docs/user_guidance/data_contract.md | 2 + docs/user_guidance/error_reports.md | 2 + docs/user_guidance/feedback_messages.md | 2 + docs/user_guidance/file_transformation.md | 2 + docs/user_guidance/getting_started.md | 117 ++++++ docs/user_guidance/implementations/duckdb.md | 175 ++++++++ .../implementations/mixing_implementations.md | 30 ++ .../platform_specific/databricks.md | 10 + .../platform_specific/palantir_foundry.md | 2 + docs/user_guidance/implementations/spark.md | 165 ++++++++ docs/user_guidance/install.md | 91 +++++ includes/jargon_and_acronyms.md | 3 + overrides/.icons/nhseng.svg | 4 + poetry.lock | 379 ++++++++++++++++-- pyproject.toml | 9 + zensical.toml | 194 +++++++++ 66 files changed, 1415 insertions(+), 1088 deletions(-) create mode 100644 .github/workflows/ci_docs_publish.yml delete mode 100644 docs/README.md create mode 100644 docs/advanced_guidance/index.md create mode 100644 docs/advanced_guidance/json_schemas.md rename docs/{ => advanced_guidance}/json_schemas/contract/components/base_entity.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/contact_error_details.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/entity.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/field.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/field_error_detail.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/field_error_type.schema copy.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/field_error_type.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/field_specification.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/readable_entity.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/type_name.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/components/validation_function.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/contract/contract.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/dataset.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/rule_store.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/business_filter.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/business_rule.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/concrete_filter.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/core_filter.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/filter.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/multiple_expressions.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/components/rule.schema.json (100%) rename docs/{ => advanced_guidance}/json_schemas/transformations/transformations.schema.json (100%) create mode 100644 docs/advanced_guidance/new_backend.md create mode 100644 docs/advanced_guidance/package_documentation/auditing.md create mode 100644 docs/advanced_guidance/package_documentation/index.md create mode 100644 docs/advanced_guidance/package_documentation/pipeline.md create mode 100644 docs/advanced_guidance/package_documentation/refdata_loaders.md create mode 100644 docs/assets/images/favicon.ico create mode 100644 docs/assets/images/favicon.svg create mode 100644 docs/assets/images/nhsuk-icon-180.png create mode 100644 docs/assets/images/nhsuk-icon-192.png create mode 100644 docs/assets/images/nhsuk-icon-512.png create mode 100644 docs/assets/images/nhsuk-icon-mask.svg create mode 100644 docs/assets/images/nhsuk-opengraph-image.png create mode 100644 docs/assets/stylesheets/extra.css delete mode 100644 docs/detailed_guidance/business_rules.md delete mode 100644 docs/detailed_guidance/data_contract.md delete mode 100644 docs/detailed_guidance/domain_types.md delete mode 100644 docs/detailed_guidance/feedback_messages.md delete mode 100644 docs/detailed_guidance/file_transformation.md create mode 100644 docs/index.md delete mode 100644 docs/json_schemas/README.md create mode 100644 docs/user_guidance/auditing.md create mode 100644 docs/user_guidance/business_rules.md create mode 100644 docs/user_guidance/data_contract.md create mode 100644 docs/user_guidance/error_reports.md create mode 100644 docs/user_guidance/feedback_messages.md create mode 100644 docs/user_guidance/file_transformation.md create mode 100644 docs/user_guidance/getting_started.md create mode 100644 docs/user_guidance/implementations/duckdb.md create mode 100644 docs/user_guidance/implementations/mixing_implementations.md create mode 100644 docs/user_guidance/implementations/platform_specific/databricks.md create mode 100644 docs/user_guidance/implementations/platform_specific/palantir_foundry.md create mode 100644 docs/user_guidance/implementations/spark.md create mode 100644 docs/user_guidance/install.md create mode 100644 includes/jargon_and_acronyms.md create mode 100644 overrides/.icons/nhseng.svg create mode 100644 zensical.toml diff --git a/.github/workflows/ci_docs_publish.yml b/.github/workflows/ci_docs_publish.yml new file mode 100644 index 0000000..09435f2 --- /dev/null +++ b/.github/workflows/ci_docs_publish.yml @@ -0,0 +1,49 @@ +name: Publish Documentation + +on: + push: + branches: main + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/configure-pages@v5 + + - uses: actions/checkout@v5 + + - name: Install extra dependencies for a python install + run: | + sudo apt-get update + sudo apt -y install --no-install-recommends liblzma-dev libbz2-dev libreadline-dev + + - name: Install asdf cli + uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 + + - name: Install software through asdf + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 + + - name: reshim asdf + run: asdf reshim + + - name: ensure poetry using desired python version + run: poetry env use $(asdf which python) + + - name: install docs requirements + run: | + poetry install --sync --no-interaction --with docs + + - run: poetry run zensical build --clean + - uses: actions/upload-pages-artifact@v4 + with: + path: site + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.github/workflows/ci_linting.yml b/.github/workflows/ci_linting.yml index 1be9758..fc9d2bf 100644 --- a/.github/workflows/ci_linting.yml +++ b/.github/workflows/ci_linting.yml @@ -17,10 +17,10 @@ jobs: sudo apt -y install --no-install-recommends liblzma-dev libbz2-dev libreadline-dev - name: Install asdf cli - uses: asdf-vm/actions/setup@v4 + uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 - name: Install software through asdf - uses: asdf-vm/actions/install@v4 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 - name: reshim asdf run: asdf reshim diff --git a/.github/workflows/ci_testing.yml b/.github/workflows/ci_testing.yml index 232ffb7..ac25451 100644 --- a/.github/workflows/ci_testing.yml +++ b/.github/workflows/ci_testing.yml @@ -20,10 +20,10 @@ jobs: sudo apt -y install --no-install-recommends liblzma-dev libbz2-dev libreadline-dev libxml2-utils - name: Install asdf cli - uses: asdf-vm/actions/setup@v4 + uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 - name: Install software through asdf - uses: asdf-vm/actions/install@v4 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 - name: reshim asdf run: asdf reshim diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index fc0de4a..0000000 --- a/docs/README.md +++ /dev/null @@ -1,304 +0,0 @@ -The Data Validation Engine (DVE) is a configuration driven data validation library. - -There are 3 core steps within the DVE: - -1. [File transformation](./detailed_guidance/file_transformation.md) - Parsing files from their submitted format into a common format. -2. [Data contract](./detailed_guidance/data_contract.md) - Validating the types that have been submitted and casting them. -3. [Business rules](./detailed_guidance/business_rules.md) - Performing more complex validations such as comparisons between fields and tables. - -with a 4th step being important but more variable depending on platform and users: - -4. [Error reports](./detailed_guidance/feedback_messages.md) - Compiles the errors generated from the previous stages and presents them within an Excel report. However, this could be reconfigured to meet the needs of your users. - -Each of these steps produce a list of [Feedback message](details/Feedback%20message.md) objects which can be reported back to the user for them to fix any issues. - -DVE configuration can be instantiated from a json (dischema) file which might be structured like this: - -```json -{ - "contract": { - "cache_originals": true, - "error_details": null, - "types": {}, - "schemas": {}, - "datasets": { - "CWTHeader": { - "fields": { - "version": { - "description": null, - "is_array": false, - "callable": "constr", - "constraints": { - "regex": "\\d{1,2}\\.\\d{1,2}" - } - }, - "periodStartDate": { - "description": null, - "is_array": false, - "callable": "conformatteddate", - "constraints": { - "date_format": "%Y-%m-%d" - } - } - }, - "mandatory_fields": [ - "version", - "periodStartDate" - ], - "reporting_fields": [], - "key_field": null, - "reader_config": { - ".xml": { - "reader": "XMLStreamReader", - "kwargs": { - "record_tag": "Header", - "n_records_to_read": 1 - }, - "field_names": null - } - }, - "aliases": {} - } - } - }, - "transformations": { - "rule_stores": [], - "reference_data": {}, - "parameters": {}, - "rules": [], - "filters": [ - { - "name": "version is at least 1.0", - "entity": "CWTHeader", - "expression": "version >= '1.0'", - "failure_type": "submission", - "failure_message": "version is not at least 1.0", - "error_code": "CWT000101", - "reporting_field": "version", - "category": "Bad value" - } - ], - "post_filter_rules": [], - "complex_rules": [] - } -} -``` -"Contract" is where [Data Contract](./detailed_guidance/data_contract.md) and [File Transformation](./detailed_guidance/file_transformation.md) (in the reader configs) are configured, and (due to legacy naming) transformations are where [Business rules](./detailed_guidance/business_rules.md) are configured. - -## Quick start -In the code example shared above we have a json file named `cwt_example.dischema.json` and an xml file with the following structure: - -```xml - - -
- 1.1 - 2025-01-01 -
-
-``` - -### Data contract -We can see in `config.contract.datasets` that there is a `CWTHeader` entity declared. This entity has 2 fields, `version` and `periodStartDate`. - -`version` is declared to be a `constr` which is the constrained string type from the Pydantic library. Therefore, any keyword arguments `constr` can be passed as `constraints` here. In this case we are constraining it to a regex 1-2 digits, followed by a literal period followed by 1-2 digits. This should match an `max n2` data type. - -`periodStartDate` on the other hand is a `conformatteddate`, this type is one that's defined in the DVE library as a `domain_type` see [Domain types](./detailed_guidance/domain_types.md). The output of a `conformatteddate` is a date type. - -This means that after the data contract step the resulting data will have the types: `version::string` and `periodStartDate::date`. - -We can also see that the `CWTHeader` entity has both `version` and `periodStartDate` set as mandatory fields. That means that if they are missing from the file or the value is null an error will be created. - -### File transformation -Within the `CWTHeader` entity we can see a `reader_config` object. This should have a key for every expected file extension that is being submitted for the given dataset. In this case just `".xml"`. We declare which reader is being used `XMLStreamReader` and any kwargs that get passed to it when it's instantiated. Stream reader expects a tag where the record exists in the file (`Header` in this case) and how many records to read. Stream reader is written to be able to quickly pull out singular records such as headers. it will stop parsing once it has hit the maximum number of records, which can save time compared to traversing the whole file. - -### Code -Lets bring together those first 2 steps in code. We want to first parse the file into a spark dataframe with all string types, then apply data contract to the dataframe to get a typed dataframe. - -> **note in the version that comes from gitlab, the dve library is spread across a number of modules. We are looking to put this in a top level `dve` module** - -```python -import os -from pyspark.sql import SparkSession -# The spark tools require the current active spark session -from dve.core_engine.backends.implementations.spark.readers.xml import SparkXMLStreamReader -# we're using the spark stream reader, this uses the xmlstream reader but outputs a dataframe -from dve.core_engine.backends.implementations.spark.contract import SparkDataContract -# Applies the data contract over a spark dataframe -from dve.core_engine.configuration.v1 import V1EngineConfig -# the engine configuration for the current DVE version -from dve.core_engine.backends.utilities import stringify_model -# this takes the types of the datacontract and converts them to strings with the same structure. -``` - -Here we have all the imports from DVE we need, the stream reader, data contract, configuration object and utility. - -we've also imported `os` so we can set some spark args to make sure [SparkXML](https://github.com/databricks/spark-xml) is included, and spark session which will be needed. - -```python -os.environ["PYSPARK_SUBMIT_ARGS"] = " ".join( - [ - "--packages", - "com.databricks:spark-xml_2.12:0.16.0", - "pyspark-shell", - ] -) -spark = SparkSession.builder.getOrCreate() - -config = V1EngineConfig.load("cwt_example.dischema.json") - -data_contract_config = config.get_contract_metadata() -reader_configs = data_contract_config.reader_metadata - -readers = {"XMLStreamReader": SparkXMLStreamReader} - -# File transformation step here -entities = {} -for entity in data_contract_config.schemas: - # get config based on file type you're parsing - ext_config = reader_configs[entity][".xml"] - reader = readers[ext_config.reader](**ext_config.parameters) - df = reader.read_to_dataframe( - "cwt_example.xml", entity, stringify_model(data_contract_config.schemas[entity]) - ) - entities[entity] = df - -# Data contract step here -data_contract = SparkDataContract(spark_session=spark) -entities, feedback_errors_uri, success = data_contract.apply_data_contract( - entities, None, data_contract_config -) -``` - -from the top down we -- set some spark arguments to make sure we have spark-xml present -- get a spark session -- load the configuration -- get the data contract config specifically -**file transformation** -- get the reader configurations from the data contract config -- create a mapping of reader_names to their concrete class. (This allows us to refer to a more abstract name in the config and decide what backend we're using in the code) -- create an empty entity dictionary -- iterate over each of the entities defined in the config -- get the reader configuration for the file type we're reading (xml in this case) -- get the concrete reader and instantiate it with the parameters we set in the config -- read the file with a stringified model, this maintains the structure of the datacontract but makes sure everything is kept as strings. -- add the dataframe to the entities dictionary -**data contract** -- instatiate the SparkDataContract class with a spark session -- apply the data contract to the dict of entities returning the entities in the correct types. any validation messages and a success bool -### Business rules - -Now we have typed entities we can apply business rules to them. We need a step implementation. we'll import that from the spark rules backend. - -```python -from dve.core_engine.backends.implementations.spark.rules import SparkStepImplementations - -business_rules = SparkStepImplementations(spark_session=spark) -business_rule_config = config.get_rule_metadata() - -messages = business_rules.apply_rules(entities, business_rule_config) -``` - -There we go. Messages is a list of [Feedback message](./detailed_guidance/feedback_messages.md) for every failed rule. - -### Utilising the Pipeline objects to run the DVE -Within the DVE package, we have also created the ability to build pipeline objects to help orchestrate the running of the DVE from start to finish. We currently have an implementation for `Spark` and `DuckDB`. These pipeline objects abstract some of the complexity described above and only requires you to supply a few objects to run the DVE from start (file transformation) to finish (error reports). These can be read in further detail [here](../src/pipeline/) and we have tests [here](../tests/test_pipeline/) to ensure they are working as expected. Furthermore, if you have a situation where maybe you only want to run the Data Contract, then you can utilise the pipeline objects in a way that only runs the specific stages that you want. Below will showcase an example where the full e2e pipeline is run and how you can trigger the stages that you want. - -> **note in the version that comes from gitlab, the dve library is spread across a number of modules. We are looking to put this in a top level `dve` module** - -```python -# Imports for a spark setup -from pyspark.sql import SparkSession - -from core_engine.backends.implementations.spark.auditing import SparkAuditingManager -from pipeline.spark_pipeline import SparkDVEPipeline - -# Local Spark Setup -os.environ["PYSPARK_SUBMIT_ARGS"] = " ".join( - [ - "--packages", - "com.databricks:spark-xml_2.12:0.16.0", - "pyspark-shell", - ] -) - -spark = SparkSession.builder.getOrCreate() - -# Setting up the audit manager -audit_manager = SparkAuditingManager( - database=spark_test_database, - pool=ThreadPoolExecutor(1), - spark=spark, -) - -# Setting up the Pipeline (in this case the Spark implemented one) -pipeline = SparkDVEPipeline( - processed_files_path="path/where/my/processed_files/should_go/", - audit_tables=audit_manager, - job_run_id=1, - rules_path="path/to/my_dischema", - submitted_files_path="path/to/my/cwt_files/", - reference_data_loader=SparkParquetRefDataLoader, - spark=spark -) -``` - -Once you have setup the Pipeline object, audit object and your environment - you are ready to use the pipeline in whatever way works for you. You can simply utilise the `cluster_pipeline_run` method which will run all the stages of dve (from file transformation to error reports) or you can run the stages that you specifically need. For instance... - -```python -# this will run all stages of the dve -dve_pipeline.cluster_pipeline_run(max_workers=2) -``` - -**OR** - -```python -submitted_files = dve_pipeline._get_submission_files_for_run() -submitted_file_infos = [] - -for submission in submitted_files: - submitted_file_infos.append(dve_pipeline.audit_received_file(sub_id, *subs)) - -dve_pipeline.data_contract_step( - pool=ThreadPoolExecutor(2), - file_transform_results=submitted_file_infos -) -``` - -For the Data Contract step you may have noticed that you will need to provide a list of `SubmissionInfo` objects. These are pydantic models which contain metadata for a given Submission. Within this example we are using the `_get_submission_files_for_run` method to get a tuple of URI's where the Submission URI and Metadata URI exist for a given submission. We then pass them through the `audit_received_file_step` method to audit the submissions and in return get a SubmissionInfo object that we can then utilise within the `data_contract_step` method. - -If you'd rather not rely on needing a `metadata.json` associated with your submitted files you can build your own bespoke process for building a list of `SubmissionInfo` objects. - -### Mixing backends - -The examples shown above are using the Spark Backend. DVE also has a DuckDB backend found at [core_engine.backends.implementations.duckdb](../src/core_engine/backends/implementations/duckdb/). In order to mix the two you will need to convert from one type of entity to the other. For example from a spark `Dataframe` to DuckDB `relation`. The easiest way to do this is to use the `write_parquet` method from one backend and use `read_parquet` from another backend. - -Currently the configuration isn't backend agnostic for applying business rules. So if you want to swap between spark and duckdb, the business rules need to be written using only features that are common to both backends. For example, a regex check in spark would be something along the lines of... -```sql -nhsnumber rlike '^\d{10}$' -``` -...but in duckdb it would be... -```sql -regexp_matches(nhsnumber, '^\d{10}$') -``` -Failures in parsing the expressions lead to failure messages such as -```python -FeedbackMessage( - entity=None, - record=None, - failure_type='integrity', - is_informational=False, - error_type=None, - error_location=None, - error_message="Unexpected error (AnalysisException: Undefined function: 'regexp_matches'. This function is neither a registered temporary function nor a permanent function registered in the database 'default'.; line 1 pos 5) in transformations (rule: root; step: 0; id: None)", - error_code=None, - reporting_field=None, - reporting_field_name=None, - value=None, - category=None -) -``` - -# Extra information -Thanks for reading the documentation and looking into utilising the DVE. If you need more information on any of the steps you can find the following guidance below. If you need additional support, please raise an issue ([see guidance here](../CONTRIBUTE.md)) and we will try and respond to you as quickly as possible. diff --git a/docs/advanced_guidance/index.md b/docs/advanced_guidance/index.md new file mode 100644 index 0000000..04fef3e --- /dev/null +++ b/docs/advanced_guidance/index.md @@ -0,0 +1,21 @@ +
+ +- :material-file-code:{ .lg .middle } __DVE Code Reference Documentation__ + + --- + + [:octicons-arrow-right-24: Read the code documentation here](package_documentation/) + +- :material-database-plus:{ .lg .middle } __Implementing a new backend__ + + --- + + [:octicons-arrow-right-24: Setup a new backend here](new_backend.md) + +- :material-code-block-braces:{ .lg .middle } __Setting up a dischema language server__ + + --- + + [:octicons-arrow-right-24: Setup your environment to make writing rules easier](json_schemas.md) + +
\ No newline at end of file diff --git a/docs/advanced_guidance/json_schemas.md b/docs/advanced_guidance/json_schemas.md new file mode 100644 index 0000000..5f24bd4 --- /dev/null +++ b/docs/advanced_guidance/json_schemas.md @@ -0,0 +1,35 @@ +# JSON Schemas + +JSON schemas define how the rules within the dischema document should be written. We also include components to help write the rulestore or ruleset documents as well. + +You can download a copy of the json schemas [here](https://github.com/NHSDigital/data-validation-engine/tree/main/docs/advanced_guidance/json_schemas). + +For autocomplete support in VS Code, you can alter the `.vscode/settings.json` and add new entries to the +`json.schemas` key. If not present, simply copy & paste the code shown below into your `settings.json`: + +```json +{ + ..., + "json.schemas": [ + { + "fileMatch": [ + "*.dischema.json" + ], + "url": "./json_schemas/dataset.schema.json" + }, + { + "fileMatch": [ + "*.rulestore.json", + "*_ruleset.json" + ], + "url": "./json_schemas/rule_store.schema.json" + } + ] +} +``` + +Your dischema will then have autocomplete and syntax suggestion support. + +# Components + +The DVE rules are built on a number of components. You can view the components [here](https://github.com/NHSDigital/data-validation-engine/tree/main/docs/advanced_guidance/json_schemas). diff --git a/docs/json_schemas/contract/components/base_entity.schema.json b/docs/advanced_guidance/json_schemas/contract/components/base_entity.schema.json similarity index 100% rename from docs/json_schemas/contract/components/base_entity.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/base_entity.schema.json diff --git a/docs/json_schemas/contract/components/contact_error_details.schema.json b/docs/advanced_guidance/json_schemas/contract/components/contact_error_details.schema.json similarity index 100% rename from docs/json_schemas/contract/components/contact_error_details.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/contact_error_details.schema.json diff --git a/docs/json_schemas/contract/components/entity.schema.json b/docs/advanced_guidance/json_schemas/contract/components/entity.schema.json similarity index 100% rename from docs/json_schemas/contract/components/entity.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/entity.schema.json diff --git a/docs/json_schemas/contract/components/field.schema.json b/docs/advanced_guidance/json_schemas/contract/components/field.schema.json similarity index 100% rename from docs/json_schemas/contract/components/field.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/field.schema.json diff --git a/docs/json_schemas/contract/components/field_error_detail.schema.json b/docs/advanced_guidance/json_schemas/contract/components/field_error_detail.schema.json similarity index 100% rename from docs/json_schemas/contract/components/field_error_detail.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/field_error_detail.schema.json diff --git a/docs/json_schemas/contract/components/field_error_type.schema copy.json b/docs/advanced_guidance/json_schemas/contract/components/field_error_type.schema copy.json similarity index 100% rename from docs/json_schemas/contract/components/field_error_type.schema copy.json rename to docs/advanced_guidance/json_schemas/contract/components/field_error_type.schema copy.json diff --git a/docs/json_schemas/contract/components/field_error_type.schema.json b/docs/advanced_guidance/json_schemas/contract/components/field_error_type.schema.json similarity index 100% rename from docs/json_schemas/contract/components/field_error_type.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/field_error_type.schema.json diff --git a/docs/json_schemas/contract/components/field_specification.schema.json b/docs/advanced_guidance/json_schemas/contract/components/field_specification.schema.json similarity index 100% rename from docs/json_schemas/contract/components/field_specification.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/field_specification.schema.json diff --git a/docs/json_schemas/contract/components/readable_entity.schema.json b/docs/advanced_guidance/json_schemas/contract/components/readable_entity.schema.json similarity index 100% rename from docs/json_schemas/contract/components/readable_entity.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/readable_entity.schema.json diff --git a/docs/json_schemas/contract/components/type_name.schema.json b/docs/advanced_guidance/json_schemas/contract/components/type_name.schema.json similarity index 100% rename from docs/json_schemas/contract/components/type_name.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/type_name.schema.json diff --git a/docs/json_schemas/contract/components/validation_function.schema.json b/docs/advanced_guidance/json_schemas/contract/components/validation_function.schema.json similarity index 100% rename from docs/json_schemas/contract/components/validation_function.schema.json rename to docs/advanced_guidance/json_schemas/contract/components/validation_function.schema.json diff --git a/docs/json_schemas/contract/contract.schema.json b/docs/advanced_guidance/json_schemas/contract/contract.schema.json similarity index 100% rename from docs/json_schemas/contract/contract.schema.json rename to docs/advanced_guidance/json_schemas/contract/contract.schema.json diff --git a/docs/json_schemas/dataset.schema.json b/docs/advanced_guidance/json_schemas/dataset.schema.json similarity index 100% rename from docs/json_schemas/dataset.schema.json rename to docs/advanced_guidance/json_schemas/dataset.schema.json diff --git a/docs/json_schemas/rule_store.schema.json b/docs/advanced_guidance/json_schemas/rule_store.schema.json similarity index 100% rename from docs/json_schemas/rule_store.schema.json rename to docs/advanced_guidance/json_schemas/rule_store.schema.json diff --git a/docs/json_schemas/transformations/components/business_filter.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/business_filter.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/business_filter.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/business_filter.schema.json diff --git a/docs/json_schemas/transformations/components/business_rule.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/business_rule.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/business_rule.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/business_rule.schema.json diff --git a/docs/json_schemas/transformations/components/concrete_filter.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/concrete_filter.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/concrete_filter.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/concrete_filter.schema.json diff --git a/docs/json_schemas/transformations/components/core_filter.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/core_filter.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/core_filter.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/core_filter.schema.json diff --git a/docs/json_schemas/transformations/components/filter.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/filter.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/filter.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/filter.schema.json diff --git a/docs/json_schemas/transformations/components/multiple_expressions.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/multiple_expressions.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/multiple_expressions.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/multiple_expressions.schema.json diff --git a/docs/json_schemas/transformations/components/rule.schema.json b/docs/advanced_guidance/json_schemas/transformations/components/rule.schema.json similarity index 100% rename from docs/json_schemas/transformations/components/rule.schema.json rename to docs/advanced_guidance/json_schemas/transformations/components/rule.schema.json diff --git a/docs/json_schemas/transformations/transformations.schema.json b/docs/advanced_guidance/json_schemas/transformations/transformations.schema.json similarity index 100% rename from docs/json_schemas/transformations/transformations.schema.json rename to docs/advanced_guidance/json_schemas/transformations/transformations.schema.json diff --git a/docs/advanced_guidance/new_backend.md b/docs/advanced_guidance/new_backend.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/advanced_guidance/new_backend.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/advanced_guidance/package_documentation/auditing.md b/docs/advanced_guidance/package_documentation/auditing.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/advanced_guidance/package_documentation/auditing.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/advanced_guidance/package_documentation/index.md b/docs/advanced_guidance/package_documentation/index.md new file mode 100644 index 0000000..4e3263e --- /dev/null +++ b/docs/advanced_guidance/package_documentation/index.md @@ -0,0 +1,15 @@ +
+ +- :material-language-python:{ .lg .middle } __Pipeline__ + + --- + + [:octicons-arrow-right-24: Read about the `Pipeline` objects here](pipeline.md) + +- :material-set-all:{ .lg .middle } __Reference Data Loader__ + + --- + + [:octicons-arrow-right-24: Read about the `Reference Data Loader` objects here](refdata_loaders.md) + +
\ No newline at end of file diff --git a/docs/advanced_guidance/package_documentation/pipeline.md b/docs/advanced_guidance/package_documentation/pipeline.md new file mode 100644 index 0000000..bdea5a6 --- /dev/null +++ b/docs/advanced_guidance/package_documentation/pipeline.md @@ -0,0 +1,18 @@ + +::: dve.pipeline.pipeline.BaseDVEPipeline + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.pipeline.duckdb_pipeline.DDBDVEPipeline + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.pipeline.spark_pipeline.SparkDVEPipeline + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/advanced_guidance/package_documentation/refdata_loaders.md b/docs/advanced_guidance/package_documentation/refdata_loaders.md new file mode 100644 index 0000000..8535fd5 --- /dev/null +++ b/docs/advanced_guidance/package_documentation/refdata_loaders.md @@ -0,0 +1,18 @@ + +::: dve.core_engine.backends.base.reference_data.BaseRefDataLoader + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.core_engine.backends.implementations.duckdb.reference_data.DuckDBRefDataLoader + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.core_engine.backends.implementations.spark.reference_data.SparkRefDataLoader + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/assets/images/favicon.ico b/docs/assets/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aff749027e9a9080463a719243d3fd21e2765c15 GIT binary patch literal 15086 zcmeHO33OG}6}?njOIs?n%1T!o7Ol2+by&sLYUkQkDOR!ADiwhUkx6itVzDxbD1$g5 z3X27Z5+(@{44M89BxIfkLP7=-^74}(l8_lP=k)CJb06>j{vd0`I`y5kUhaG6o^$WH z``mZ$`S-jiHB4QmMvhcmj!|E6- zTqwnIpEZgKQfeqy#Qkd2f!o#S@OxEY#C<9-^fncEXrc-{cn6nvtH8)9DscaJ&;7v( zDlqIWPaW%xjtUw!$~pJSkeu~@cf~*TZR5!NYmDQYryD6tZ#1&j{l!RLe1qYfGs4Jy zW0KsT+_k`{hHnH7UEKjGcEC zHJ?hSlJ^!-*@5N6`gE?gROBP4DkqQKVI0{=u7qt=>3D-$Dl(}q=Y1*S(B>(tXbo>pOTHjuYGFQhUuwYA(;9q8*P?V{rl%?_Nkf z=bOo$wuhQZlBhmEQf%lrTSdito~GD&R}M2w!MTZBG=K$oljEiLsvP00b}UO-!#q0T>EDuCin^uc1wOLxFsz7 zp5U6hVUhui^EU+3Ll33 zRgH%4hcb7d`)Ma4rSx^FW6|;`vCfR)>lVJSJ_%vrvB^fjM!-g30D+;dvmb)~aBd^u z!@eRWs{oD{0(kEU;5{Ya@O^&!K;M)3s*Rkc+`LD*dEayMv*#M%fscCOYo2}{koD5% zx|o^6-SJP3aVIYNq5If^Yu%37BRp+f(5pq?tQX8N2k*ga$ep^Ij&GiU{adb-WjB-S z*jC|}9ej?CJ`zBAZ%vWCzwF=&;ur<70>wQZNL5ESQFYS0yszI*$xCl!on6#imO`yY z9>)*qRGYbO@_{pv)@Tsxk6yUtO6Uk`EoMjR_}{4`v~3t^1o z=%e4Dte5Yk)D^#`#Akj?h=a~#g-Kh8u~L@*LdJ*j3kE!fyf-IvH+^C=wVzi62fKS}8olc8SmYNbCYO8lUQfx23+jt${LD z{Ym&{|Lnum6~AJPt3)Pm;}rJu{c>NI7eQt0hyLCks!e}SusK_FRO;Z9#-cbvoQgOR z)YH*Q9OFv=N9JGci5ofY1NNYX6HbbIB9Pec;{Sm;z;ozZ5>G z?hfkhY!hDsTRj%k_ja~Z_qisCGyD5`!IC+`u_MpjEn+u#m@m}{+bL=B4d_>2f9C!gNU8cS6}yeDh*vsX)pYq=fb@b%R;~E$Rs1;* z?^+&c8nEUa_ zaXFZb@GWaN+u=@l`g&Q5u_iN~XhYUtF8?R{54UUXX@@8A9yGzT7hzAr-ZW5;zK8TX zd8qn!AY>z8Bk=!=fNU5S`7=~xF4DAA;hchxNY!+{%yWwC_`xO{0UH4u0UH4u0UH4u z0UH4u0ULoKA;4z^`rPYNRq8>1-1g&4I(^k2`HX(Ll9+z`koJr#RZ!r6O?Z>&T zCgk0H^$NDk)YWE*^Eb#9Y@KcLaHILU4dnT)gmYZf6~6n3c@}7`*Z1e^Ps>>^&i-{< zy7aZbHX~H45zgkFbFTE#(Pxzxgor9*UKbgh5n8FzvEEA;IO6*TO|4b=QV-lOg_=qd z`8@IpuQq+{FW$4zt0}at#~MB3aF55S{}Ize}waYoPR@3=QALOvH1GD z?7%XwCeR=BkWbM4SWBFVTdCoAjF+6R{WyEpxdP~6zUcOJ9tiw~0tfYWwBc4r-ZQqt z6~9HZq(tsS>!U=@z)JA9wcht0xdJO8|6rBzwI6kumzwbIX!(|kEY0S3brv<1CY#UN zugJq$3HcUatgr0XCR(c*pHQ=X-49Cm?Zn%2sk5oZOCH|_S>^oMuk$-v5xGR5f1-M>`&!`^Qt(y1l{d_w})E6yIEGU6eYvj(X(J!NWHb*0l=XVbNCkph@mCIn>Fr_{z + + + diff --git a/docs/assets/images/nhsuk-icon-180.png b/docs/assets/images/nhsuk-icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..4881d5e39d65bd9a849a2e0317ee322d779b1205 GIT binary patch literal 1079 zcmV-71jze|P)RCwC$n%i>YAP@yj zfPvxvKX@;GvD9b?QdMfV>hz-n;sDdaQv4D^2qAG^@08Qq=)St?GYK&z0f|nz>#`bR`hYsk=xM5DH6lWQ&L(ji-^22^?!aJ_59<}D=@nXYuXbv&m z;ast-j%o~FdCbXvGc-Nqrxhn;3!g$dJtPcQH$sn7BHjnn2tD*~dPwZ{N^`Q|jksa7 zwCmyX&;|X%j(*=mGH%>GI9m)261Sn;@sJID<363>ArHma!@Ty;txYmB&pI?~*Dy9H zcRVcb=fTg}mFoA8@r-`)ci(*FA-9Onlirj4r8{NG-w#a>c@u~|tZNU)*0=6BG(E&N zKlTu751`XJbVtXqJ}N`-nB%|hmU!L?O%HJ>#vYE)Lt};uzc)g+_;oGd7TDTDU#@f6 zdK#~oZrHcS9&&#gcZ{UG_uhjZhKHhY2U_Hz;}hcBZ&(aZk3 zJT7B+C}wOxT6*}-ohHFO)qLlz>H7CSY?1H%2q>3osSkG_qbIAS>DOWkt{?I#|NsBO!%u5Y zzv$ojAawG1z3QVpX}gP;-;galU~x@dfq{WV&C|s(q~g}wS!bs`w&G}c+92@cul$d5 zJC}=3JSgxer)t^0*yF#n9bHe%Wds_H6Ew&je=J_$z`*i{nW$>RXTY?K73Qp&*+nC*7t5^Bq{POdG3Av0mGIf^E=C8WFgSU_A zh?UkaxeXOF<{svCa6O}QLvYpg^}YEE*Q`wC%za-`!CL%EW8yvr-nCOyP3m7fOukYO z&Co0GYhQci9>%S#X9SIY%bjamTH(9vnqrYmw)Ap!hfOg&?UPR(u8Zh(s`aT_+x^7y z*oJ)#rcPh17N$Qin)UF`le(OyU0HjitD ztM4P*Jzo63FJEl0x+@xRJ?zSd4Ns5X`23qGX>;!7b8YQAKWv!3rX;$-e0I^Do|qIZ z{^EoiDWbKEAAL6E&N}7?X%_oUmod?)06~PCA&{rRd}|awSs^C@4!b7`HW*H z-{NI7){RJe@NoI*Ju@Fu1nI@A)o` znb!|JHDH3Ec<()gLSp|;{*(J&pDMpmlXm=ql=U@{4TsKcIc;>`VDY6a! zEDv0lwoKac_~GAw1ukJTs$Q#@T{nLfTY1Ba|9{Q0<0)05R`boX7b?G-9TTDR!9YCz z?d~*Nmoq*pI?r~fJFGXKC%~#P5r6-%$T4HHh~NWWuNnXLwVM`RxqDq}R^F05FCV%;OkDmlTYuY) zwQJ8TJn;K8XGC3mf8fR!7sU*^yR>JhdI(k?KWtUR_iq24uX5|yCn&h4y-fTsz4J#r zOD*Hk{y)#G4)b3>-#+j9vD3`O?d$bse%W1T?q#!l=8T(je11IDV{tfeUl(V-221Gv YW7ey;l{hIpH5$bCboFyt=akR{0PvO#`2YX_ literal 0 HcmV?d00001 diff --git a/docs/assets/images/nhsuk-icon-512.png b/docs/assets/images/nhsuk-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..a2697f4f0e2a1943fb44743e848dac0882137496 GIT binary patch literal 3308 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 HNR9j#xpT~~ literal 0 HcmV?d00001 diff --git a/docs/assets/images/nhsuk-icon-mask.svg b/docs/assets/images/nhsuk-icon-mask.svg new file mode 100644 index 0000000..970859e --- /dev/null +++ b/docs/assets/images/nhsuk-icon-mask.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/images/nhsuk-opengraph-image.png b/docs/assets/images/nhsuk-opengraph-image.png new file mode 100644 index 0000000000000000000000000000000000000000..434b2d9f4fdf1cd1cd7ef792c89938ccbfbae47e GIT binary patch literal 4585 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 literal 0 HcmV?d00001 diff --git a/docs/assets/stylesheets/extra.css b/docs/assets/stylesheets/extra.css new file mode 100644 index 0000000..945791d --- /dev/null +++ b/docs/assets/stylesheets/extra.css @@ -0,0 +1,57 @@ +:root { + --nhs-blue: #005EB8; +} + +.md-header__button.md-logo img { + transform: scale(1.8); + transform-origin: left center; +} + +.md-footer-meta { + background-color: transparent !important; + box-shadow: none; +} + +.md-footer-meta::before { + content: ""; + display: block; + height: 3px; + background-color: var(--nhs-blue); +} + +.md-footer-meta.md-typeset a { + margin: 0 0.1rem; +} + +.md-footer-meta.md-typeset a svg { + transform: scale(1.5); + transform-origin: center; + transition: transform 0.2s ease, filter 0.2s ease; +} + +.md-footer-meta.md-typeset a:hover svg { + transform: scale(1.75); + filter: brightness(1.2) drop-shadow(0 0 6px rgba(30, 136, 229, 0.7)); +} + +.md-footer-meta a { + text-decoration: underline; +} + +/* Light mode */ +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--nhs-blue); + --md-footer-bg-color: var(--md-default-bg-color); + --md-footer-fg-color: #000000; + --md-footer-fg-color--light: #333333; + --md-footer-fg-color--lighter: #555555; +} + +/* Dark mode */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: var(--nhs-blue); + --md-footer-bg-color: var(--md-default-bg-color); + --md-footer-fg-color: #e0e0e0; + --md-footer-fg-color--light: #bdbdbd; + --md-footer-fg-color--lighter: #9e9e9e; +} diff --git a/docs/detailed_guidance/business_rules.md b/docs/detailed_guidance/business_rules.md deleted file mode 100644 index adb4e37..0000000 --- a/docs/detailed_guidance/business_rules.md +++ /dev/null @@ -1,363 +0,0 @@ -# Business Rules -Business rules are defined in the `transformations` section of the config. There are 6 keys within the json document that we will discuss in more detail throughout this document. - -## Keys -| Key | Purpose | -| --- | ------- | -| `parameters` | For setting globally available variables. | -| `reference_data` | For bringing in reference data tables. | -| `rules_store` | For referring to other configuration files that contain shared rules. | -| `filters` | Simple rules that don't require much or any transformation. | -| `complex_rules` | Series of transformations that end in a filter. Such as joining or aggregating before performing a check. | -| `post_filter_rules` | For clearing down created entities that are no longer needed after validation. | - -## Filters -These are the most simple of the business rules. These are defined as a json object with the following structure: -```json -{ -"entity": "APCActivity", -"name": "EpiNo_is_valid", -"expression": "EpiNo IS NULL OR EpiNo RLIKE '^(0[1-9]|[1-7][0-9]|8[0-7]|9[89])$'", -"failure_type": "submission", -"failure_message": "is invalid", -"error_code": "1203", -"reporting_field": "EpiNo", -"is_informational" : false, -"category": "Bad value" -} -``` -This rule checks that EpiNo must be present and that the value is 01-87 or 98 or 99. If EpiNo is missing this rule doesnt fire (to prevent double dinging a missing value). Any EpiNo that are present but not one of the values expected will raise a 1203 error with the message "is invalid". -Lets break it down: -| Key | Purpose | -| --- | ------- | -| `entity` | This is the name of the entity to perform the filter on. In this Case the `APCActivity` dataframe | -| `name` | This should be a descriptive name for the rule. | -| `expression` | The SQL expression that evaluates to a bool. Any row that evaluates to False will be filtered out. This is so that you can define the rules as they are written in the ETOS rather than inverting the conditions | -| `failure_type` | The type of failure. There are three types of failures. Submission, record or integrity.
  • "submission" means the whole submission is invalidated by a failure in this rule and should be rejected (though this is for you to implement).
  • "record" means that this row of data is invalid, and will be excluded from the output.
  • "integrity" means that some constraint on the data has failed and no further processing can occur. This is normally raised when there is a parsing error in the expression but can be used to quickly reject data that doesn't meet a basic check
  • | -| `failure_message` | The message you wish for the user to receive | -| `error_code` | the code that links back to the specification. For example CHC had error codes like `CHC0010021` for the second field in the CHC001 tables first validation. This allows for collection of metrics for which rules have fired (again for you to implement) and allows the user to go back to the specification if the error message isn't clear enough | -| `reporting_field` | This is the field to report back to the user as having failed. The expression could be more complex and be something like `if(NhsNumber is null, NHSStatus = '05', True)`, where is NHSNumber isn't null we short circuit the rule and everything else passes. otherwise the result of the expression is the `NHSStaus` being 05. In this case you may wish to have reporting field be `NHSStatus` and report back which status triggered the check. or `['NHSNumber', 'NHSStatus']` to report them both back. | -| `is_informational` | This bool signals that this is a warning rather than an error -| `category` | Optional literal. Used more in metrics to give an idea of how many things fail due to a values being wrong, formatting, nulls or file parsing. Below is an example of categorical error types...
  • "bad value" - The value(s) in the check were wrong
  • "wrong format" - The formatting of the field is incorrect
  • "blank" - the value is missing when it shouldn't be
  • "bad file" - usually used when the file fails to parse due to bad formatting
  • - -## Parameters -Parameters are globally available parameters that can be templated in to a rule using jinja2 syntax. - -Lets say we have an example that compares several fields against the start of the financial year. - -we could implement it like this: - -```json -{ - "filters" : [ - { - "entity": "APCActivity", - "name": "StartDate_is_valid", - "expression": "StartDate >= '2025-04-01'", - "failure_type": "submission", - "failure_message": "start date is before the start of the financial year", - "error_code": "1203", - "reporting_field": "StartDate", - "is_informational" : false, - "category": "Bad value" - }, - { - "entity": "APCActivity", - "name": "EndDate_is_valid", - "expression": "EndDate >= '2025-04-01'", - "failure_type": "submission", - "failure_message": "EndDate is before the start of the financial year", - "error_code": "1203", - "reporting_field": "EndDate", - "is_informational" : false, - "category": "Bad value" - }, - ... - ] -} -``` -This is fine for just 2 rules, but what if all dates need to be after the start of the financial year? What if a requirement comes in that it should be the 6th of april not the 1st of april? - -This is what parameters are for. - -```json -{ - "parameters" : { - "financial_year_start_date" : "'2025-04-01'" - }, - "filters" : [ - { - "entity": "APCActivity", - "name": "StartDate_is_valid", - "expression": "StartDate >= {{ financial_year_start_date }}", - "failure_type": "submission", - "failure_message": "start date is before the start of the financial year", - "error_code": "1203", - "reporting_field": "StartDate", - "is_informational" : false, - "category": "Bad value" - }, - { - "entity": "APCActivity", - "name": "EndDate_is_valid", - "expression": "EndDate >= {{ financial_year_start_date }}", - "failure_type": "submission", - "failure_message": "EndDate is before the start of the financial year", - "error_code": "1204", - "reporting_field": "EndDate", - "is_informational" : false, - "category": "Bad value" - }, - ... - ] -} -``` -Now we have the financial year start date parameterized. Any rule that needs to use it uses the same version. If we change the value in the parameter, all of the rules that use the parameter are updated too. - -These rules are quite repetitive. Using the same set up, similar error message. The only difference is the reporting field and error code in essence. - -## Complex rules - -Complex rules are pre-configured rules that can have multiple steps and accept parameters. These need to be defined in another file and then brought in using the rule store. - -The complex rule key in the main configuration refers to externally defined complex rules, and passes any parameters into them. So lets look at a simple rule, refactoring the example from above. - -> `complex_rules.rulestore.json` -```json -{ - "date_is_ge_financial_year" : { - "description" : "checks the passed date is after or equal to the passed in date", - "type" : "complex_rule", - "parameter_descriptions" : { - "error_code" : "code for the raised error", - "financial_year_start_date" : "the date that the financial year starts", - "field" : "the field to check", - "entity" : "the entity the field exist on" - }, - "parameter_defaults" : {}, - "rule_config": { - "rules" : [], - "filters" : [ - { - "entity": "{{ entity }}", - "name": "{{ field }}_is_valid", - "expression": "{{ field }} >= {{ financial_year_start_date }}", - "failure_type": "submission", - "failure_message": "{{ field }} is before the start of the financial year", - "error_code": "{{ error_code }}", - "reporting_field": "{{ field }}", - "is_informational" : false, - "category": "Bad value" - }, - ] - } - } -} -``` -Now that we have those rules define, we can use them in our regular configuration file. - -First we need to include the file in our rule_stores ->`example.json` -```json -{ - "parameters" : { - "financial_year_start_date" : "'2025-04-01'" - }, - "rule_stores": [ - { - "store_type": "json", - "filename": "complex_rules.dischema.json" - }, - ], - "filters" : [], - "complex_rules" : [ - { - "rule_name" : "date_is_ge_financial_year", - "parameters" : { - "field" : "StartDate", - "error_code" : "1203", - "entity" : "APCActivity", - } - }, - { - "rule_name" : "date_is_ge_financial_year", - "parameters" : { - "field" : "EndDate", - "error_code" : "1204", - "entity" : "APCActivity", - } - }, - ... - ] -} -``` -Note we've replaced the filters from the parameters section with complex rules. This requires that we pull in a rule_store. There are no limits to the number that can be included, and they can be shared across multiple versions of the specification. Now we have a rule that's defined once, and called multiple times with different parameters. - -> Note that `financial_year_start_date` isn't passed explicitly, that's because it's set as a parameter. Parameters are implicitly passed, you can be explicit if you prefer. - -## Rules -We've covered adding filters to complex rules, but we can add rules to them aswell. This may be a bit of a misnomer, these are transformations on the data that get executed before filters. These operations include -- select -- takes an entity and performs a select for either adding new columns, removing columns. -- remove -- remove a given column -- add column -- adds a new column -- group_by -- perform an aggregation on an entity -- filter_without_notifying -- filter things without raising an error message, to do things like remove nulls before doing a regular filter -- Joins -- left -- inner -- anti_join -- join to another table, any row that doesn't have a match in the other table will remain -- more performant than doing a join then a null check -- semi_join -- join to another table, any row that doesn't have in the other table with be removed -- join_header -- joins a table with a single row onto every row. will raise an error if the header table has more than a single row. -- used for things like checking submitting all dates in a file match the header -- one_to_one_join -- join to another entity expecting no change in the number of rows. integrity check can be toggled off -> see [json_schemas/transformations](../json_schemas/transformations/) for expected fields for each operation - -Rules are executed in the order they are put into the array. So a join then select should be implemented in that order. - -```json -{ - "rule_name" : { - ... - }, - "rules": [ - { - "name": "Get CareId counts", - "operation": "group_by", - "entity": "{{ feed_type }}Activity", - "new_entity_name": "{{ feed_type }}CareIdCounts", - "group_by": "CareId", - "agg_columns": { - "COUNT(1)": "CareIdFreq" - } - }, - { - "name": "Filter to keep only CareIds occuring more than once", - "operation": "filter_without_notifying", - "entity": "{{ feed_type }}CareIdCounts", - "filter_rule": "CareIdFreq > 1" - }, - { - "name": "Inner join the activities onto the CareId counts", - "operation": "inner_join", - "entity": "{{ feed_type }}CareIdCounts", - "target": "{{ feed_type }}Activity", - "join_condition": "{{ feed_type }}CareIdCounts.CareId == {{ feed_type }}Activity.CareId", - "new_columns": "{{ feed_type }}Activity.*" - } - ], - "filters": [ - { - "entity": "{{ feed_type }}CareIdCounts", - "expression": "FALSE", - "failure_type": "submission", - "failure_message": "cannot be duplicate", - "error_code": "1500", - "reporting_entity": "{{ feed_type }}Activity", - "reporting_field": "CareId", - "category": "Bad value" - } - ], - "post_filter_rules": [ - { - "name": "Remove temporary entities", - "operation": "remove_entity", - "entity": "{{ feed_type }}CareIdCounts" - } - ], - "dependencies" : [] -} -``` -Above is an example taken from a [PLICS](https://digital.nhs.uk/data-and-information/data-tools-and-services/data-services/patient-level-information-and-costing-system-plics-data-collections) rule. We start with a `group_by`, that creates a new entity. - -> ⚠️ If you don't set a new entity, it will override the entity that's been used ⚠️ - -We can then filter out any that don't have a count greater than 1. We then join back the activity date so it can be included in the error. - -The filter acts on the newly created entity, and since we've already filtered out any that's less than 1, all of the remaining are failures. So we raise a 1500 error for all of them. - -Then post-filter rules runs, and clears up the created entity. - -Finally we see the `dependencies` key, this is a list of rule names that this rule depends on. In this case it doesn't have any dependencies. But lets say we wanted to explode out an array, and that exploded version is used for many rules. We can make that explode a rule without any filters. Then other rules can depend on it. - -> Any dependencies need to be included in the complex rules of the `dischema.json` file - -## Reference data - -Reference data can be included, it's on object that takes the name you want to refer to the data as a key and the specification as a value: - -```json -{ - "reference_data": { - "allowed_submitters": { - "type": "table", - "database": "dve", - "table_name": "refdata_plics_organisation_submitting_id" - }, - "collection_activity": { - "type": "table", - "database": "dve", - "table_name": "refdata_plics_int_collection_activity" - }, - "collection_resource": { - "type": "table", - "database": "dve", - "table_name": "refdata_plics_int_collection_resource" - } - } -} -``` -This allows us to refer to `refdata_plics_organisation_submitting_id` as `allowed_submitters` when we do things like anti-joins to it. The type is a table, it's in the dve database and the table name is `refdata_plics_organisation_submitting_id`. If we use the `SparkRefDataLoader` from `core_engine/backends/implementations/spark/reference_data.py` as our loader then this will lazily include the tables when they are used. There are other ways to specify reference data than just database objects - we can also specify relative file paths (from the location of the dischema location) or absolute uris. - -When using reference data we recommend using the `EntityManager` class, this prevents reference data from being mutated. - -an example in code for the parquet reader would be... -```python -ref_data_config = config.get_reference_data_config() -rules = config.get_rule_metadata() -SparkRefDataLoader.spark = spark -SparkRefDataLoader.dataset_config_uri = "/path/to/folder/containing/dischema" - -ref_data = SparkRefDataLoader( - ref_data_config, -) - -entities = {...} -entity_manager = EntityManager(entities, ref_data) - -business_rules.apply_rules(entity_manager, rules) -``` -For the table loader it would be... -```python -ref_data_config = config.get_reference_data_config() -rules = config.get_rule_metadata() -ref_data = SparkTableRefDataLoader(ref_data_config) - -entities = {...} -entity_manager = EntityManager(entities, ref_data) - -business_rules.apply_rules(entity_manager, rules) -``` - -...This can then be used in rules for refdata comparison: - -```json -{ - "name": "Get the activities violating 1029", - "operation": "anti_join", - "entity": "{{ feed_type }}Activity", - "target": "refdata_allowed_submitters", - "join_condition": "{{ feed_type }}Activity.OrgId <=> refdata_allowed_submitters.Org_ID", - "new_entity_name": "{{ feed_type }}1029Violators" -} -``` -> Note the prefix `refdata_` acts as an alias and allows for explicit join between entities. diff --git a/docs/detailed_guidance/data_contract.md b/docs/detailed_guidance/data_contract.md deleted file mode 100644 index 5be63d3..0000000 --- a/docs/detailed_guidance/data_contract.md +++ /dev/null @@ -1,315 +0,0 @@ -Lets look at the data contract configuration from [Introduction to DVE](../README.md) more closely, with a few more fields added: - -```json -{ - "contract": { - "cache_originals": true, - "error_details": null, - "types": {}, - "schemas": {}, - "datasets": { - "CWTHeader": { - "fields": { - "version": { - "description": null, - "is_array": false, - "callable": "constr", - "constraints": { - "regex": "\\d{1,2}\\.\\d{1,2}" - } - }, - "periodStartDate": { - "description": null, - "is_array": false, - "callable": "conformatteddate", - "constraints": { - "date_format": "%Y-%m-%d" - } - }, - "periodEndDate": { - "description": null, - "is_array": false, - "callable": "conformatteddate", - "constraints": { - "date_format": "%Y-%m-%d" - } - }, - }, - "mandatory_fields": [ - "version", - "periodStartDate", - "periodEndDate" - ], - "reporting_fields": [], - "key_field": null, - "reader_config": { - ".xml": { - "reader": "XMLStreamReader", - "kwargs": { - "record_tag": "Header", - "n_records_to_read": 1 - }, - "field_names": null - } - }, - "aliases": {} - }, - "CWTActivity": { - "fields": { - "activityStartDate":{ - "is_array": false, - "callable": "conformatteddate", - "constraints": { - "date_format": "%Y-%m-%d" - } - } - } - } - } - } -} -``` - -### Types - -Here we have only filled out datasets. We've added a few more fields such as `PeriodEndDate` and `activityStartDate` and we're starting to see a fair amount of duplication. Lets refactor this to remove that. For this we use `types`. This allows us to pre-configure a type and re-use it across the different datasets. - -```json -{ - "contract": { - "cache_originals": true, - "error_details": null, - "types": { - "isodate": { - "description": "an isoformatted date type", - "callable": "conformatteddate", - "constraints": { - "date_format": "%Y-%m-%d" - } - } - }, - "schemas": {}, - "datasets": { - "CWTHeader": { - "fields": { - "version": { - "description": null, - "is_array": false, - "callable": "constr", - "constraints": { - "regex": "\\d{1,2}\\.\\d{1,2}" - } - }, - "periodStartDate": "isodate", - "periodEndDate": "isodate" - }, - "mandatory_fields": [ - "version", - "periodStartDate", - "periodEndDate" - ], - "reporting_fields": [], - "key_field": null, - "reader_config": { - ".xml": { - "reader": "XMLStreamReader", - "kwargs": { - "record_tag": "Header", - "n_records_to_read": 1 - } - } - }, - "aliases": {} - }, - "CWTActivity": { - "fields": { - "activityStartDate": "isodate" - }, - "reader_config": { - ".xml": { - "reader": "SparkXMLReader", - "kwargs": { - "record_tag": "Activity" - } - } - } - } - } - } -} -``` - -Now we've added an `isodate` type in the `types` object. We can now use this pre-configured type elsewhere. - -### Schemas - -Schemas are used when a dataset has another nested dataset within. An example in XML would be: - -```xml - - 2025-01-02 - 2025-01-31 - 1111111111 - 01 - - somecode - 100 - - abcd - 10.10 - - - defg - 20.20 - - - -``` - -We can see here that the Activity has a number of fields. `startdate`, `enddate` etc. However, `CstActivity` has its own fields. Including `resource` which has it's own fields. This is a use case for Schemas. - -```json -{ - "contract": { - "cache_originals": true, - "error_details": null, - "types": { - "isodate": { - "description": "an isoformatted date type", - "callable": "conformatteddate", - "constraints": { - "date_format": "%Y-%m-%d" - } - } - }, - "schemas": { - "resource": { - "fields": { - "resource_id": "str", - "cost": { - "callable": "condecimal", - "constraints": { - "max_digits": 18, - "decimal_places": 8 - } - } - }, - "mandatory_fields": [ - "cost", - "resource_id" - ] - }, - "CstActivity": { - "fields": { - "cstCode": "str", - "number": "int", - "resource": { - "model": "resource", - "is_array": true - } - } - } - }, - "datasets": { - "CWTActivity": { - "fields": { - "startdate": "isodate", - "enddate": "isodate", - "nhsnumber": "str", - "nationalcode": "str", - "CstActivity": { - "model": "CstActivity", - "is_array": true - } - }, - "reader_config": { - ".xml": { - "reader": "SparkXMLReader", - "kwargs": { - "record_tag": "Activity" - } - } - } - } - } - } -} -``` - -There's a lot going on here. We've set the `CstActivity` to a `model` and set the `is_array` parameter to `true`. This builds it as an array of that model. - -The same is true for resource in `CstActivity`. In Spark this would create a schema that has an array of structs of `CstActivities` with an array of Structs of Resources. - -You can define as many schemas as you need to model your domain. This is particularly useful when the nested schemas don't have linkage IDs, so they can't be parsed as separate entities because the hierarchy would be lost. - -Schemas can have `mandatory_fields` but don't require reader configurations. - -### Field types - -Fields can have a type defined as a string, either a base type like `date`, `str`, a [Domain type](Domain%20types.md), or a defined [type](#types): - -```json -{ - "startdate" : "date", - "enddate" : "isodate", - "numberofactivities": "NonNegativeInt" -} -``` - -If the type is an array then it needs to be defined as an object rather than short hand with just a string. - -```json -{ - "startdates" : { - "type" : "date", - "is_array" : true - } -} -``` - -It can be a model type defined in [schemas](#schemas), which can be also be an array or not. - -```json -{ - "schemas" : { - "APCCstActivity" : { - "fields" : { - ... - } - } - }, - ... - { - "CstActivity" : { - "model" : "APCCstActivity" - }, - "Resources" : { - "model" : "APCResources", - "is_array" : true - } - } -} -``` - -Finally callables. These are functions that return a type. Like `constr` from pydantic or `conformatteddate` in DVE [Domain types](Domain%20types.md). Any keyword arguments that go to these callables are passed in as `constraints`. - -```json -{ - "ID": { - "callable" : "constr", - "constraints" : { - "min_length" : 5, - "max_length": 20, - "regex" : "^ABC\w+" - } - }, - "nhsnumber" : { - "callable" : "permissive_nhs_number", - "constraints" : { - "warn_on_test_numbers" : true - } - } -} -``` - -In the example above, I've defined an `ID` field that is a constrained string type that should be between 5 and 20 characters in length and start with `ABC`. Additionally, I have also defined an `nhsnumber` field that raises warning when a test number is submitted (palindromes, or starts with 9). diff --git a/docs/detailed_guidance/domain_types.md b/docs/detailed_guidance/domain_types.md deleted file mode 100644 index 62279b1..0000000 --- a/docs/detailed_guidance/domain_types.md +++ /dev/null @@ -1,27 +0,0 @@ -# Domain Types - -Domain types are custom defined pydantic types that solve common problems with usual datasets or schemas defined in [Data contract](./data_contract.md). -This might include Postcodes, NHS Numbers, dates with specific formats etc. - -Below is a list of defined types, their output type and any contraints. Nested beneath them are any constraints that area allowed and their default values if there are any. -| Defined Type | Output Type | Contraints & Defaults | Supported Implementations | -| ------------ | ----------- | --------------------- | ------------------------- | -| NHSNumber | str | | Spark, DuckDB | -| permissive_nhs_number | str |
  • warn_on_test_numbers = False
  • | Spark, DuckDB | -| Postcode | str | | Spark, DuckDB | -| OrgId | str | | Spark, DuckDB | -| conformatteddate | date |
  • date_format: str
  • ge: date
  • le: date
  • gt: date
  • lt: date
  • | Spark, DuckDB | -| formatteddatetime | datetime |
  • date_format: str
  • timezone_treatment: one_of ["forbid", "permit", "require] = "permit"
  • | Spark, DuckDB | -| formattedtime | time |
  • time_format: str
  • timezone_treatment: one_of ["forbid", "permit", "require"] = "permit" | DuckDB | -| reportingperiod | date |
  • reporting_period_type: one_of ["start", "end"]
  • date_format: str = "%Y-%m-%d"
  • | Spark, DuckDB | -| alphanumeric | str |
  • min_digits : NonNegativeInt = 1
  • max_digits: PositiveInt = 1
  • | Spark, DuckDB | -| identifier | str |
  • min_digits : NonNegativeInt = 1
  • max_digits: PositiveInt = 1
  • | Spark, DuckDB | - -**Other types that are allowed include:** -- str -- int -- date -- datetime -- Decimal -- float -- Any types that are included in [pydantic version 1.10](https://docs.pydantic.dev/1.10/usage/types/#pydantic-types) diff --git a/docs/detailed_guidance/feedback_messages.md b/docs/detailed_guidance/feedback_messages.md deleted file mode 100644 index 56ac983..0000000 --- a/docs/detailed_guidance/feedback_messages.md +++ /dev/null @@ -1 +0,0 @@ -WIP - it's a class in [DVE/core_engine/message.py](../../src/core_engine/message.py). \ No newline at end of file diff --git a/docs/detailed_guidance/file_transformation.md b/docs/detailed_guidance/file_transformation.md deleted file mode 100644 index f83afe0..0000000 --- a/docs/detailed_guidance/file_transformation.md +++ /dev/null @@ -1 +0,0 @@ -WIP - See reader config in into \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c4c60f5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,39 @@ +--- +title: Data Validation Engine +tags: + - Introduction + - File Transformation + - Data Contract + - Business Rules + - Spark + - DuckDB +--- + +# Data Validation Engine + +The Data Validation Engine (DVE) is a configuration driven data validation library written in [Python](https://www.python.org/), [Pydantic](https://docs.pydantic.dev/latest/) and a SQL backend currently consisting of [DuckDB](https://duckdb.org/) or [Spark](https://spark.apache.org/sql/). The configuration to run validations against a dataset are defined and written in a json document, which we will be referring to as the "dischema". The rules written within the dischema are designed to be run against all incoming data in a given submision - as this allows the DVE to capture all possible issues with the data without the submitter having resubmit the same data repeatedly which is burdensome and time consuming for the submitter and receiver of the data. Additionally, the rules can be configured to have the following behaviour: + +- **File Rejection** - The entire submission will be rejected if the given rule triggers one or more times. +- **Row Rejection** - The row that triggered the rule will be rejected. Rows that pass the validation will be flowed through into a validated entity. +- **Warning** - The rule will still trigger and be listed as a feedback message, but the record will still flow through into the validated entity. + +Certain scenarios prevent all validations from being executed. For more details, see the [File Transformation](user_guidance/file_transformation.md) section. + +The DVE has 3 core components: + +1. [File Transformation](user_guidance/file_transformation.md) - Parsing submitted files into a "stringified" (all fields casted to string) parquet format. + + ???+ tip + If your files are already in a parquet format, you do not need to use the file transformation and you can move straight onto the Data Contract. + +2. [Data Contract](user_guidance/data_contract.md) - Validates submitted data against a specified datatype and casts successful records to that type. + +3. [Business rules](user_guidance/business_rules.md) - Performs simple and complex validations such as comparisons between fields, entities and/or lookups against reference data. + +For each component listed above, a [feedback message](user_guidance/feedback_messages.md) is generated whenever a rule is violated. These [feedback messages](user_guidance/feedback_messages.md) can be interegated directly into your system given you can consume `jsonl` files. Alternatively, we offer a fourth component called the [Error Reports](user_guidance/error_reports.md). This component will load the [feedback messages](user_guidance/feedback_messages.md) into an `.xlsx` (Excel) file which could be sent back to the submitter of the data. The excel file is compatiable with services that offer spreadsheet reading such as [Microsoft Excel](https://www.microsoft.com/en/microsoft-365/excel), [Google Docs](https://docs.google.com/), [Libre Office Calc](https://www.libreoffice.org/discover/calc/) etc. + +To be able to run the DVE out of the box, you can have look at the Backend Implementations sections with [DuckDB](user_guidance/implementations/duckdb.md) or [Spark](user_guidance/implementations/spark.md). If you to need a write a custom backend implementation, you may want to look at the [Advanced User Guidance](advanced_guidance/backends.md) section. + +Feel free to use the Table of Contents on the left hand side of the page to navigate to sections of interest or to use the "Next" and "Previous" buttons at the bottom of each page if you want to read through each page in sequential order. + +If you have questions or need additional support with the DVE, then please raise an issue on our GitHub page [here](https://github.com/NHSDigital/data-validation-engine/issues). diff --git a/docs/json_schemas/README.md b/docs/json_schemas/README.md deleted file mode 100644 index 10d96e1..0000000 --- a/docs/json_schemas/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# JSON Schemas - -These JSON schemas define the - -For autocomplete support in VS Code, alter `settings.json` and add new entries to the -`json.schemas` array (or create this value if it's missing) - -```json -{ - ..., - "json.schemas": [ - { - "fileMatch": [ - "*.dischema.json" - ], - "url": "./json_schemas/dataset.schema.json" - }, - { - "fileMatch": [ - "*.rulestore.json", - "*_ruleset.json" - ], - "url": "./json_schemas/rule_store.schema.json" - } - ] -} -``` - -Data Ingest JSON schemas (when saved with file_name `dataset.dischema.json`) should then have -autocomplete support. diff --git a/docs/user_guidance/auditing.md b/docs/user_guidance/auditing.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/auditing.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/business_rules.md b/docs/user_guidance/business_rules.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/business_rules.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/data_contract.md b/docs/user_guidance/data_contract.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/data_contract.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/error_reports.md b/docs/user_guidance/error_reports.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/error_reports.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/feedback_messages.md b/docs/user_guidance/feedback_messages.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/feedback_messages.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/file_transformation.md b/docs/user_guidance/file_transformation.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/file_transformation.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/getting_started.md b/docs/user_guidance/getting_started.md new file mode 100644 index 0000000..c790b48 --- /dev/null +++ b/docs/user_guidance/getting_started.md @@ -0,0 +1,117 @@ +--- +title: Installing the Data Validation Engine +tags: + - Introduction + - Data Contract + - Business Rules +--- + + +## Rules Configuration Introduction + +To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `data contract` or `contract` - this describes what a *"perfect"* version of your data should look like after type casting the data. For example, here is a document describing how the DVE might validate data about a movies dataset: + +!!! example "Example `movies.dischema.json`" + ```json + { + "contract": { + "schemas": { + "cast": { + "fields": { + "name": "str", + "role": "str", + "date_joined": "date" + } + } + }, + "datasets": { + "movies": { + "fields": { + "title": "str", + "year": "int", + "genre": { + "type": "str", + "is_array": true + }, + "duration_minutes": "int", + "ratings": { + "type": "NonNegativeFloat", + "is_array": true + }, + "cast": { + "model": "cast", + "is_array": true + } + }, + }, + "mandatory_fields": [ + "title", + "year" + ], + "reader_config": { + ".json": { + "reader": "SparkJSONReader" + } + } + } + } + } + ``` + +Within the example above, there are two parent keys - `schemas` and `datasets`. + +`schemas` allow you to define custom complex data types. So, in the example above, the field `cast` would be expecting an array of structs containing the actors name, role and the date they joined the movie. + +`datasets` describe the actual models for the entities you want to load. In the example above, we only want to load a single entity called `movies` which contains the fields `title, year, genre, duration_minutes, ratings and cast`. However, you could load the complex type `cast` into a seperate entity if you wanted to split your data into seperate entities. This can be useful in situations where a given entity has all the information you need to perform a given validation rule against, making the performance of rule faster & more efficient as there is less data to scan in a given entity. + +!!! note + The "splitting" of entities is considerably more useful in situtations where you want to normalise/de-normalise your data. If you're unfamiliar with this concept, you can read more about it [here](https://en.wikipedia.org/wiki/Database_normalization). However, you should keep in mind potential performance impacts of doing this. If you have rules that requires fields from different entities, you will have to perform a `join` between the split entities to be able to perform the rule. + +For each dataset definition, you will need to provide a `reader_config` which describes how to load the data during the [File Transformation](file_transformation.md) stage. So, in the example above, we expect `movies` to come in as a `json` file. However, you can add more readers if you have the same data in different data formats (e.g. `csv`, `xml`, `json`). Regardless, of what they submit, the [File Transformation](file_transformation.md) stage will turn their submissions into a consistent "stringified" parquet format which is a requirement for the subsequent stages. + +To learn more about how you can construct your Data Contract please read [here](data_contract.md). + +The second part of the dischema are the `business_rules` *or* `tranformations`. This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you have choosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). +!!! example "Example `movies.dischema.json`" + ```json + { + "transformations": { + "filters":{ + { + "entity": "movies", + "name": "Ensure movie is less than 4 hours long", + "expression": "duration_minutes > 240", + "error_code": "MOVIE_TOO_LONG", + "failure_message": "Movie must be less than 4 hours long." + } + } + } + } + ``` +You may look at the expression above and think "Hang on! That's the opposite of what you want! You're only getting movies less than 4 hours!", however, the validation rules are wrapped inside a `NOT` expression. So, you write the rules as though you are looking for non problematic values. + +We also offer a feature called `complex_rules`. These are rules where you need to transform the data before you can apply the rule. For instance, you may want to perform a join, aggregate the data, or perform a filter. The complex rules allow you to do this and you can combine them as well before you perform the validation. + +To learn more about how to write your validation rules and complex validation rules, please follow the guidance written [here](business_rules.md). + + +## Utilising the Pipeline objects to run the DVE +Within the DVE package, we have created the ability to build pipeline objects to help orchestrate the running of the DVE from start to finish. We currently have an implementation for `Spark` and `DuckDB` ready for users to use out of the box. The links below will direct you to detailed guidance on how you can setup a DVE pipeline. + +
    + +- :material-duck:{ .lg .middle } __Set up with DuckDB__ + + --- + + [:octicons-arrow-right-24: Setup a DuckDB pipeline here](implementations/duckdb.md) + +- :material-shimmer:{ .lg .middle } __Set up with Spark__ + + --- + + [:octicons-arrow-right-24: Setup a Spark pipeline here](implementations/spark.md) + +
    + +
    diff --git a/docs/user_guidance/implementations/duckdb.md b/docs/user_guidance/implementations/duckdb.md new file mode 100644 index 0000000..584f1d0 --- /dev/null +++ b/docs/user_guidance/implementations/duckdb.md @@ -0,0 +1,175 @@ +!!! quote + DuckDB is a high-performance analytical database system. It is designed to be fast, reliable, portable, and easy to use. DuckDB provides a rich SQL dialect with support far beyond basic SQL. DuckDB supports arbitrary and nested correlated subqueries, window functions, collations, complex types (arrays, structs, maps), and several extensions designed to make SQL easier to use. + + DuckDB is available as a standalone CLI application and has clients for Python, R, Java, Wasm, etc., with deep integrations with packages such as pandas and dplyr. + +You can read more about DuckDB with the following links: + +- [Official Documentation :material-file-document-arrow-right:](https://duckdb.org/docs/stable/) +- [GitHub :material-github:](https://github.com/duckdb/duckdb) + +## Setting up a DuckDB Connection + +To be able to use DuckDB with the DVE you first need to create a DuckDB connection object. You can simply do this with the following code: + +=== "Persist Database on memory" + ```py + import duckdb as ddb + + db_path = ":memory:" + db_con = ddb.connect(db_path) + ``` + +=== "Persist Database on disk" + ```py + import duckdb as ddb + + db_path = "path/to/my_database.duckdb" + db_con = ddb.connect(db_path) + ``` + +!!! note + You will need to close the db_con object with `db.close()`. Alternatively, you could build a custom [context manager](https://docs.python.org/3/library/contextlib.html) object to open and close the connection without needing to explicitly close the connection. + + +Now you have the DuckDB connection object setup, you are ready to setup the required DVE objects. + +## Generating SubmissionInfo objects + +Before we utilise the DVE, we need to generate an iterable object containing `SubmissionInfo` objects. These objects effectively contain the necessery metadata for the DVE to work with a given submission. Here is an example function used to generate SubmissionInfo objects from a given path: + +```py +import glob +from datetime import date, datetime +from pathlib import Path +from typing import Optional +from uuid import uuid4 + +from dve.core_engine.models import SubmissionInfo + + +def generate_sub_infos_from_submissions_path( + submission_path: Path, + dataset_id: Optional[str] = "example", + submitting_org: Optional[str] = None, + submission_method: Optional[str] = "local_test", + reporting_period_start_date: Optional[date | datetime] = None, + reporting_period_end_date: Optional[date | datetime] = None, +) -> list[SubmissionInfo]: + sub_infos: list[SubmissionInfo] = [] + for f in glob.glob(str(submission_path) + "/*.*"): + file_path = Path(f) + file_stats = file_path.stat() + metadata = { + "dataset_id": dataset_id, + "file_name": file_path.stem, + "file_extension": file_path.suffix, + "submission_method": submission_method, + "file_size": file_stats.st_size, + "datetime_received": datetime.now(), + } + if submitting_org: + metadata["submitting_org"] = submitting_org + if reporting_period_start_date: + metadata["reporting_period_start"] = str(reporting_period_start_date) + if reporting_period_end_date: + metadata["reporting_period_end"] = str(reporting_period_end_date) + + sub_infos.append(SubmissionInfo(submission_id=uuid4().hex, **metadata)) + return sub_infos + + +submissions = generate_sub_infos_from_submissions_path(Path("path", "to", "my", "submissions")) +``` + +!!! note + If you have a large number of submissions, it may be worth converting the above into a [generator](https://docs.python.org/3/reference/expressions.html#generator-expressions). Using the example above, you can do this by simply removing the sub_infos object and yield the SubmissionInfo object per file returned from the glob iterator. + +## DuckDB Audit Table Setup + +The first object you must setup is an "Audit Manager Object". This can be done with the following code: + +```py +from dve.core_engine.backends.implementations.duckdb.auditing import DDBAuditingManager + +audit_manager = DDBAuditingManager(db_path, connection=db_con) # type: ignore +``` + +The "Audit Manager" object within the DVE is used to keep track of the status of your submission. A submission for instance could fail during the File Transformation section, so it's important that we have something to keep track of the submission. The Audit Manager object has a number of methods that can be used to read/write information to tables being stored within the duckdb connection setup in the previous step. + +You can learn more about the Auditing Objects [here](../auditing.md). + +Once you have setup your "Audit Manager" object, we can move onto setting up the DuckDB reference data loader (if required) and then setting up the DuckDB DVE Pipeline object. + +## DuckDB Reference Data Setup (Optional) +If your business rules are reliant on utilising reference data, you will need to write the following code to ensure that reference data can be loaded during the application of those rules: + +```py +from dve.core_engine.backends.implementations.duckdb.reference_data import DuckDBRefDataLoader + +DuckDBRefDataLoader.connection = db_con +DuckDBRefDataLoader.dataset_config_uri = Path("path", "to", "my", "rules").as_posix() +``` + +The connection passed into the `DuckDBRefDataLoader` object will then be able use various DuckDB readers to load data from an existing table on the connection OR loading data from reference data persisted in either `parquet` or `pyarrow` format. + +If you want to learn more about the reference data loaders, you can view the advanced user guidance [here](../../advanced_guidance/package_documentation/refdata_loaders.md). + +Now we can move onto setting up the DuckDB DVE Pipeline object. + +## DuckDB Pipeline Setup + +To setup a DuckDB Pipeline, you can use the following example below: + +=== "Without Rules" + + ```py + + from dve.pipeline.duckdb_pipeline import DDBDVEPipeline + + + dve_pipeline = DDBDVEPipeline( + processed_files_path=Path("location_to_store", "dve_outputs").as_posix(), + audit_tables=audit_manager, + connection=db_con, + submitted_files_path=Path("submissions", "path").as_posix(), + reference_data_loader=DuckDBRefDataLoader, + ) + ``` + +=== "With Rules" + + ```py + from dve.pipeline.duckdb_pipeline import DDBDVEPipeline + + + dve_pipeline = DDBDVEPipeline( + processed_files_path=Path("location_to_store", "dve_outputs").as_posix(), + audit_tables=audit_manager, + connection=db_con, + rules_path=Path("to", "my", "rules").as_posix(), + submitted_files_path=Path("submissions", "path").as_posix(), + reference_data_loader=DuckDBRefDataLoader, + ) + ``` + +!!! note + If using remote resources, then you will want to use `as_uri` for your paths. + + E.g. + ```py + Path("remote", "path").as_uri() + ``` + +Once your Pipeline object is defined, you can simply run the `cluster_pipeline_run` method. E.g. + +```py +error_reports = dve_pipeline.cluster_pipeline_run() +``` + + +## Further documentation +For further details on the objects referenced above, you can use the following links to read more about the objects: + +- [Pipeline Docs](../../advanced_guidance/package_documentation/pipeline.md) +- [Reference Data Docs](../../advanced_guidance/package_documentation/refdata_loaders.md) diff --git a/docs/user_guidance/implementations/mixing_implementations.md b/docs/user_guidance/implementations/mixing_implementations.md new file mode 100644 index 0000000..dc75aeb --- /dev/null +++ b/docs/user_guidance/implementations/mixing_implementations.md @@ -0,0 +1,30 @@ + +## Mixing backend implementations + +The examples shown above are using the Spark Backend. DVE also has a DuckDB backend found at [core_engine.backends.implementations.duckdb](https://github.com/NHSDigital/data-validation-engine/tree/main/src/dve/core_engine/backends/implementations/duckdb). In order to mix the two you will need to convert from one type of entity to the other. For example from a spark `Dataframe` to DuckDB `relation`. The easiest way to do this is to use the `write_parquet` method from one backend and use `read_parquet` from another backend. + +Currently the configuration isn't backend agnostic for applying business rules. So if you want to swap between spark and duckdb, the business rules need to be written using only features that are common to both backends. For example, a regex check in spark would be something along the lines of... +```sql +nhsnumber rlike '^\d{10}$' +``` +...but in duckdb it would be... +```sql +regexp_matches(nhsnumber, '^\d{10}$') +``` +Failures in parsing the expressions lead to failure messages such as +```python +FeedbackMessage( + entity=None, + record=None, + failure_type='integrity', + is_informational=False, + error_type=None, + error_location=None, + error_message="Unexpected error (AnalysisException: Undefined function: 'regexp_matches'. This function is neither a registered temporary function nor a permanent function registered in the database 'default'.; line 1 pos 5) in transformations (rule: root; step: 0; id: None)", + error_code=None, + reporting_field=None, + reporting_field_name=None, + value=None, + category=None +) +``` \ No newline at end of file diff --git a/docs/user_guidance/implementations/platform_specific/databricks.md b/docs/user_guidance/implementations/platform_specific/databricks.md new file mode 100644 index 0000000..e478c0d --- /dev/null +++ b/docs/user_guidance/implementations/platform_specific/databricks.md @@ -0,0 +1,10 @@ +## Installation + +Firstly, please ensure that you've read the guidance on our [installation section](../../install.md). + +You can follow these guides to help you install the Data Validation Engine onto a Databricks Cluster: + +- [AWS](https://docs.databricks.com/aws/en/libraries/) +- [GCP](https://docs.databricks.com/gcp/en/libraries/) +- [Microsoft Azure](https://learn.microsoft.com/en-us/azure/databricks/libraries/) + diff --git a/docs/user_guidance/implementations/platform_specific/palantir_foundry.md b/docs/user_guidance/implementations/platform_specific/palantir_foundry.md new file mode 100644 index 0000000..1c63d00 --- /dev/null +++ b/docs/user_guidance/implementations/platform_specific/palantir_foundry.md @@ -0,0 +1,2 @@ +!!! note + This section has not yet been written. Coming soon. diff --git a/docs/user_guidance/implementations/spark.md b/docs/user_guidance/implementations/spark.md new file mode 100644 index 0000000..75e1f5e --- /dev/null +++ b/docs/user_guidance/implementations/spark.md @@ -0,0 +1,165 @@ +!!! quote + Apache Spark™ is a multi-language engine for executing data engineering, data science, and machine learning on single-node machines or clusters. + +You can read more about Spark here with the following links: + +- [Official Documentation :material-file-document-arrow-right:](https://spark.apache.org/) +- [GitHub :material-github:](https://github.com/apache/spark) + + +## Setting up a Spark Session + +For a basic Spark Session setup, you can use the following snippet of code: +```py +spark = SparkSession.builder.appName("SimpleApp").getOrCreate() +``` + +You can learn more about setting up a Spark Session [here](https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.SparkSession.html). + +!!! warning + + If you need to load XML data and the version of spark you're running is <4.0.0, you'll need the `spark-xml` extension. You can read more about it [here](https://github.com/databricks/spark-xml). + + +## Generating SubmissionInfo Objects + +Before we utilise the DVE, we need to generate an iterable object containing `SubmissionInfo` objects. These objects effectively contain the necessery metadata for the DVE to work with a given submission. Here is an example function used to generate SubmissionInfo objects from a given path: + +```py +import glob +from datetime import date, datetime +from pathlib import Path +from typing import Optional +from uuid import uuid4 + +from dve.core_engine.models import SubmissionInfo + + +def generate_sub_infos_from_submissions_path( + submission_path: Path, + dataset_id: Optional[str] = "example", + submitting_org: Optional[str] = None, + submission_method: Optional[str] = "local_test", + reporting_period_start_date: Optional[date | datetime] = None, + reporting_period_end_date: Optional[date | datetime] = None, +) -> list[SubmissionInfo]: + sub_infos: list[SubmissionInfo] = [] + for f in glob.glob(str(submission_path) + "/*.*"): + file_path = Path(f) + file_stats = file_path.stat() + metadata = { + "dataset_id": dataset_id, + "file_name": file_path.stem, + "file_extension": file_path.suffix, + "submission_method": submission_method, + "file_size": file_stats.st_size, + "datetime_received": datetime.now(), + } + if submitting_org: + metadata["submitting_org"] = submitting_org + if reporting_period_start_date: + metadata["reporting_period_start"] = str(reporting_period_start_date) + if reporting_period_end_date: + metadata["reporting_period_end"] = str(reporting_period_end_date) + + sub_infos.append(SubmissionInfo(submission_id=uuid4().hex, **metadata)) + return sub_infos + + +submissions = generate_sub_infos_from_submissions_path(Path("path", "to", "my", "submissions")) +``` + +!!! note + If you have a large number of submissions, it may be worth converting the above into a [generator](https://docs.python.org/3/reference/expressions.html#generator-expressions). Using the example above, you can do this by simply removing the sub_infos object and yield the SubmissionInfo object per file returned from the glob iterator. + +## Spark Audit Table Setup + +The first object you must setup is an "Audit Manager Object". This can be done with the following code: + +```py +from dve.core_engine.backends.implementations.spark.auditing import SparkAuditingManager + +db_name = "test_dve" +spark.sql(f"CREATE DATABASE {db_name};") + +audit_manager = SparkAuditingManager(db_name, spark) +``` + +!!! note + + `spark` session is optional for the `SparkAuditingManager`. If not provided a spark session will be generated. + +The "Audit Manager" object within the DVE is used to keep track of the status of your submission. A submission for instance could fail during the File Transformation section, so it's important that we have something to keep track of the submission. The Audit Manager object has a number of methods that can be used to read/write information to tables being stored within the duckdb connection setup in the previous step. + +You can learn more about the Auditing Objects [here](../auditing.md). + +Once you have setup your "Audit Manager" object, we can move onto setting up the Spark reference data loader (if required) and then setting up the Spark DVE Pipeline object. + +## Spark Reference Data Setup (Optional) +If your business rules are reliant on utilising reference data, you will need to write the following code to ensure that reference data can be loaded during the application of those rules: + +```py +from pathlib import Path + +from dve.core_engine.backends.implementations.spark.reference_data import SparkRefDataLoader + +SparkRefDataLoader.spark = spark +SparkRefDataLoader.dataset_config_uri = Path("path", "to", "my", "rules").as_posix() +``` + +## Spark Pipeline Setup + +To setup a Spark Pipeline, you can use the following example below: + +=== "Without Rules" + + ```py + + from dve.pipeline.spark_pipeline import SparkDVEPipeline + + + dve_pipeline = SparkDVEPipeline( + processed_files_path=Path("location_to_store", "dve_outputs").as_posix(), + audit_tables=audit_manager, + submitted_files_path=Path("submissions", "path").as_posix(), + reference_data_loader=SparkRefDataLoader, + spark=spark, + ) + ``` + +=== "With Rules" + + ```py + from dve.pipeline.spark_pipeline import SparkDVEPipeline + + + dve_pipeline = SparkDVEPipeline( + processed_files_path=Path("location_to_store", "dve_outputs").as_posix(), + audit_tables=audit_manager, + rules_path=Path("to", "my", "rules").as_posix(), + submitted_files_path=Path("submissions", "path").as_posix(), + reference_data_loader=SparkRefDataLoader, + spark=spark, + ) + ``` + +!!! note + If using remote resources, then you will want to use `as_uri` for your paths. + + E.g. + ```py + Path("remote", "path").as_uri() + ``` + +Once your Pipeline object is defined, you can simply run the `cluster_pipeline_run` method. E.g. + +```py +error_reports = dve_pipeline.cluster_pipeline_run() +``` + +## Further documentation + +For further details on the objects referenced above, you can use the following links to read more about the objects: + +- [Pipeline Docs](../../advanced_guidance/package_documentation/pipeline.md) +- [Reference Data Docs](../../advanced_guidance/package_documentation/refdata_loaders.md) diff --git a/docs/user_guidance/install.md b/docs/user_guidance/install.md new file mode 100644 index 0000000..34e1f3a --- /dev/null +++ b/docs/user_guidance/install.md @@ -0,0 +1,91 @@ +--- +title: Installing the Data Validation Engine +tags: + - Introduction + - Installation +--- + +!!! warning + **DVE is currently an unstable package. Expect breaking changes between every minor patch**. We intend to follow semantic versioning of `major.minor.patch` more strictly after a 1.0 release. Until then, we recommend that you pin your install to the latest version available and keep an eye on [future releases](https://github.com/NHSDigital/data-validation-engine/releases) that will have changelogs provided with each release. + + **Please note that we only support Python runtimes of 3.10 and 3.11.** In the future we will look to add support for Python versions greater than 3.11, but it's not an immediate priority. + + If working on Python 3.7, the `0.1` release supports this (and only this) version of Python. However, we have not been updating that version with any bugfixes, performance improvements etc. There are also a number of vulnerable dependencies on version `0.1` release due to [Python 3.7 being depreciated](https://devguide.python.org/versions/) and a number of packages dropping support. **If you choose to install `0.1`, you accept the risks of doing so and additional support will not be provided.** + +You can install the DVE package through python package managers such as [pip](https://pypi.org/project/pip/), [pipx](https://github.com/pypa/pipx), [uv](https://docs.astral.sh/uv/) and [poetry](https://python-poetry.org/). See examples below for installing the DVE: + +=== "pip" + + ```sh + pip install git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + ``` + +=== "pipx" + + ```sh + pipx install git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + ``` + +=== "uv" + + Add to your existing `uv` project... + ```sh + uv add git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + ``` + + ...or you can add via your `pyproject.toml`... + + ```toml + dependencies = [ + nhs-dve @ https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + ] + ``` + + ```sh + uv lock + ``` + + ```sh + uv sync + ``` + +=== "poetry" + + Add to your existing `poetry` project... + ```sh + poetry add git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + ``` + + ...or you can add via your `pyproject.toml`... + + ```toml + [tool.poetry.dependencies] + nhs-dve = { git = "https://github.com/NHSDigital/data-validation-engine.git", tag = "vMaj.Min.Pat" } + ``` + + ```sh + poetry lock + ``` + + ```sh + poetry install + ``` + +!!! note + Replace `Maj.Min.Pat` with the version of the DVE you want. We recommend the latest release if you're just starting with the DVE. + +!!! info + We are working on getting the DVE available via PyPi and Conda. We will update this page with the relevant instructions once this has been succesfully setup. + +Python dependencies are listed in `pyproject.toml` [(here)](https://github.com/NHSDigital/data-validation-engine/blob/main/pyproject.toml). Many of the dependencies are locked to quite restrictive versions due to complexity of this package. Core packages such as Pydantic, Pyspark and DuckDB are unlikely to receive flexible version constraints as changes in those packages could cause the DVE to malfunction. For less important dependencies, we have tried to make the contraints more flexible. Therefore, we would advise you to install the DVE into a seperate environment rather than trying to integrate it into an existing Python environment. + +Once you have installed the DVE you are almost ready to use it. To be able to run the DVE, you will need to choose one of the supported pipeline runners (see Backend implementations here - [DuckDB](user_guidance/implementations/duckdb.md) *or* [Spark](user_guidance/implementations/spark.md)) and you will need to create your own dischema document to configure how the DVE should validate incoming data. You can read more about this in [Getting Started](getting_started.md) page. + + +## DVE Version Compatability Matrix + +| DVE Version | Python Version | DuckDB Version | Spark Version | +| ------------ | -------------- | -------------- | ------------- | +| >=0.6 | >=3.10,<3.12 | 1.1.* | 3.4.* | +| >=0.2,<0.6 | >=3.10,<3.12 | 1.1.0 | 3.4.4 | +| 0.1 | >=3.7.2,<3.8 | 1.1.0 | 3.2.1 | diff --git a/includes/jargon_and_acronyms.md b/includes/jargon_and_acronyms.md new file mode 100644 index 0000000..4962306 --- /dev/null +++ b/includes/jargon_and_acronyms.md @@ -0,0 +1,3 @@ +*[DVE]: Data Validation Engine +*[dischema]: Data ingest schema +*[stringified]: all fields casted to string diff --git a/overrides/.icons/nhseng.svg b/overrides/.icons/nhseng.svg new file mode 100644 index 0000000..cd21739 --- /dev/null +++ b/overrides/.icons/nhseng.svg @@ -0,0 +1,4 @@ + + + + diff --git a/poetry.lock b/poetry.lock index 7b1987a..7074536 100644 --- a/poetry.lock +++ b/poetry.lock @@ -779,14 +779,14 @@ files = [ [[package]] name = "click" -version = "8.3.1" +version = "8.2.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["dev", "lint"] +groups = ["dev", "docs", "lint"] files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [package.dependencies] @@ -798,12 +798,12 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev", "lint", "test"] +groups = ["dev", "docs", "lint", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {lint = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {docs = "platform_system == \"Windows\"", lint = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "commitizen" @@ -1024,14 +1024,14 @@ files = [ [[package]] name = "cucumber-tag-expressions" -version = "9.0.0" +version = "9.1.0" description = "Provides a tag-expression parser and evaluation logic for cucumber/behave" optional = false python-versions = ">=3.10" groups = ["dev", "test"] files = [ - {file = "cucumber_tag_expressions-9.0.0-py3-none-any.whl", hash = "sha256:36f3eacf49ad24feeb60218db4c51ab114853b3f022f4f3ad790c32b7597faee"}, - {file = "cucumber_tag_expressions-9.0.0.tar.gz", hash = "sha256:731302c12bd602309596b35e733c1021b517d4948329803c23ca026e26ef4e99"}, + {file = "cucumber_tag_expressions-9.1.0-py3-none-any.whl", hash = "sha256:cca145d677a942c1877e5a2cf13da8c6ec99260988877c817efd284d8455bb56"}, + {file = "cucumber_tag_expressions-9.1.0.tar.gz", hash = "sha256:d960383d5885300ebcbcb14e41657946fde2a59d5c0f485eb291bc6a0e228acc"}, ] [[package]] @@ -1046,6 +1046,21 @@ files = [ {file = "decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656"}, ] +[[package]] +name = "deepmerge" +version = "2.0" +description = "A toolset for deeply merging Python dictionaries." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00"}, + {file = "deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pyupgrade", "twine", "validate-pyproject[all]"] + [[package]] name = "delta-spark" version = "2.4.0" @@ -1218,16 +1233,48 @@ python-dateutil = ">=2.4" [[package]] name = "filelock" -version = "3.21.2" +version = "3.24.3" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560"}, - {file = "filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708"}, + {file = "filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d"}, + {file = "filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa"}, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +groups = ["docs"] +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "griffelib" +version = "2.0.0" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.10" +groups = ["docs"] +files = [ + {file = "griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f"}, +] + +[package.extras] +pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] + [[package]] name = "identify" version = "2.6.16" @@ -1318,7 +1365,7 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["main", "dev", "test"] +groups = ["main", "dev", "docs", "test"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1505,13 +1552,29 @@ html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] source = ["Cython (==0.29.37)"] +[[package]] +name = "markdown" +version = "3.10.2" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.10" +groups = ["docs"] +files = [ + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, +] + +[package.extras] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] +testing = ["coverage", "pyyaml"] + [[package]] name = "markupsafe" version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["main", "dev", "test"] +groups = ["main", "dev", "docs", "test"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1616,6 +1679,127 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +groups = ["docs"] +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +description = "Automatically link across pages in MkDocs." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089"}, + {file = "mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197"}, +] + +[package.dependencies] +Markdown = ">=3.3" +markupsafe = ">=2.0.1" +mkdocs = ">=1.1" + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +groups = ["docs"] +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +description = "Automatic documentation from sources, for MkDocs." +optional = false +python-versions = ">=3.10" +groups = ["docs"] +files = [ + {file = "mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046"}, + {file = "mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434"}, +] + +[package.dependencies] +Jinja2 = ">=3.1" +Markdown = ">=3.6" +MarkupSafe = ">=1.1" +mkdocs = ">=1.6" +mkdocs-autorefs = ">=1.4" +mkdocstrings-python = {version = ">=1.16.2", optional = true, markers = "extra == \"python\""} +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=1.16.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +description = "A Python handler for mkdocstrings." +optional = false +python-versions = ">=3.10" +groups = ["docs"] +files = [ + {file = "mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12"}, + {file = "mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8"}, +] + +[package.dependencies] +griffelib = ">=2.0" +mkdocs-autorefs = ">=1.4" +mkdocstrings = ">=0.30" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + [[package]] name = "moto" version = "4.0.13" @@ -1994,7 +2178,7 @@ version = "26.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev", "lint", "test"] +groups = ["dev", "docs", "lint", "test"] files = [ {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, @@ -2113,14 +2297,14 @@ files = [ [[package]] name = "parse" -version = "1.21.0" +version = "1.21.1" description = "parse() is the opposite of format()" optional = false python-versions = "*" groups = ["dev", "test"] files = [ - {file = "parse-1.21.0-py2.py3-none-any.whl", hash = "sha256:6d81f7bae0ab25fd72818375c4a9c71c8705256bfc42e8725be609cf8b904aed"}, - {file = "parse-1.21.0.tar.gz", hash = "sha256:937725d51330ffec9c7a26fdb5623baa135d8ba8ed78817ea9523538844e3ce4"}, + {file = "parse-1.21.1-py2.py3-none-any.whl", hash = "sha256:55339ca698019815df3b8e8b550e5933933527e623b0cdf1ca2f404da35ffb47"}, + {file = "parse-1.21.1.tar.gz", hash = "sha256:825e1a88e9d9fb481b8d2ca709c6195558b6eaa97c559ad3a9a20aa2d12815a3"}, ] [[package]] @@ -2150,7 +2334,7 @@ version = "1.0.4" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" -groups = ["dev", "lint"] +groups = ["dev", "docs", "lint"] files = [ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, @@ -2164,14 +2348,14 @@ tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "platformdirs" -version = "4.7.0" +version = "4.9.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" -groups = ["dev", "lint"] +groups = ["dev", "docs", "lint"] files = [ - {file = "platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6"}, - {file = "platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36"}, + {file = "platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd"}, + {file = "platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291"}, ] [[package]] @@ -2400,7 +2584,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev", "test"] +groups = ["dev", "docs", "test"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -2438,6 +2622,25 @@ tomlkit = ">=0.10.1" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pymdown-extensions" +version = "10.21" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f"}, + {file = "pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.19.1)"] + [[package]] name = "pyspark" version = "3.4.4" @@ -2504,7 +2707,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev", "test"] +groups = ["main", "dev", "docs", "test"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2531,7 +2734,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev", "test"] +groups = ["dev", "docs", "test"] files = [ {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, @@ -2601,6 +2804,21 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +description = "A custom YAML tag for referencing environment variables in YAML files." +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, + {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, +] + +[package.dependencies] +pyyaml = "*" + [[package]] name = "questionary" version = "2.1.1" @@ -2640,14 +2858,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.25.8" +version = "0.26.0" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" groups = ["dev", "test"] files = [ - {file = "responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c"}, - {file = "responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4"}, + {file = "responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37"}, + {file = "responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4"}, ] [package.dependencies] @@ -2682,7 +2900,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev", "test"] +groups = ["main", "dev", "docs", "test"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2709,7 +2927,7 @@ version = "2.4.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["dev", "lint", "test"] +groups = ["dev", "docs", "lint", "test"] markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, @@ -2890,12 +3108,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev", "lint", "test"] +groups = ["main", "dev", "docs", "lint", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {test = "python_version == \"3.10\""} +markers = {docs = "python_version == \"3.10\"", test = "python_version == \"3.10\""} [[package]] name = "tzdata" @@ -2929,25 +3147,68 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "20.36.1" +version = "20.38.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, - {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, + {file = "virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794"}, + {file = "virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +docs = ["furo (>=2023.7.26)", "pre-commit-uv (>=4.1.4)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinx-autodoc-typehints (>=3.6.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2025.12.21.14)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest-xdist (>=3.5)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["docs"] +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "wcwidth" @@ -3084,19 +3345,51 @@ files = [ [[package]] name = "xmltodict" -version = "1.0.2" +version = "1.0.4" description = "Makes working with XML feel like you are working with JSON" optional = false python-versions = ">=3.9" groups = ["dev", "test"] files = [ - {file = "xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d"}, - {file = "xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649"}, + {file = "xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a"}, + {file = "xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61"}, ] [package.extras] test = ["pytest", "pytest-cov"] +[[package]] +name = "zensical" +version = "0.0.23" +description = "A modern static site generator built by the creators of Material for MkDocs" +optional = false +python-versions = ">=3.10" +groups = ["docs"] +files = [ + {file = "zensical-0.0.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35d6d3eb803fe73a67187a1a25443408bd02a8dd50e151f4a4bafd40de3f0928"}, + {file = "zensical-0.0.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5973267460a190f348f24d445ff0c01e8ed334fd075947687b305e68257f6b18"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:953adf1f0b346a6c65fc6e05e6cc1c38a6440fec29c50c76fb29700cc1927006"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49c1cbd6131dafa056be828e081759184f9b8dd24b99bf38d1e77c8c31b0c720"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b7fe22c5d33b2b91899c5df7631ad4ce9cccfabac2560cc92ba73eafe2d297"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a3679d6bf6374f503afb74d9f6061da5de83c25922f618042b63a30b16f0389"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54d981e21a19c3dcec6e7fa77c4421db47389dfdff20d29fea70df8e1be4062e"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:afde7865cc3c79c99f6df4a911d638fb2c3b472a1b81367d47163f8e3c36f910"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c484674d7b0a3e6d39db83914db932249bccdef2efaf8a5669671c66c16f584d"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:927d12fe2851f355fb3206809e04641d6651bdd2ff4afe9c205721aa3a32aa82"}, + {file = "zensical-0.0.23-cp310-abi3-win32.whl", hash = "sha256:ffb79db4244324e9cc063d16adff25a40b145153e5e76d75e0012ba3c05af25d"}, + {file = "zensical-0.0.23-cp310-abi3-win_amd64.whl", hash = "sha256:a8cfe240dca75231e8e525985366d010d09ee73aec0937930e88f7230694ce01"}, + {file = "zensical-0.0.23.tar.gz", hash = "sha256:5c4fc3aaf075df99d8cf41b9f2566e4d588180d9a89493014d3607dfe50ac4bc"}, +] + +[package.dependencies] +click = ">=8.1.8" +deepmerge = ">=2.0" +markdown = ">=3.7" +pygments = ">=2.16" +pymdown-extensions = ">=10.15" +pyyaml = ">=6.0.2" +tomli = {version = ">=2.0", markers = "python_full_version < \"3.11.0\""} + [[package]] name = "zipp" version = "3.23.0" @@ -3120,4 +3413,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.12" -content-hash = "08ea1eedf25a896fdc21f03d04f4403d47d655fc90eb5eb310ff7cde7e3b7a6d" +content-hash = "7d4c014f794bf1e5125e697c4eab04f07961e7d77ae680377a6ddc984ba4d33b" diff --git a/pyproject.toml b/pyproject.toml index 6036c9e..0b2116f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,15 @@ types-setuptools = "68.2.0.0" types-urllib3 = "1.26.25.14" types-xmltodict = "0.13.0.3" +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +click = "8.2.1" +mkdocs = "^1.6.1" +mkdocstrings = { version = "^1.0.3", extras = ["python"] } +zensical = "~=0.0.23" + [tool.ruff] line-length = 100 diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..f7a3731 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,194 @@ +[project] +site_name = "Data Validation Engine" +site_description = "Documentation for using the Data Validation Engine (DVE)." +site_author = "NHS England" +site_url = "https://nhsdigital.github.io/data-validation-engine/" +copyright = """ + +""" +nav = [ + {"User Guidance" = [ + "index.md", + {"Installation" = "user_guidance/install.md"}, + {"Getting Started" = "user_guidance/getting_started.md"}, + {"Auditing" = "user_guidance/auditing.md"}, + {"Creating a Dischema" = [ + {"File Transformation" = "user_guidance/file_transformation.md"}, + {"Data Contract" = "user_guidance/data_contract.md"}, + {"Business Rules" = "user_guidance/business_rules.md"}, + {"Feedback Messages" = "user_guidance/feedback_messages.md"}, + ]}, + {"Backend Implementations" = [ + {"DuckDB" = "user_guidance/implementations/duckdb.md"}, + {"Spark" = "user_guidance/implementations/spark.md"}, + {"Platform Specific Implementations" = [ + {"Databricks" = "user_guidance/implementations/platform_specific/databricks.md"}, + {"Palantir Foundry" = "user_guidance/implementations/platform_specific/palantir_foundry.md"}, + ]}, + ]}, + ]}, + {"Advanced User Guidance" = [ + "advanced_guidance/index.md", + {"DVE Package Documentation" = [ + "advanced_guidance/package_documentation/index.md", + {"Pipeline" = "advanced_guidance/package_documentation/pipeline.md"}, + {"Refdata Loaders" = "advanced_guidance/package_documentation/refdata_loaders.md"}, + ]}, + {"DVE Developer Guidance" = [ + {"Implementing a new backend" = "advanced_guidance/new_backend.md"}, + {"Dischema Language Server" = "advanced_guidance/json_schemas.md"}, + ]}, + ]} +] +extra_css = ["assets/stylesheets/extra.css"] +# extra_javascript = ["assets/javascript/extra.js"] +repo_url = "https://github.com/NHSDigital/data-validation-engine" +repo_name = "Data Validation Engine" + +# ---------------------------------------------------------------------------- +# Section for configuring theme options +# ---------------------------------------------------------------------------- +[project.theme] +variant = "classic" +custom_dir = "overrides" +logo = "assets/images/favicon.svg" +favicon = "assets/images/favicon.ico" +language = "en" +features = [ + "content.action.edit", + "content.code.annotate", + "content.code.copy", + "content.code.select", + # "content.footnote.tooltips", + "content.tabs.link", + # "content.tooltips", + # "header.autohide", + # "navigation.expand", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.instant.preview", + "navigation.instant.progress", + "navigation.path", + #"navigation.prune", + "navigation.sections", + "navigation.tabs", + #"navigation.tabs.sticky", + "navigation.top", + # "navigation.tracking", + "search.highlight", + "toc.follow", + "toc.integrate", +] + +# ---------------------------------------------------------------------------- +# In the "palette" subsection you can configure options for the color scheme. +# You can configure different color # schemes, e.g., to turn on dark mode, +# that the user can switch between. Each color scheme can be further +# customized. +# +# Read more: +# - https://zensical.org/docs/setup/colors/ +# ---------------------------------------------------------------------------- +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "material/brightness-auto" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +toggle.icon = "material/brightness-7" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +toggle.icon = "material/brightness-4" +toggle.name = "Switch to system preference" + +# ---------------------------------------------------------------------------- +# In the "font" subsection you can configure the fonts used. By default, fonts +# are loaded from Google Fonts, giving you a wide range of choices from a set +# of suitably licensed fonts. There are options for a normal text font and for +# a monospaced font used in code blocks. +# ---------------------------------------------------------------------------- +[project.theme.font] +text = "Inter" +code = "Jetbrains Mono" + +# ---------------------------------------------------------------------------- +# The "extra" section contains miscellaneous settings. +# ---------------------------------------------------------------------------- + +[project.extra.consent] +title = "Cookie consent" +description = """ + We use cookies to recognize your repeated visits and preferences, as well + as to measure the effectiveness of our documentation and whether users + find what they're searching for. With your consent, you're helping us to + make our documentation better. +""" + +[[project.extra.social]] +icon = "nhseng" +link = "https://www.england.nhs.uk/" +name = "NHS England Website" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/NHSDigital" +name = "NHS Digital GitHub" + +# ---------------------------------------------------------------------------- +# Markdown Extensions +# ---------------------------------------------------------------------------- + +[project.markdown_extensions.abbr] +[project.markdown_extensions.admonition] +[project.markdown_extensions.attr_list] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.pymdownx.details] + +[project.markdown_extensions.pymdownx.emoji] +emoji_index = "zensical.extensions.emoji.twemoji" +emoji_generator = "zensical.extensions.emoji.to_svg" +options.custom_icons = ["overrides/.icons"] + +[project.markdown_extensions.pymdownx.highlight] +[project.markdown_extensions.pymdownx.inlinehilite] + +[project.markdown_extensions.pymdownx.snippets] +auto_append = ["includes/jargon_and_acronyms.md"] + +[project.markdown_extensions.pymdownx.superfences] + +[project.markdown_extensions.pymdownx.tabbed] +alternate_style = true + +[project.markdown_extensions.pymdownx.tabbed.slugify] +object = "pymdownx.slugs.slugify" +kwds = { case = "lower" } + +[project.markdown_extensions.toc] +permalink = true + +[project.markdown_extensions.zensical.extensions.preview] + +# ---------------------------------------------------------------------------- +# Plugins +# ---------------------------------------------------------------------------- + +[project.plugins.mkdocstrings.handlers.python] +paths = ["src/dve"] +inventories = ["https://docs.python.org/3/objects.inv"] From a02c3bd4e2747857887305284a01ffe0cae8fbcb Mon Sep 17 00:00:00 2001 From: "george.robertson1" <50412379+georgeRobertson@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:16:53 +0000 Subject: [PATCH 2/5] docs: further wip docs --- .../package_documentation/readers.md | 87 +++++++++ docs/index.md | 6 +- docs/user_guidance/auditing.md | 36 +++- docs/user_guidance/file_transformation.md | 168 +++++++++++++++++- docs/user_guidance/getting_started.md | 16 +- docs/user_guidance/install.md | 10 +- .../implementations/duckdb/readers/csv.py | 1 + zensical.toml | 1 + 8 files changed, 306 insertions(+), 19 deletions(-) create mode 100644 docs/advanced_guidance/package_documentation/readers.md diff --git a/docs/advanced_guidance/package_documentation/readers.md b/docs/advanced_guidance/package_documentation/readers.md new file mode 100644 index 0000000..6a944d3 --- /dev/null +++ b/docs/advanced_guidance/package_documentation/readers.md @@ -0,0 +1,87 @@ +## CSV + +=== "Base" + + ::: src.dve.core_engine.backends.readers.csv.CSVFileReader + options: + heading_level: 3 + merge_init_into_class: true + members: false + +=== "DuckDB" + + ::: src.dve.core_engine.backends.implementations.duckdb.readers.csv.DuckDBCSVReader + options: + heading_level: 3 + members: + - __init__ + + ::: src.dve.core_engine.backends.implementations.duckdb.readers.csv.PolarsToDuckDBCSVReader + options: + heading_level: 3 + members: + - __init__ + + ::: src.dve.core_engine.backends.implementations.duckdb.readers.csv.DuckDBCSVRepeatingHeaderReader + options: + heading_level: 3 + members: + - __init__ + +=== "Spark" + + ::: src.dve.core_engine.backends.implementations.spark.readers.csv.SparkCSVReader + options: + heading_level: 3 + members: + - __init__ + +## JSON + +=== "DuckDB" + + ::: src.dve.core_engine.backends.implementations.duckdb.readers.json.DuckDBJSONReader + options: + heading_level: 3 + members: + - __init__ + +=== "Spark" + + ::: src.dve.core_engine.backends.implementations.spark.readers.json.SparkJSONReader + options: + heading_level: 3 + members: + - __init__ + +## XML + +=== "Base" + + ::: src.dve.core_engine.backends.readers.xml.BasicXMLFileReader + options: + heading_level: 3 + merge_init_into_class: true + members: false + +=== "DuckDB" + + ::: src.dve.core_engine.backends.implementations.duckdb.readers.xml.DuckDBXMLStreamReader + options: + heading_level: 3 + members: + - __init__ + +=== "Spark" + + ::: src.dve.core_engine.backends.implementations.spark.readers.xml.SparkXMLStreamReader + options: + heading_level: 3 + members: + - __init__ + + ::: src.dve.core_engine.backends.implementations.spark.readers.xml.SparkXMLReader + options: + heading_level: 3 + members: + - __init__ diff --git a/docs/index.md b/docs/index.md index c4c60f5..dea5827 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ tags: # Data Validation Engine -The Data Validation Engine (DVE) is a configuration driven data validation library written in [Python](https://www.python.org/), [Pydantic](https://docs.pydantic.dev/latest/) and a SQL backend currently consisting of [DuckDB](https://duckdb.org/) or [Spark](https://spark.apache.org/sql/). The configuration to run validations against a dataset are defined and written in a json document, which we will be referring to as the "dischema". The rules written within the dischema are designed to be run against all incoming data in a given submision - as this allows the DVE to capture all possible issues with the data without the submitter having resubmit the same data repeatedly which is burdensome and time consuming for the submitter and receiver of the data. Additionally, the rules can be configured to have the following behaviour: +The Data Validation Engine (DVE) is a configuration driven data validation library written in [Python](https://www.python.org/), [Pydantic](https://docs.pydantic.dev/latest/) and a SQL backend currently consisting of [DuckDB](https://duckdb.org/) or [Spark](https://spark.apache.org/sql/). The configuration to run validations against a dataset are defined and written in a json document, which we will be referring to as the "dischema". The rules written within the dischema are designed to be run against all incoming data in a given submission - as this allows the DVE to capture all possible issues with the data without the submitter having resubmit the same data repeatedly which is burdensome and time consuming for the submitter and receiver of the data. Additionally, the rules can be configured to have the following behaviour: - **File Rejection** - The entire submission will be rejected if the given rule triggers one or more times. - **Row Rejection** - The row that triggered the rule will be rejected. Rows that pass the validation will be flowed through into a validated entity. @@ -30,9 +30,9 @@ The DVE has 3 core components: 3. [Business rules](user_guidance/business_rules.md) - Performs simple and complex validations such as comparisons between fields, entities and/or lookups against reference data. -For each component listed above, a [feedback message](user_guidance/feedback_messages.md) is generated whenever a rule is violated. These [feedback messages](user_guidance/feedback_messages.md) can be interegated directly into your system given you can consume `jsonl` files. Alternatively, we offer a fourth component called the [Error Reports](user_guidance/error_reports.md). This component will load the [feedback messages](user_guidance/feedback_messages.md) into an `.xlsx` (Excel) file which could be sent back to the submitter of the data. The excel file is compatiable with services that offer spreadsheet reading such as [Microsoft Excel](https://www.microsoft.com/en/microsoft-365/excel), [Google Docs](https://docs.google.com/), [Libre Office Calc](https://www.libreoffice.org/discover/calc/) etc. +For each component listed above, a [feedback message](user_guidance/feedback_messages.md) is generated whenever a rule is violated. These [feedback messages](user_guidance/feedback_messages.md) can be integrated directly into your system given you can consume `JSONL` files. Alternatively, we offer a fourth component called the [Error Reports](user_guidance/error_reports.md). This component will load the [feedback messages](user_guidance/feedback_messages.md) into an `.xlsx` (Excel) file which could be sent back to the submitter of the data. The excel file is compatible with services that offer spreadsheet reading such as [Microsoft Excel](https://www.microsoft.com/en/microsoft-365/excel), [Google Docs](https://docs.google.com/), [Libre Office Calc](https://www.libreoffice.org/discover/calc/) etc. -To be able to run the DVE out of the box, you can have look at the Backend Implementations sections with [DuckDB](user_guidance/implementations/duckdb.md) or [Spark](user_guidance/implementations/spark.md). If you to need a write a custom backend implementation, you may want to look at the [Advanced User Guidance](advanced_guidance/backends.md) section. +To be able to run the DVE out of the box, you will need to choose and install one of the supported Backend Implementations such as [DuckDB](user_guidance/implementations/duckdb.md) or [Spark](user_guidance/implementations/spark.md). If you to need a write a custom backend implementation, you may want to look at the [Advanced User Guidance](advanced_guidance/backends.md) section. Feel free to use the Table of Contents on the left hand side of the page to navigate to sections of interest or to use the "Next" and "Previous" buttons at the bottom of each page if you want to read through each page in sequential order. diff --git a/docs/user_guidance/auditing.md b/docs/user_guidance/auditing.md index 1c63d00..2ae9b54 100644 --- a/docs/user_guidance/auditing.md +++ b/docs/user_guidance/auditing.md @@ -1,2 +1,34 @@ -!!! note - This section has not yet been written. Coming soon. +--- +tags: + - Auditing +--- + +The Auditing objects within the DVE are used to help control and store information about a given submission and what stage it's currently at. In addition to the above, it's also used to store statistics about the submission and the number of validations it has triggered etc. So, for users not interested in using the Error reports stage, you could source information directly from the audit tables. + +## Audit Tables +Currently, these are the audit tables that can be accessed within the DVE: + +| Table Name | Purpose | +| --------------------- | ------- | +| processing_status | Contains information about the submission and what the current processing status is. | +| submission_info | Contains information about the submitted file. | +| submission_statistics | Contains validation statistics for each submission. | + +## Audit Objects + +You can use the the following methods to help you interact with the tables above or you can query the table via `sql`. + +
    + +::: src.dve.core_engine.backends.base.auditing.BaseAuditingManager + options: + heading_level: 3 + members: + - get_submission_info + - get_submission_statistics + - get_submission_status + - get_all_file_transformation_submissions + - get_all_data_contract_submissions + - get_all_business_rule_submissions + - get_all_error_report_submissions + - get_current_processing_info diff --git a/docs/user_guidance/file_transformation.md b/docs/user_guidance/file_transformation.md index 1c63d00..89013ef 100644 --- a/docs/user_guidance/file_transformation.md +++ b/docs/user_guidance/file_transformation.md @@ -1,2 +1,166 @@ -!!! note - This section has not yet been written. Coming soon. +--- +title: File Transformation +tags: + - Contract + - Data Contract + - File Transformation + - Readers +--- + +The File Transformation stage within the DVE is used to convert submitted files to stringified parquet format. This is critical as the rest of the stages within the DVE are reliant on the data being in parquet format. [Parquet was choosen as it's a very efficient column oriented format](https://www.databricks.com/glossary/what-is-parquet). When specifying which formats you are expecting, you will define it in your dischema like this: + +=== "DuckDB" + + ```json + { + "contract": { + "datasets": { + "": { + "fields": { + ... + }, + }, + "reader_config": { + ".json": { + "reader": "DuckDBJSONReader", + "kwargs": { + ... + } + }, + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + ... + } + } + } + } + } + } + ``` + +=== "Spark" + + ```json + { + "contract": { + "datasets": { + "": { + "fields": { + ... + }, + }, + "reader_config": { + ".csv": { + "reader": "SparkCSVReader", + "kwargs": { + ... + } + }, + ".json": { + "reader": "SparkJSONReader", + "kwargs": { + ... + } + } + } + } + } + } + ``` + +The secondary use of the File Transformation stage is the ability to normalise your data into multiple entities. Imagine you had something like Hospital and Patient data in a single submission. You could split this out into seperate entities so that the validated outputs of the data could be loaded into seperate tables. For example: + +=== "DuckDB" + + ```json + { + "contract": { + "datasets": { + "hospital": { + "fields": { + "hospital_id": "int", + "hospital_name": "string" + }, + "reader_config": { + ".json": { + "reader": "DuckDBJSONReader", + "kwargs": { + "encoding": "utf-8", + "multi_line": true, + } + } + } + }, + "patients": { + "fields": { + "patient_id": "int", + "patient_name": "string" + }, + "reader_config": { + ".json": { + "reader": "DuckDBJSONReader", + "kwargs": { + "encoding": "utf-8", + "multi_line": true, + } + } + } + } + } + } + } + ``` + + +=== "Spark" + + ```json + { + "contract": { + "datasets": { + "hospital": { + "fields": { + "hospital_id": "int", + "hospital_name": "string" + }, + "reader_config": { + ".json": { + "reader": "SparkJSONReader", + "kwargs": { + "encoding": "utf-8", + "multi_line": true, + } + } + } + }, + "patients": { + "fields": { + "patient_id": "int", + "patient_name": "string" + }, + "reader_config": { + ".json": { + "reader": "SparkJSONReader", + "kwargs": { + "encoding": "utf-8", + "multi_line": true, + } + } + } + } + } + } + } + ``` + +!!! abstract "" + You can read more about the readers and kwargs [here](../advanced_guidance/package_documentation/readers.md). + +## Supported Formats + +| Format | DuckDB | Spark | Version Available | +| ------- | ------------------ | ------------------ | ----------------- | +| `.csv` | :white_check_mark: | :white_check_mark: | >= 0.1.0 | +| `.json` | :white_check_mark: | :white_check_mark: | >= 0.1.0 | +| `.xml` | :white_check_mark: | :white_check_mark: | >= 0.1.0 | diff --git a/docs/user_guidance/getting_started.md b/docs/user_guidance/getting_started.md index c790b48..8b3965e 100644 --- a/docs/user_guidance/getting_started.md +++ b/docs/user_guidance/getting_started.md @@ -9,9 +9,10 @@ tags: ## Rules Configuration Introduction -To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `data contract` or `contract` - this describes what a *"perfect"* version of your data should look like after type casting the data. For example, here is a document describing how the DVE might validate data about a movies dataset: +To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `contract` (data contract) - this describes the structure of your data and controls how the data should be typecasted. For example, here is a dischema document describing how the DVE might validate data about a movies dataset: !!! example "Example `movies.dischema.json`" + ```json { "contract": { @@ -62,17 +63,18 @@ Within the example above, there are two parent keys - `schemas` and `datasets`. `schemas` allow you to define custom complex data types. So, in the example above, the field `cast` would be expecting an array of structs containing the actors name, role and the date they joined the movie. -`datasets` describe the actual models for the entities you want to load. In the example above, we only want to load a single entity called `movies` which contains the fields `title, year, genre, duration_minutes, ratings and cast`. However, you could load the complex type `cast` into a seperate entity if you wanted to split your data into seperate entities. This can be useful in situations where a given entity has all the information you need to perform a given validation rule against, making the performance of rule faster & more efficient as there is less data to scan in a given entity. +`datasets` describe the actual models for the entities you want to load. In the example above, we only want to load a single entity called `movies` which contains the fields `title, year, genre, duration_minutes, ratings and cast`. However, you could load the complex type `cast` into a seperate entity if you wanted to split your data into seperate entities. This can be useful in situations where a given entity has all the information you need to perform a given validation rule against, making the performance of rule faster & more efficient as there's less data to scan in a given entity. !!! note The "splitting" of entities is considerably more useful in situtations where you want to normalise/de-normalise your data. If you're unfamiliar with this concept, you can read more about it [here](https://en.wikipedia.org/wiki/Database_normalization). However, you should keep in mind potential performance impacts of doing this. If you have rules that requires fields from different entities, you will have to perform a `join` between the split entities to be able to perform the rule. -For each dataset definition, you will need to provide a `reader_config` which describes how to load the data during the [File Transformation](file_transformation.md) stage. So, in the example above, we expect `movies` to come in as a `json` file. However, you can add more readers if you have the same data in different data formats (e.g. `csv`, `xml`, `json`). Regardless, of what they submit, the [File Transformation](file_transformation.md) stage will turn their submissions into a consistent "stringified" parquet format which is a requirement for the subsequent stages. +For each dataset definition, you will need to provide a `reader_config` which describes how to load the data during the [File Transformation](file_transformation.md) stage. So, in the example above, we expect `movies` to come in as a `JSON` file. However, you can add more readers if you have the same data in different data formats (e.g. `csv`, `xml`, `json`). Regardless, of what they submit, the [File Transformation](file_transformation.md) stage will turn their submissions into a "stringified" parquet format which is a requirement for the subsequent stages. To learn more about how you can construct your Data Contract please read [here](data_contract.md). -The second part of the dischema are the `business_rules` *or* `tranformations`. This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you have choosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). +The second part of the dischema are the `business_rules` *or* `tranformations`. This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you've choosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). !!! example "Example `movies.dischema.json`" + ```json { "transformations": { @@ -88,11 +90,11 @@ The second part of the dischema are the `business_rules` *or* `tranformations`. } } ``` -You may look at the expression above and think "Hang on! That's the opposite of what you want! You're only getting movies less than 4 hours!", however, the validation rules are wrapped inside a `NOT` expression. So, you write the rules as though you are looking for non problematic values. +You may look at the expression above and think "Hang on! That's the opposite of what you want! You're only getting movies less than 4 hours!", however, all validation rules are wrapped inside a `NOT` expression. So, you write the rules as though you are looking for non problematic values. -We also offer a feature called `complex_rules`. These are rules where you need to transform the data before you can apply the rule. For instance, you may want to perform a join, aggregate the data, or perform a filter. The complex rules allow you to do this and you can combine them as well before you perform the validation. +We also offer a feature called `complex_rules`. These are rules where you need to transform the data before you can apply the rule. For instance, you may want to perform a join, aggregate the data, or perform a filter. The complex rules allow you to combine "pre-steps" before you perform the validation. -To learn more about how to write your validation rules and complex validation rules, please follow the guidance written [here](business_rules.md). +To learn more about how to write your validation rules and complex validation rules, please follow the guidance [here](business_rules.md). ## Utilising the Pipeline objects to run the DVE diff --git a/docs/user_guidance/install.md b/docs/user_guidance/install.md index 34e1f3a..e476728 100644 --- a/docs/user_guidance/install.md +++ b/docs/user_guidance/install.md @@ -6,13 +6,13 @@ tags: --- !!! warning - **DVE is currently an unstable package. Expect breaking changes between every minor patch**. We intend to follow semantic versioning of `major.minor.patch` more strictly after a 1.0 release. Until then, we recommend that you pin your install to the latest version available and keep an eye on [future releases](https://github.com/NHSDigital/data-validation-engine/releases) that will have changelogs provided with each release. + **DVE is currently an unstable package. Expect breaking changes between every minor patch**. We intend to follow semantic versioning of `major.minor.patch` more strictly after a 1.0 release. Until then, we recommend that you pin your install to the latest version available and keep an eye on [future releases](https://github.com/NHSDigital/data-validation-engine/releases). **Please note that we only support Python runtimes of 3.10 and 3.11.** In the future we will look to add support for Python versions greater than 3.11, but it's not an immediate priority. If working on Python 3.7, the `0.1` release supports this (and only this) version of Python. However, we have not been updating that version with any bugfixes, performance improvements etc. There are also a number of vulnerable dependencies on version `0.1` release due to [Python 3.7 being depreciated](https://devguide.python.org/versions/) and a number of packages dropping support. **If you choose to install `0.1`, you accept the risks of doing so and additional support will not be provided.** -You can install the DVE package through python package managers such as [pip](https://pypi.org/project/pip/), [pipx](https://github.com/pypa/pipx), [uv](https://docs.astral.sh/uv/) and [poetry](https://python-poetry.org/). See examples below for installing the DVE: +You can install the DVE package through python package managers such as [pip](https://pypi.org/project/pip/), [pipx](https://github.com/pypa/pipx), [uv](https://docs.astral.sh/uv/) and [poetry](https://python-poetry.org/). === "pip" @@ -75,11 +75,11 @@ You can install the DVE package through python package managers such as [pip](ht Replace `Maj.Min.Pat` with the version of the DVE you want. We recommend the latest release if you're just starting with the DVE. !!! info - We are working on getting the DVE available via PyPi and Conda. We will update this page with the relevant instructions once this has been succesfully setup. + We are working on getting the DVE available via PyPi and Conda. We will update this page with the relevant instructions once this has been successfully setup. -Python dependencies are listed in `pyproject.toml` [(here)](https://github.com/NHSDigital/data-validation-engine/blob/main/pyproject.toml). Many of the dependencies are locked to quite restrictive versions due to complexity of this package. Core packages such as Pydantic, Pyspark and DuckDB are unlikely to receive flexible version constraints as changes in those packages could cause the DVE to malfunction. For less important dependencies, we have tried to make the contraints more flexible. Therefore, we would advise you to install the DVE into a seperate environment rather than trying to integrate it into an existing Python environment. +Python dependencies are listed in the [`pyproject.toml`](https://github.com/NHSDigital/data-validation-engine/blob/main/pyproject.toml). Many of the dependencies are locked to quite restrictive versions due to complexity of this package. Core packages such as Pydantic, Pyspark and DuckDB are unlikely to receive flexible version constraints as changes in those packages could cause the DVE to malfunction. For less important dependencies, we have tried to make the contraints more flexible. Therefore, we would advise you to install the DVE into a seperate environment rather than trying to integrate it into an existing Python environment. -Once you have installed the DVE you are almost ready to use it. To be able to run the DVE, you will need to choose one of the supported pipeline runners (see Backend implementations here - [DuckDB](user_guidance/implementations/duckdb.md) *or* [Spark](user_guidance/implementations/spark.md)) and you will need to create your own dischema document to configure how the DVE should validate incoming data. You can read more about this in [Getting Started](getting_started.md) page. +Once you have installed the DVE you are almost ready to use it. To be able to run the DVE, you will need to choose one of the supported pipeline runners (see Backend implementations here - [DuckDB](user_guidance/implementations/duckdb.md) *or* [Spark](user_guidance/implementations/spark.md)) and you will need to create your own dischema document to configure how the DVE should validate incoming data. You can read more about this in the [Getting Started](getting_started.md) page. ## DVE Version Compatability Matrix diff --git a/src/dve/core_engine/backends/implementations/duckdb/readers/csv.py b/src/dve/core_engine/backends/implementations/duckdb/readers/csv.py index ff65d9f..8c474f4 100644 --- a/src/dve/core_engine/backends/implementations/duckdb/readers/csv.py +++ b/src/dve/core_engine/backends/implementations/duckdb/readers/csv.py @@ -169,6 +169,7 @@ class DuckDBCSVRepeatingHeaderReader(PolarsToDuckDBCSVReader): `NonDistinctHeaderError`. So using the example above, the expected entity would look like this... + | headerCol1 | headerCol2 | headerCol3 | | ---------- | ---------- | ---------- | | shop1 | clothes | 2025-01-01 | diff --git a/zensical.toml b/zensical.toml index f7a3731..b93f45d 100644 --- a/zensical.toml +++ b/zensical.toml @@ -42,6 +42,7 @@ nav = [ "advanced_guidance/package_documentation/index.md", {"Pipeline" = "advanced_guidance/package_documentation/pipeline.md"}, {"Refdata Loaders" = "advanced_guidance/package_documentation/refdata_loaders.md"}, + {"Readers" = "advanced_guidance/package_documentation/readers.md"}, ]}, {"DVE Developer Guidance" = [ {"Implementing a new backend" = "advanced_guidance/new_backend.md"}, From f63499c25a6310da69380ea43fa0f876baba6d14 Mon Sep 17 00:00:00 2001 From: "george.robertson1" <50412379+georgeRobertson@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:57:19 +0000 Subject: [PATCH 3/5] docs: added auto docs --- .../package_documentation/auditing.md | 15 +- .../package_documentation/domain_types.md | 6 + .../feedback_messages.md | 12 + .../package_documentation/operations.md | 6 + .../refence_data_types.md | 18 + docs/assets/images/doc_images/error_data.png | Bin 0 -> 37301 bytes .../images/doc_images/error_summary.png | Bin 0 -> 36656 bytes .../assets/images/doc_images/summary_view.png | Bin 0 -> 48190 bytes docs/user_guidance/auditing.md | 15 +- docs/user_guidance/business_rules.md | 591 +++++++++++++++++- docs/user_guidance/data_contract.md | 451 ++++++++++++- docs/user_guidance/error_reports.md | 31 +- docs/user_guidance/feedback_messages.md | 33 +- docs/user_guidance/getting_started.md | 2 +- includes/jargon_and_acronyms.md | 3 + zensical.toml | 24 +- 16 files changed, 1181 insertions(+), 26 deletions(-) create mode 100644 docs/advanced_guidance/package_documentation/domain_types.md create mode 100644 docs/advanced_guidance/package_documentation/feedback_messages.md create mode 100644 docs/advanced_guidance/package_documentation/operations.md create mode 100644 docs/advanced_guidance/package_documentation/refence_data_types.md create mode 100644 docs/assets/images/doc_images/error_data.png create mode 100644 docs/assets/images/doc_images/error_summary.png create mode 100644 docs/assets/images/doc_images/summary_view.png diff --git a/docs/advanced_guidance/package_documentation/auditing.md b/docs/advanced_guidance/package_documentation/auditing.md index 1c63d00..a022a64 100644 --- a/docs/advanced_guidance/package_documentation/auditing.md +++ b/docs/advanced_guidance/package_documentation/auditing.md @@ -1,2 +1,13 @@ -!!! note - This section has not yet been written. Coming soon. + +::: src.dve.core_engine.backends.base.auditing.BaseAuditingManager + options: + heading_level: 3 + members: + - get_submission_info + - get_submission_statistics + - get_submission_status + - get_all_file_transformation_submissions + - get_all_data_contract_submissions + - get_all_business_rule_submissions + - get_all_error_report_submissions + - get_current_processing_info diff --git a/docs/advanced_guidance/package_documentation/domain_types.md b/docs/advanced_guidance/package_documentation/domain_types.md new file mode 100644 index 0000000..8fefe9b --- /dev/null +++ b/docs/advanced_guidance/package_documentation/domain_types.md @@ -0,0 +1,6 @@ + +::: dve.metadata_parser.domain_types + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/advanced_guidance/package_documentation/feedback_messages.md b/docs/advanced_guidance/package_documentation/feedback_messages.md new file mode 100644 index 0000000..ede7aad --- /dev/null +++ b/docs/advanced_guidance/package_documentation/feedback_messages.md @@ -0,0 +1,12 @@ + +::: dve.core_engine.message.FeedbackMessage + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.core_engine.message.UserMessage + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/advanced_guidance/package_documentation/operations.md b/docs/advanced_guidance/package_documentation/operations.md new file mode 100644 index 0000000..8c3fb6e --- /dev/null +++ b/docs/advanced_guidance/package_documentation/operations.md @@ -0,0 +1,6 @@ + +::: dve.core_engine.backends.metadata.rules + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/advanced_guidance/package_documentation/refence_data_types.md b/docs/advanced_guidance/package_documentation/refence_data_types.md new file mode 100644 index 0000000..c5ad0ad --- /dev/null +++ b/docs/advanced_guidance/package_documentation/refence_data_types.md @@ -0,0 +1,18 @@ + +::: dve.core_engine.backends.base.reference_data.ReferenceTable + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.core_engine.backends.base.reference_data.ReferenceFile + handler: python + options: + show_root_heading: true + heading_level: 2 + +::: dve.core_engine.backends.base.reference_data.ReferenceURI + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/assets/images/doc_images/error_data.png b/docs/assets/images/doc_images/error_data.png new file mode 100644 index 0000000000000000000000000000000000000000..c8fb19345e789baf769167213f4a817c12d476fa GIT binary patch literal 37301 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
    U0;M(!K?#i~73CyCyAn9^z9s?YwFR;R7 zEP))g3FVJ-GOe&*VjM;~Y;^fV5`LxY`P)_oC2uyu0PDN+BO#{yELLar zt9SKZ178RNPbEdrDHVQs_5K~yI@u%LI*v0EQ*nsu)i`EsY_x!n{MnoPZntI&0$%94->dzucaS7oGoF1nI37 z_{C`cSS#}G&uI|c4r9UfEU<58k z-=G+nA7CTFfeO{VUnR&5TmhM!RH$oc2tp8k?XTgx51{-1Ck3(_5)Amg09yx`26Xuqc+w^TFSzbv;aH+-7Hd z`@O|;8sFltP6>^cCdG_Izk3f7Z_XPHobPw{DeP8Z1UO4}?pTC7DVgob2`MWpA#0`j zi=_Yp9-l@f>r9daI`E_dj2BJX>3$lzBhQFVJk^FKuL`* zM-t?QM!K#Ro$k9~9S=hM%+K}BCo|Vbeo+W4@$)8)72XFQ@2kv6%ZTa{H5q82(-T%| zl^DY`m;9{9K4~0v zG^@SufU}qw)Fdg^9=F<SXE{<*&G= zkZi4)I!&A=@hM!Y%#FgN2BmZ-ZeA9T3y*pIRZ;esxmU?`aIhW=V zSf{O)#w<5X@&amM0mP-jL+NeGjdj+jkhVLbpCrQJ&!Vol_72&axUMw|_3Elv*+$E= z@Y}eSTvx-pmyVpn@8QL>6Z1Jgiz?~}88691LRPqdByjeFI{&f$af_WWcO5kLeWK<3 zMVmM3p;d+Hkk|YjgmU{SO}^1#Tlh%y*X^9)>@{UN%u29Mi19ND(gROQhWG3=zJsAr zCbN#K1_D~H>sqa5nNES5v^WMUI1nYu58y6>CVQzSn}3kE zVEI#<0~<=?5bbhaH1L+63vU_&Bn)w}K@kIQmSbb%G)@nWb%Q3W!~bfSlpra=*) zk34y^j1C}%>XB@AsDl1jY0B#J=(`p;c3{ZPL7&$txVbkRoO>gwCf2)WnRl;Ex9BcZ zNg?am{H-;2^f>O5s-!xwK!wYCmL+uPC6DfRVFsryh6o}G!_h%!9xZz;sl3(Zcru+U z+9k_3YG&*wax3r-K1JGhE3a8O!$iqBC8(r?`$IMtjmKOeWZ;O)bk1G2u4snJ1I$lw zYg3Jqnbr{`bvCY`-I@-=7Gyb*9?|msY=wOq0zoZSQl}YGttS#eP4rgKB}u)V11gtjo>P@MDV&*STaV(oF*;sY3!MsF!xbF!D2vN#>ONs6=o&A{&akX^ zYcID#l*yB(lS$WPp>w_*x&XgrMA!sHy~V%VFdJLdSyPPV80Rq?sMFAUX;vMMBKN+>R@&q;1+zOui)JFLd z5S8ZESbjju2^AK7`y^gOOgSl;F<8OTw-1Hr`5-1UI>!$?A4UHl?fK4VEk;W~W1e1T zqAhw8yrP+9A=RIUyd_VYO#M+Qz%{=^7aV*lEqEiwfp>bZVZL{2y3i14kI3#i$0Z+K zmQR(VK~LXJt}rIe2lIAE(nM+0bw8+^j2n3#5(QZ@c`A_f!x$cTH5gcqn@cZNj=l2n z`NT>>tW=98$sKg*8!TvnN6Kkd5Y)G=*&V*TKt^d_r;fI!ERtm0(N)2GmGSZ1XH9Z1 zRMMY@LN%b4dzL7L#4!{;78frkE_kOSC0P~~J(I_uZ6=EJN$UfL*@DvNx_YaJ== z6v>f*rT0)1Bi<;zqvPE!v2mF{7RQ+kz;uXH;mKyXG`Z4~Fu?VLRaUeR>>o3JEdIli zj!Mx>snDD>U=+c*%KbM$&P^$J2%=V4C@Htwkj{bpX1%p!+br|!@0xVE-2X8kFLu=u zNYXy_pK_C)!|8cZGc(7!TDikK8uzqWv%8o$m|^3-*N_8;hJ1ZpwvWeT&v7R!PvUa8 z$J{f%h3ywAoIU8)p-&u&+64gmpn*b04&e0U&W5$2a;K5p^52+0?rTR?0N{TT^4?G2 zcTSNdnFL6BThJhzqAl61qeFJv{-7^{t3&$4ENT%eK)yuip+3nzj3>=C&T)j4GcF#H z!k3N${B=TNbHvNI@v6cR+?x1i@wqFyCJ(eDId2J{6Y9jQz)d*W!|Zg++(wq|B+WKb zra^!$fr<{jZ!Fgnt|Hps%M$Qxp#J&;*<`e(w#(Ds>+usS#iHa&Zob!9UOsSxzHm7e zF-Lrx`&o^#Z{UdRwqG>Fh_}2B`WN(mSGtE97CXMw1<%0&xd3F+e;7Rzhm9B2jRJg( z)YJkTr?tI~Yp0&cdoHF_P;D;SSGavMeW0Y>`X!qpCgDYu))zPSTg}YTFXTKB7Y3@@Ut>EvVcCQJH8xHT zD?!Eb9v}ByU}kl%!1K1y!MekT6jwMa88N1s$z?NJH6%d*DLRQ!%#nQhy1k#mxj@U$ zW~gsj%{7t7g=sx1gxsw~1vu5*!)m?9N^u`8fPvXeI*G2ac((Ub2B;B7{wlw zVaLEArAJ6Xr%QhvAMyJcG3Uq(J|l)IOjsHe>VbQkkO8QxW~yj>;*UpmqyYJ^r0j79 zoq?YRz;NQclv7=!Z&_m6jn3p62(mk?j0$-8U^udOP8l+TmRaiS1WubA3LR7@O=#UX;_Rt zYX6<4nA0yVHz5x!$$vrhk2Uh5hTDIG?L;4m$l}2LlMv+LW9xMfaoJ1_tgbGejyl;Y zSR(q~LKQ<^#N(8kCZalr<4(B!-OFnp9}kC zu(GkOZftzB%?FuXdS;zLV^>CKoBK7dMnb zbWT9kbzM8&_Dj{WY~eX->(S+qW*>K*NdJdw3Z5t+jPK~GbD?R-dm;w8-sR@aYnqj3 z??D3wd?2xXG0C4zbE?}k$?B{ZRJs2_?z8?v?muwaTmR4G-uU0hJxLT3;NnH#y7=ZN z+ov35yf=hKa_{#`V!uz93i;wB8{{T>fsf^E>f?*qC`kHPjUE$YeF(bOnT!LAj<1ci z&6b_R{xdo+g2Pp^p}u8T^eOU!Jz)J|H;6D#!pv$|&i;p_N~u)!^&1S92&(eqG0U{L(fMwBjAhTiI@>Y(sBgn+5zD9|Gm^I} zqwkd5uC`LN!>wc7>^n0G`34o@lrWUqY8jZnAut;^dwnCZVl|o)eBtPMYMuxP zw>pEIt9sXY9rR)rvnH%8#P-&155DSnDmy0%xN&AjSwMGk9-HS4xz6Fnod}wf{2pCm zceDCp^L0qN``k-@n_yr)yrx15GQBCYFkUzE(+*?5AFTc*KNiDKsx<^cUfN5lsAmxWsrI@0scDVx*N;)H?m|LJ$fjXJ4-=Vjn202xGTvHaD>SM=Uk z-Rd-PpW4*{7c`57o>AQ%HrIPVhI^u zJ_Djn1OA%=zUkfN3j1!-AM)Ino_YgX3OU8veEyX$Rg+hC2H!^4WmSgmC*jXnlDrrI z^z7_=AulDWjh7_~p#^t~|xUiz&zeytm>bW4o}| zB@%wi1je+RjzUV4mP%yHt%2vRr3{ssM`kq$OV@x`GCwH z(fV+&C9pzC{8Sz$pRwQDXDusH)E$f_YdL3DIY zXF?N*b!!ki*nVND~hQunjr+3`E6T(n{g+H3O3ZgcJZECe#`Iv2?idF`38+Z(P# z!SxJ>I{71^w0Wnki)&hn@Bxv^3;8<6M3I?PExGk2QSRfh7spqRH#%2Z0ty#v?uLRK z5%hXONo>sN{0tmN%N_#x4jxTUULXwcr$LVtY2bw+MXZGLd)-;K*#_+O7GP7RyhGF3)jb z(GyX1aH(fiAv5omGng&)dOdS!5rf{Ejl^8-{5+*qWnqffG3WsK&&id&H&`!!_sX~a zz4B^5eXmP)xu7+V&vpG$&^E!PG^eNIS2lzJZ_Js&vSg~Bl`Wo3z4 zm)Mm~&d&J!)oocLFH~onU0E*or&)f6#&^%980YU|at}|RV{@>lh^p)nMiY|Jh-rCz zvV8HU-1xZ>`p-6sz7VYAFrNN(V7Xg8d%|mPm$T4=q+d5R)qF9_`*Z9;hE!TsH}A)I zP?^wq#SlMQSyx2*%fuopj<1G;NCbF^L?hWTKJ*$3OE-$jqM2p;v=*8uVtbVcd_EnN zvm)sp+1iL809#IGf1Qy;M!U z8z;iA(~)SyzW*;2^v{qX@7=6n&Qx5_&QXhp9$tmIY2u36Kc54gDj&D8mCxVH%@m*z zTy^aa42t}rpu74vtZCs++Rva5JEl?GHrMh7 z73*4e>MLYca>JY0YVhK)DS41#yj#FFKz$Kf?TLxg@8W)dHg-U57+B4swian<+`&fc z`gV{Uy{oM|;elF^Os$!1kwu6rHu5z9bRZ4;0z(^b0*3xq7)L@2pZf z|GUAS?S`3qAr$HS5m@;903=JZ2Q1qfR{f~iaQ@o_WLrlAXe&GBu1njWdzmq@xe04I z8v~(K8NMf~E3)g7|6gV^^!d5?iGW*x1Khht^6>8;7mZQZG1&;4VR>DH>>s)Ed8{yM zDXu;P$DS#I*aN%wlHGPSf*fpz4GsrmbGWr4VgEk6K<)n_?ZScI`vY-7ho|TJpN@?` za{_&R3$_mDChS61cfPicv+Jbbt$*j~+8LRQ>Lu{IVadLhziR;hl*&9>}=P+S>^OQgme#W1Dc8SlZ)(_gZg?`?&g;~C$j7Y)$;k;>c93LI zTe2EH9Q_EGgMW)K^B=o+(pc9OTnN1lW8Yn{PTtuC{1$&{j)3}o zGu9p4M9OP3a)spce#!zqvA z0Mv(!_8#%a4IDGOxco(9E@#d`DoNVw+GZ6jK?0F2>HG?PI|cBE4uOg1Q-?M=i(d?y z^fO-+Sy8POr1H{>ZGH-wJN^!Mi5>D{#OA*D^O1akM6sjk>DNUl_@qw_t2C?ayrJCG zA4Aw<%HA6<`u8i1PVDt(-SV12sq|fY)R^Jybo3rZxZolG9heg)hu(Ncy{m72p=HT2 ze0h$Ap^Y{09kRFR4_nb%x3$$JOp6p@3Zz zKQWq@#;i$c=gGc1H??}h%X62mGM#GRK5eCxs}U!oDU$JrKR$Gxj>KBY91r88x~?^? zxVlZoY4DM&sx7hVmwj=}q`$cwQO6XWED=ec(Zyy(h2&hHX=6Ysk@3c7p3KvrN+5uI62|v}6o$7J(FkG3 z%ev??Azie`Wazodk9<1z zhDE;8aM0i=+fAsQr%1yU@p=m6W|Vny9*x^bxcL?lkU;V zAbX!I&=ovYM>#cOJJk4@DqgB4w7)CqC>TbHg(ZzyI8hu?9OQG)%aa5zDF$5T#Jc7u z6GnV{vV`ObP8fVzI_l}YvH*Y-7VRhjwVLD-D%>d?$u+kKC&`|0qO5GP2a7^>She3e zuE(_o1B5AdqY8`XiO5JW1}jD1q!XR4@~BhM`vdwx+CwK5Ng!kLM9qcb(s+eQcKOhLO zPr*EcZMr8OhoZ^_*!h~3uunMbKmx>$a`2ljrADHv&sa;~zk1Kfocc2L-1Zvd=m4~s zJ?Re3?c-lN8lKj^jMd3kLx}KNgHrCPcQe@~F{$j_aMmK8?;@kg<{JIjoMwNCN_WQm z(y+)wHe3VYIy`3PSO_m~%)gA9nKTd09b|*Y2@_5JaouIxr2BXhFuJ~+jeZzcZr`nb zrA5xC_QXCz`oNhDzE76I5dFLrX4n|DRcW}+HiGwfy5ml4-w97qYft2Im7Oz&Q zl*c2_i;@Ay6n-2Y<7C3W^e>2!DRo74`jRf~HvbjChqM(M{uT+zJGvl2-ov{6yarM6 z0N_9$Db5|7bt6Tj#i=3=x>aerR4SFE1BK#C}w{B>YMe zMj&?#7$PmrlKE%;TJzc`H7@UD25ge16CPD=8^VE8if}=EP3P$(DJdxvmuETWhC#~Q z1t_^537@`eq~{eER8g}G*(DckyrbWjpt_dj5V`tGnu7dN&!lYRSwKKmtE%Yx4~UFt zXm^?B;$4yT`xq7f91tP7JPs_g%e8T*X=<{|ZD^7k(WA|Yiy1Du<|aDx{sYNI*h2aL zM6xl792O?@jz?PHHNnwQ^bv)TdC8`-we9AiIQ3GTZ*3oGdydgE<;%_X6egn66pQgY zaX7xWyo6*^t0HECCc!D0bggzi-bK7R2H&8b7@L%GUFIq+@f{JXxMIxSAqBj%x42hP|l*)fv2k^Z=gY8Yb_oV;udP;x11sEx^Y?4 zK9O}SB&lE_oW0`DeTtfbu?9|poV_3KC859&Ocmy%sll^qk4w=XDlX z5@FQIyL28jUEH`5^E!a#Y;Lio;>YJTtM`D~hqrszrn%g&L^qX_6D+kp*bNa&8uiOG z`kI=A<@$#aNt*gJHxI1`rvBK~(L0zTDNaD6?lluvN`-5`X7Z}N z2Sg6bxg3Dc63XJD_uUsXy`occNY`M?!rnTG$35azHN_FcWZ+zZN(q9*M4xErhh{ev zn_7~sEqmN7%?;w8(Tsid3!n|cM~Mi@W%`HaZL>rWmS@jPW!b?>wA&+4-<|heX=UJ2 zW6I&G!^D}}bZ^I&{cewJw|G(RFMO9JQsnn42Y@wHymx3=J}##p+<4~vk%872DfR1e zuL(}cZUSNC`RL1iTq*qj@cXhKV$X`YMoQj4e)60Vj#xlvyZuFbQbXI2Hi| zxaY?V@c5`yQ=T{U1rg34yuFM@iyHsVSK2Ab-R_YPx|w|WyozYd$moGJuGhgPo!-v; z80a=WxlqG(HRdlm&@|g*S&)s$>5XhGp^J6}*Da!yYn;*3=UK{3mIz;0<9r{(@>R$q zBgam>8=o-1lk=roH-XZ6DA?yz)$04@;}zY1Zso|JE^_N4x~2&h>;*fQS9W5-wdE$j zAl8d`mKFjH54JG=b?%#(F;de)gL-Z^>*OMENw@sY)AbrpP9L?$`oWz>G@5{a%y=Mc zYXNBx){m7;IZ)KW{tfzr;GE!lTY)L75dr-ya}8M%X!rzVIMZ`49w3tE^Bk1wZkz0# zGf0cNjJ#Ld(5QDEcTvKepf6h6Fuivs#!Iqv~X3a~UxVlsI zIA)*T-67Z6?>($NpEPBK5;It_=M1ZMrFrrCPB^Qqx6~JI^m9`LKx$PI>lOe5peKo& z+m8Ii?7XP>6gnrXHoj<+?RD`?ODG;mrMmn}dossNY;$zpNY{Zm=AhTDuCcyt)>CeX zGc!xdx={pNV)3R-Cnyz^f$`GYz%YWNlt-)i5x=ecxH8|0g#%{lbZEjlo<&3ZCJ{>n zros7oCbXe8u{D{|C-wAEqjcAw!pMTPFFgaxd?k)J!GvN~&ZtAtE}UF=bWv?*O6-MD zYWXW}!oZ6aKdccF@KdVIHoK7$oer-pI=+c2 ziu14TUUgL?pwuYcynWqMOLXrOo<;l0S9S(0@K#2#o;obn=T-6m37sQ$H_&2 z^fWKzXn1B@bAX@R)w>d}(1fl!ycHk!2KEghdu~At4s^1_ws<3Y1a3Cy8AZE9y!d!L z8eLfQ;PyMrxE_#{PHC9Fm;Ss&ezIrW?ExY0=2^s8oZ{%&B)ccIvFq!kRY~}KyU%2)!#xh_Rei z13AI*#b>JaO@^05KTqZFn*ZS^6HRS*EQKfSH z8Om%{r_H_5$N$38$f|l9G)7?bH75vT!S!@If-*9L1QCTX=3CNuu=rwE$%G8fQ*rJu zKPyIiaubbR04mAF=Xxt87#0p0j%L&lZ*9d|7!+pRa)k86sF`zQ#|Dv)?3@vEqk;eP zu{c{DrRc@Ub#h{K=oJ4vcKGnfi4^~d?o0CG#gX^kp2<)Omn-s#g*IeZ(-Js$iq%?6J;;$+8N*J`>^LbGknK=dqz1cJHLqt$gpoCA`>wf`pV0YaB literal 0 HcmV?d00001 diff --git a/docs/assets/images/doc_images/summary_view.png b/docs/assets/images/doc_images/summary_view.png new file mode 100644 index 0000000000000000000000000000000000000000..b1ca28245e8f596c1de97fd1444e8206b930ec7d GIT binary patch literal 48190 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%0A{p{(FMkhA!m4qdBu0#Q%DO6bWYFD*r=#iABY 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 EFD5y;NdN!< literal 0 HcmV?d00001 diff --git a/docs/user_guidance/auditing.md b/docs/user_guidance/auditing.md index 2ae9b54..6d6a101 100644 --- a/docs/user_guidance/auditing.md +++ b/docs/user_guidance/auditing.md @@ -18,17 +18,4 @@ Currently, these are the audit tables that can be accessed within the DVE: You can use the the following methods to help you interact with the tables above or you can query the table via `sql`. -
    - -::: src.dve.core_engine.backends.base.auditing.BaseAuditingManager - options: - heading_level: 3 - members: - - get_submission_info - - get_submission_statistics - - get_submission_status - - get_all_file_transformation_submissions - - get_all_data_contract_submissions - - get_all_business_rule_submissions - - get_all_error_report_submissions - - get_current_processing_info +You can read more about how to interact with the Audit Objects [here](../advanced_guidance/package_documentation/auditing.md). diff --git a/docs/user_guidance/business_rules.md b/docs/user_guidance/business_rules.md index 1c63d00..54def88 100644 --- a/docs/user_guidance/business_rules.md +++ b/docs/user_guidance/business_rules.md @@ -1,2 +1,591 @@ +--- +title: Business Rules +tags: + - Business Rules +--- + +The Business Rules section contain the rules you want to apply to your dataset. Rule logic might include... + +- Checking if two or more fields are equivalent +- Aggregating data to check if it matches a given value +- Joining against other entities to compare values + +All rules are written in `SQL`. Depending on which [backend implementation](./implementations/) you have choosen, the syntax might be different between implementations. + +When writing the rules, you need to be aware that the expressions are wrapped in `NOT` expression. So, you should write the rules as though you are looking for non problematic values. + +When rules are being applied, [Complex Rules](./business_rules.md#complex-rules) are always applied before [Rules](./business_rules.md#rules) and [Filters](./business_rules.md#filters) + +This page is meant to give you greater details on how you can write your Business Rules. If you want a summary of how the Business Rules work, then please refer to the [Getting Started](./getting_started.md#rules-configuration-introduction) page. + +## Filters + +For the simplest rules, you can write them in the filters section. For example, if you had a movies dataset where you wanted to check the length of the movie had a realistic duration then you could write a rule like this... + + +=== "Record Rejection" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "filters": [ + { + "entity": "movies", + "name": "Ensure movie is less than 4 hours long", + "expression": "duration_minutes > 240", + "failure_type": "record", + "error_code": "MOVIE_TOO_LONG", + "failure_message": "Movie must be less than 4 hours long.", + "category": "Bad Value" + } + ] + } + } + ``` + +=== "File Rejection" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "filters": [ + { + "entity": "movies", + "name": "Ensure movie is less than 4 hours long", + "expression": "duration_minutes > 240", + "failure_type": "submission", + "error_code": "MOVIE_TOO_LONG", + "failure_message": "Movie must be less than 4 hours long.", + "category": "Bad Value" + } + ] + } + } + ``` + +=== "Warning" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "filters": [ + { + "entity": "movies", + "name": "Ensure movie is less than 4 hours long", + "expression": "duration_minutes > 240", + "failure_type": "record", + "is_informational": true, + "error_code": "MOVIE_TOO_LONG", + "failure_message": "Movie must be less than 4 hours long.", + "category": "Bad Value", + } + ] + } + } + ``` + +The rule above can be written directly into the filters section because we do not need to perform any complex pre-step(s) such as filtering, aggregation(s), join(s) etc. We can simply select the fields of interest and perform the check. + +If you need to perform more complex rules, with pre-steps, then see the [Complex Rules](./business_rules.md#complex-rules) section further down this page. + + + +### Types of rejections + +You may have noticed the three type of rejections in the example above. For any given rule you can reject a record, the whole file (submission) or just raise a warning. More details in table below: + +| Rejection Type | Behaviour | How to set in the rule | +| -------------- | --------- | ---------------------- | +| `submission` | Rejects the entire file. Even if it triggers once, no data will be projected in the final asset. | Set `failure_type` to `submission` | +| `record` | Rejects the record that failed the check. Any records that fail will not be projected in the final asset | Set `failure_type` to `record` | +| `warning` | Raises a warning that the record failed the check. This has no impact on whether the file/record is rejected. | Set `is_informational` to `true` | + +## Rules + +The `rules` section allows you to perform pre-steps to entities. For example, if you wanted to derive a new column, apply filters, aggregations, joins etc. + +With pre-steps, you can either modify an existing `entity` or create a new entity from an existing one. For example, here is a pre-step showing both modifying an existing entity or creating a new one: + +=== "Existing Entity" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "rules": [ + { + "name": "add duration_hours as a new column", + "operation": "add", + "entity": "movies", + "column_name": "duration_hours", + "expression": "(duration_minutes / 60)" + } + ] + } + } + ``` + +=== "New Entity" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "rules": [ + { + "name": "add duration_hours as a new column", + "operation": "add", + "entity": "movies", + "column_name": "duration_hours", + "expression": "(duration_minutes / 60)", + "new_entity_name": "movies_modified" + } + ] + } + } + ``` + +The difference between modifiying the existing entity and adding a new one is simply adding `"new_entity_name": ""`. + +!!! warning + + If you add columns to an existing entity defined within the contract, that column will be written out with the projected entity. To get around this, you will either need to create new entities *or* you can see the [post rule logic](./business_rules.md#post-rule) section to remove the column. + +### Operations + +For a full list of operations that you can perform during the pre-steps see [Advanced User Guidance: Operations](../advanced_guidance/package_documentation/operations.md). + +## Post Rule + +When a Business Rule has been finished, "post step rules" can be run. This is useful in situtations where you've created lots of new entities *or* you have added lots of new columns to existing entities. + +For new entities, it's advised that you always remove them. In instances where you have derived new columns for existing entities you may not want them to persist the columns in the projected assets. The code snippets below showcases how you can remove columns and new entities: + +=== "New Column Removal" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "rules": [ + { + "name": "add duration_hours as a new column", + "operation": "add", + "entity": "movies", + "column_name": "duration_hours", + "expression": "(duration_minutes / 60)", + } + ], + "filters": [ + ... + ], + "post_filter_rules": [ + { + "operation": "remove", + "entity": "movies", + "column_name": "duration_hours" + } + ] + } + } + ``` + + +=== "New Entity Removal" + + ```json title="movies.dischema.json" + { + "contract": { + "datasets": { + "movies": { + "fields": { + "duration_minutes": "int", + ... + }, + ... + } + } + }, + "transformations": { + "rules": [ + { + "name": "add duration_hours as a new column", + "operation": "add", + "entity": "movies", + "column_name": "duration_hours", + "expression": "(duration_minutes / 60)", + "new_entity_name": "movies_modified" + } + ], + "filters": [ + ... + ], + "post_filter_rules": [ + { + "operation": "remove_entity", + "entity": "movies_modified" + } + ] + } + } + ``` + + +## Reference Data + +If your Business Rules are reliant on reference data, then you can add the `"reference_data"` key to the `"transformations"` section. The snippet below shows various formats of reference data that you might want to add: + +=== "Parquet source" + + ```json title="movies.dischema.json" + { + "transformations": { + "reference_data": { + "movie_genre_lookup":{ + "type": "filename", + "filename": "path/to/my/movie_genre_lookup.parquet" + }, + ... + } + } + } + ``` + +=== "Arrow source" + + ```json title="movies.dischema.json" + { + "transformations": { + "reference_data": { + "movie_genre_lookup":{ + "type": "filename", + "filename": "path/to/my/movie_genre_lookup.arrow" + }, + ... + } + } + } + ``` + +=== "Database source" + + ```json title="movies.dischema.json" + { + "transformations": { + "reference_data": { + "movie_genre_lookup": { + "type": "table", + "database": "my_database", + "table_name": "movie_genre_lookup" + }, + ... + } + } + } + ``` + !!! note - This section has not yet been written. Coming soon. + + - When a new reference data entity is created, it will always be prefixed with `refdata_` + + !!! warning + + - Refdata entities are also immutable. So, if you need to modify them in any way, you will always need to create a new entity from it + +For latest supported reference data types, see [Advanced User Guidance: Reference Data Types](../advanced_guidance/package_documentation/refence_data_types.md). + +## Complex Rules + +Complex Rules are recommended when you need to perform a number of "pre-step" operations before you can apply a business rule (filter). For instance, if you needed to add a column, filter and then join you would need to add all these steps into your [Rules](./business_rules.md#rules) section. This might be ok, if you only need a small number of pre-steps or only have a couple of rules. However, when you have lots of rules and more than 1 have a number of operations required, it's best to place these into a [Rulestore](./business_rules.md#rule-stores) and reference them within the complex rules. Otherwise, you could start to make the dischema document completely unmaintainable. + +Here is an example of defining a complex rule: + +=== "dischema" + + ```json title="movies.dischema.json" + { + "transformations": { + "parameters": {"entity": "movies"}, + "reference_data": { + "sequels": { + "type": "table", + "database": "movies_refdata", + "table_name": "sequels" + } + }, + "complex_rules": [ + { + "rule_name": "ratings_count" + }, + { + "rule_name": "poor_sequel_check", + "parameters": { + "sequel_entity": "refdata_sequels" + } + } + ] + } + } + ``` + +=== "rulestore" + + ```json title="movies_rulestore.json" + { + "ratings_count": { + "description": "Ensure more than 1 rating", + "type": "complex_rule", + "parameter_descriptions": { + "entity": "The entity to apply the workflow to." + }, + "parameter_defaults": {}, + "rule_config": { + "rules": [ + { + "name": "Get count of ratings", + "operation": "add", + "entity": "{{entity}}", + "column_name": "no_of_ratings", + "expression": "length(ratings)" + } + ], + "filters": [ + { + "name": "filter_too_few_ratings", + "entity": "{{entity}}", + "expression": "no_of_ratings > 1", + "error_code": "LIMITED_RATINGS", + "reporting_field": "title", + "failure_message": "Movie has too few ratings ({{ratings}})" + } + ], + "post_filter_rules": [ + { + "name": "Remove the no_of_ratings field", + "operation": "remove", + "entity": "{{entity}}", + "column_name": "no_of_ratings" + } + ] + } + }, + "poor_sequel_check": { + "description": "check if bad sequel exists", + "type": "complex_rule", + "parameter_descriptions": { + "entity": "The entity to apply the workflow to.", + "sequel_entity": "The entity containing sequel data" + }, + "parameter_defaults": {}, + "rule_config": { + "rules": [ + { + "name": "Join sequel data", + "operation": "inner_join", + "entity": "{{entity}}", + "target": "{{sequel_entity}}", + "join_condition": "{{entity}}.title = {{sequel_entity}}.sequel_to", + "new_entity_name": "with_sequels", + "new_columns": { + "{{sequel_entity}}.ratings": "sequel_rating" + } + }, + { + "name": "Get median sequel rating", + "operation": "group_by", + "entity": "with_sequels", + "group_by": "title", + "agg_columns": { + "list_aggregate(sequel_rating, 'median')": "median_sequel_rating" + } + } + + ], + "filters": [ + { + "name": "filter_rubbish_sequel", + "entity": "with_sequels", + "expression": "median_sequel_rating > 5", + "error_code": "RUBBISH_SEQUEL", + "reporting_entity": "derived", + "reporting_field": "title", + "failure_message": "The movie {{title}} has a rubbish sequel", + "is_informational": true + } + ], + "post_filter_rules": [ + { + "name": "Remove the with_sequel entity", + "operation": "remove_entity", + "entity": "with_sequels" + } + ] + } + } + } + ``` + +For all complex rules, you must set the key `"type"` to "complex_rule". Description is optional but future you will thank you when there is a quick explantation explaining what the rule is doing. + +After that, you define the `"rule_config"` key which defines the [Rules](./business_rules.md#rules), [Filters](./business_rules.md#) and [Post Rule steps](./business_rules.md#post-rule) to be applied. + +The sections below will cover the unique elements in a complex rule not already covered in the previous sections. + +### Parameters + +Parameters have two scopes. "Global" and "local". + +"Global" parameters can be defined as a new key under the `"transformations"` section. These can contain variables accessible by every single rule and filter. + +"Local" parameters are defined during the setup of a [Complex Rule](./business_rules.md#complex-rules). + +Below is an example showing how you would define them: + +=== "Global Example" + + ```json title="movies.dischema.json" + { + "contract": { + ... + }, + "transformations": { + "parameters": { + "param_name": "value", + "param_name2": "value", + ... + }, + ... + } + } + ``` + +=== "Local Example" + + === "dischema" + + ```json title="movies.dischema.json" + { + "contract": { + ... + }, + "transformations": { + "complex_rules": [ + { + "rule_name": "my_complex_rule", + "parameters": { + "param_key1": "value", + "param_key2": "value", + ... + } + } + ], + ... + } + } + ``` + + === "Rulestore" + + ```json title="movies_rulestore.json" + { + "my_complex_rule": { + "parameter_descriptions": { + "param_key1": "required for x,y,z reason", + "param_key2": "lorem ipsum", + }, + "parameter_defaults": { + "param_key2": "hello world" + }, + "rule_config": { + ... + } + } + } + ``` + +### Rule Stores + +Rule stores are seperate JSON documents that you can load into the dischema document. The benefit of building rulestores are that you can reutilise them across multiple dischema documents. + +To add a new rulestore simply add a new key called `"rule_stores"` under the transformation section. For example: + +```json title="movies.dischema.json" + { + "contract": { + ... + }, + "transformations": { + "rulestores": [ + { + "store_type": "json", + "filename": ".json" + }, + ... + ] + ... + } + } +``` diff --git a/docs/user_guidance/data_contract.md b/docs/user_guidance/data_contract.md index 1c63d00..21ba19c 100644 --- a/docs/user_guidance/data_contract.md +++ b/docs/user_guidance/data_contract.md @@ -1,2 +1,449 @@ -!!! note - This section has not yet been written. Coming soon. +--- +title: Data Contract +tags: + - Contract + - Data Contract + - Domain Types +--- + +The Data Contract describes the structure (models) of your data and controls how the data should be typecasted. We use [Pydantic](https://docs.pydantic.dev/1.10/) to generate and validate the models. This page is meant to give you greater details on how you should write your Data Contract. If you want a summary of how the Data Contract works, please refer to the [Getting Started](./getting_started.md#rules-configuration-introduction) page. + +!!! Note + + We plan to migrate to Pydantic v2+ in a future release. This page currently reflects what is available through Pydantic v1. + +## Models + +The models within the Data Contract are written under the `datasets` key. For example, this is how you might define a model for a movies dataset: + +=== "movies.dischema.json" + + ```json + { + "datasets": { + "movie": { + "fields": { + "movie_uuid": "int", + "movie_name": "str", + "year_released": "conformatteddate", + "genres": { + "type": "str", + "is_array": true + } + } + }, + "cast": { + "fields": { + "actor_id": "int", + "actor_forename": "str", + "actor_surname": "str", + "character_name": "", + "movies_acted": { + "type": "int", + "is_array": true + } + } + } + } + } + ``` + +=== "movies.json" + + ```json + { + "movie_uuid": 1, + "movie_name": "John Doe & The Giant Peach", + "year_released": "1964-01-01", + "genres": [ + "thriller", + "action", + "horror" + ], + "cast": { + "actor_forename": "John", + "actor_surname": "Doe", + "character_name": "John Doe", + "movies_acted": [ + 1 + ] + } + } + ``` + +From the example above, we've built two models from the source data which in turn will provide two seperated entities to work with in the business rules and how the data will be written out at the end of the process. Those models being `"movie"` and `"cast"` with `fields` specifying the name of the columns and the data type they should be casted to. We will look into [data types later in this page](data_contract.md#types). + + +### Mandatory Fields + +Within the Data Contract you can also specify `mandatory fields`. These are fields that must be present in the submitted data or a [Feedback Message](./feedback_messages.md) will be generated stating that the field is missing. You can define `mandatory fields` like this... + +```json title="movies.dischema.json" +{ + "contract": { + "datasets": { + "movie": { + "fields": { + "movie_uuid": "int", + "movie_name": "str", + "year_released": "conformatteddate", + "genres": { + "type": "str", + "is_array": true + } + }, + "required_fields": [ + "movie_uuid", + "movie_name" + ] + }, + "cast": { + "fields": { + "actor_id": "int", + "actor_forename": "str", + "actor_surname": "str", + "character_name": "", + "movies_acted": { + "type": "int", + "is_array": true + } + }, + "required_fields": [ + "actor_id", + "actor_forename", + "actor_surname" + ] + } + } + } +} +``` + +### Key Fields + +You can define a `key_field` or `key_fields` within a given entity. These represent the unique identifiers within your dataset. `key_field` represents a single unique identifier, whereas `key_fields` allows a combination of fields to represent a unique record. + +This can be defined within the dischema like... + +```json title="movies.dischema.json" +{ + "contract": { + "datasets": { + "movie": { + "fields": { + "movie_uuid": "int", + "movie_name": "str", + ... + }, + "key_fields": [ + "movie_uuid", + "movie_name" + ] + }, + "cast": { + "fields": { + "actor_id": "int", + ... + }, + "key_field": "actor_id" + } + } + } +} +``` + +### Readers + +You can define a reader for each specific model. You can have multiple readers if your incoming data is in multiple formats (e.g. csv & json). Here is an example of adding readers to our movie dataset example: + +```json title="movies.dischema.json" +{ + "contract": { + "datasets": { + "movie": { + "fields": { + ... + }, + "mandatory_fields": { + ... + }, + "reader_config": { + ".json": { + "reader": "DuckDBJSONReader", + "kwargs": { + "encoding": "utf-8", + "multi_line": true, + } + } + } + }, + "cast": { + "fields": { + ... + }, + "mandatory_fields": { + ... + }, + "reader_config": { + ".json": { + "reader": "DuckDBJSONReader", + "kwargs": { + "encoding": "utf-8", + "multi_line": true, + } + } + } + } + } + } +} +``` + +If you want to read more about the readers, please see the [File Transformation](./file_transformation.md) page. + + +## Types + +Within the `fields` section of the contract you must define what data type a given field should be. Depending on how strict/lenient you want your types to be, a number of types are available to use. The types available are: + +- [Built-in standard library](https://docs.python.org/3.11/library/stdtypes.html) types (such as `int`, `str`, `date`) available with your version of Python installed for the DVE. +- [Pydantic v1 types](https://docs.pydantic.dev/1.10/usage/types/) +- [Custom Types](./data_contract.md#custom-types) +- [Domain types](./data_contract.md#domain-types) + +### Constraints + +Given the DVE supports Pydantic types, you can use any of the [constrained types available](https://docs.pydantic.dev/1.10/usage/types/#constrained-types). The docs will also show you what `kwarg` arguments are available for each constraint such as min/max length, regex patterns etc. + +For example, if you wanted to use a `constr` type for a field, you would define it like this: + +```json title="movies.dischema.json" +{ + "contract": { + "datasets": { + "movie": { + "fields": { + "movie_uuid": "int", + "movie_name": { + "callable": "constr", + "constraints": { + "min_length": 1, + "max_length": 20 + } + }, + ... + } + } + } + } +} +``` + +In the example above we would be ensuring that the movie name is between 1 & 20 characters. If it is less than 1, or more than 20, a [Feedback Message](./feedback_messages.md) will be produced. + +### Custom Types + +As shown in the [Constraints](./data_contract.md#constraints) section above, you may want to apply the same constraints to many fields. A better way to define this rather than rewriting the constraints repeatedly for each field, is to define a custom type under the `types` key within the `contract` section. You can define a custom type like this: + +```json title="movies.dischema.json" +{ + "types": { + "MyConstrainedString": { + "callable": "constr", + "constraints": { + "min_length": 1, + "max_length": 20 + } + } + }, + "contract": { + "datasets": { + "movie": { + "fields": { + "movie_uuid": "int", + "movie_name": "MyConstrainedString", + ... + } + }, + "cast": { + "fields": { + "actor_id": "int", + "actor_forename": "MyConstrainedString", + "actor_surname": "MyConstrainedString", + ... + } + } + } + } +} +``` + +As you can see, we can set the "type" for several fields to `MyConstrainedString` which has a min & max length constraint. + +#### Domain Types + +Domain types are custom Pydantic model types available with the DVE. Current Domain types available are `Postcode`, `NHSNumber`, `FormattedDatetime` etc. You can find the full list of Domain Types [here](../advanced_guidance/package_documentation/domain_types.md). + +### Complex Types + +DVE supports the ability to define complex types such as `arrays`, `structs`, arrays of structs etc. + +To define a struct type, you would add it to the `types` section like this... + +```json title="movies.dischema.json" +{ + "contract": { + "types": { + "Actor": { + "actor_id": "int", + "actor_forename": "str", + "actor_surname": "str", + "character_name": "", + "movies_acted": { + "type": "int", + "is_array": true + } + } + }, + "datasets": { + ... + } + } +} +``` + +... and then you can simply add a new field with `model` set to the new type and `is_array` equal to `true` (or `false` if you just want a struct). + +```json title="movies.dischema.json" +{ + "contract": { + "types": { + "Actor": { + "actor_id": "int", + "actor_forename": "str", + "actor_surname": "str", + "character_name": "", + "movies_acted": { + "type": "int", + "is_array": true + } + } + }, + "datasets": { + "movie": { + "fields": { + "movie_uuid": "int", + "movie_name": { + "callable": "constr", + "constraints": { + "min_length": 1, + "max_length": 20 + } + }, + "actors": { + "model": "Actor", + "is_array": true + } + } + } + } + } +} +``` + +If you just want to turn a simple type into an array, simply set `is_array` to `true`. E.g. + +```json title="movies.dischema.json" +{ + "contract": { + "datasets": { + "cast": { + "fields": { + "movies_acted": { + "type": "int", + "is_array": true + } + } + } + } + } +} +``` + +## Error Categories + +As mentioned earlier, when a field... + +- cannot be correctly type casted +- breaks the constraints of the type +- is missing when mandatory + +... a [Feedback Message](./feedback_messages.md) will be produced. Each error raised, will be categorised into one of... + +| Category | Meaning | +| -------- | ------- | +| Blank | The value is missing | +| Wrong format | The value could not be casted into the defined type.

    I.e. str -> date, str -> int etc | +| Bad value | The value broke one of the constraints | + +## Custom Error Details + +When a [Feedback Message](./feedback_messages.md) is produced during the contract a number of default error codes and messages are utilised. If you need to overhaul the error code and error message, you can create a custom contract error details `JSON` document. It can be setup in the following way: + +=== "movie.dischema.json" + + ```json + { + "contract": { + "error_details": "movie_data_contract_details.json", + "datasets": { + "fields": { + "movie_uuid": "int", + "movie_name": "str", + ... + } + } + } + } + ``` + +=== "movie_data_contract_details.json" + + ```json + { + "movie_uuid": { + "Blank": { + "error_code": "MOVIE_UUID_01", + "error_message": "File Rejected - movie_uuid is blank." + }, + "Bad Value": { + "error_code": "MOVIE_UUID_02", + "error_message": "File Rejected - movie_uuid has an incorrect data format. movie_uuid={{ movie_uuid }}." + } + }, + "movie_name": { + "Bad Value": { + "error_code": "MOVIE_NAME_01", + "error_message": "File Rejected - movie_name has an incorrect data format. movie_name={{ movie_name }}." + } + } + } + ``` + +!!! Warning + + The contract details document must be in the same directory as the dischema document. + + +## Cache Originals + +This setting allows you to retain a copy of the original entities (as defined within the dischema) before the business rules are applied. This is set to `False` by default. To enable it, simply add the following to your dischema document: + +```json title="movies.dischema.json" +{ + "contract": { + "cache_originals": true, + ... + } +} +``` diff --git a/docs/user_guidance/error_reports.md b/docs/user_guidance/error_reports.md index 1c63d00..79c3be3 100644 --- a/docs/user_guidance/error_reports.md +++ b/docs/user_guidance/error_reports.md @@ -1,2 +1,31 @@ +--- +title: Error Reports +tags: + - Feedback + - Messages + - Error Reports +--- + + +As mentioned in the [Introduction](../index.md) a fourth optional component is offered with the DVE. This is known as the Error Reports. This step will collate the [Feedback Messages](./feedback_messages.md) and populate them into an spreadsheet document. + +The Error Report will be available for each submission under `error_report/` folder. + +## Summary +Contains metadata around the submission and whether it was successful. +![Summary](../assets/images/doc_images/summary_view.png) + + +## Error Summary +Contains an aggregation of all the errors and warnings that have occured and how many times they occured for that submission. +![Error Summary](../assets/images/doc_images/error_summary.png) + + +## Error Data +Provides a breakdown of every single error that occured within a submission. +![Error Data](../assets/images/doc_images/error_data.png) + + !!! note - This section has not yet been written. Coming soon. + + The images above were generated from our movies test dataset. You can view the rules and data [here](https://github.com/NHSDigital/data-validation-engine/tree/main/tests/testdata/movies). diff --git a/docs/user_guidance/feedback_messages.md b/docs/user_guidance/feedback_messages.md index 1c63d00..ee81288 100644 --- a/docs/user_guidance/feedback_messages.md +++ b/docs/user_guidance/feedback_messages.md @@ -1,2 +1,31 @@ -!!! note - This section has not yet been written. Coming soon. +--- +title: Feedback Messages +tags: + - Feedback + - Messages +--- + +During the processing of a submission through the DVE, Feedback Messages will be produced. + +These messages are generated when... + +1. the data is structurely incorrect during the [File Transformation](./file_transformation.md) stage. +2. the data has failed during the modelling and casting steps during the [Data Contract](./data_contract.md) stage. +3. the data has failed one of the validation rules defined during the [Business Rules](./business_rules.md) stage. + +The messages are compiled into a `jsonl` file associated with the stage it failed in. + +The `jsonl` files produced will be in the same folder as your submission under a folder called `errors/`. + +## Processing Errors + +In situations where the DVE cannot continue (critical failure), a message will be produced and stored in the submissions folder under a name called `processing_errors/`. + +The processing error `jsonl` file will contain information regarding why the DVE could not continue. + +If the DVE is crashing and the error message is either unreadable or is crashing when it should be working, please [raise an issue](https://github.com/NHSDigital/data-validation-engine/issues) on our GitHub. + + +## Feedback Message Models + +Please refer to [Advanced User Guidance: Feedback Messages](../advanced_guidance/package_documentation/feedback_messages.md). diff --git a/docs/user_guidance/getting_started.md b/docs/user_guidance/getting_started.md index 8b3965e..7ce276a 100644 --- a/docs/user_guidance/getting_started.md +++ b/docs/user_guidance/getting_started.md @@ -1,5 +1,5 @@ --- -title: Installing the Data Validation Engine +title: Getting Started tags: - Introduction - Data Contract diff --git a/includes/jargon_and_acronyms.md b/includes/jargon_and_acronyms.md index 4962306..9b42c4e 100644 --- a/includes/jargon_and_acronyms.md +++ b/includes/jargon_and_acronyms.md @@ -1,3 +1,6 @@ *[DVE]: Data Validation Engine *[dischema]: Data ingest schema *[stringified]: all fields casted to string +*[kwarg]: Key Word Arguments +*[constr]: Constrained String +*[immutable]: Unchanging over time or unable to be changed diff --git a/zensical.toml b/zensical.toml index b93f45d..eb4c9af 100644 --- a/zensical.toml +++ b/zensical.toml @@ -25,7 +25,6 @@ nav = [ {"File Transformation" = "user_guidance/file_transformation.md"}, {"Data Contract" = "user_guidance/data_contract.md"}, {"Business Rules" = "user_guidance/business_rules.md"}, - {"Feedback Messages" = "user_guidance/feedback_messages.md"}, ]}, {"Backend Implementations" = [ {"DuckDB" = "user_guidance/implementations/duckdb.md"}, @@ -35,14 +34,33 @@ nav = [ {"Palantir Foundry" = "user_guidance/implementations/platform_specific/palantir_foundry.md"}, ]}, ]}, + {"Reporting" = [ + {"Feedback Messages" = "user_guidance/feedback_messages.md"}, + {"Error Reports" = "user_guidance/error_reports.md"}, + ]} ]}, {"Advanced User Guidance" = [ "advanced_guidance/index.md", {"DVE Package Documentation" = [ "advanced_guidance/package_documentation/index.md", {"Pipeline" = "advanced_guidance/package_documentation/pipeline.md"}, - {"Refdata Loaders" = "advanced_guidance/package_documentation/refdata_loaders.md"}, - {"Readers" = "advanced_guidance/package_documentation/readers.md"}, + {"Auditing" = "advanced_guidance/package_documentation/auditing.md"}, + {"Data Contract" = [ + {"Readers" = "advanced_guidance/package_documentation/readers.md"}, + {"Domain Types" = "advanced_guidance/package_documentation/domain_types.md"}, + ]}, + {"Business Rules" = [ + {"Rules" = [ + {"Operations" = "advanced_guidance/package_documentation/operations.md"}, + ]}, + {"Refdata" = [ + {"Refdata Types" = "advanced_guidance/package_documentation/refence_data_types.md"}, + {"Refdata Loaders" = "advanced_guidance/package_documentation/refdata_loaders.md"}, + ]} + ]}, + {"Feedback" = [ + {"Feedback Messages" = "advanced_guidance/package_documentation/feedback_messages.md"}, + ]}, ]}, {"DVE Developer Guidance" = [ {"Implementing a new backend" = "advanced_guidance/new_backend.md"}, From ebd7ef586275990c033c0bced913ec52facfec79 Mon Sep 17 00:00:00 2001 From: "george.robertson1" <50412379+georgeRobertson@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:44:55 +0000 Subject: [PATCH 4/5] docs: updated content and fixed auto code doc pathing --- docs/advanced_guidance/index.md | 4 +++ .../package_documentation/auditing.md | 2 +- .../package_documentation/index.md | 4 +++ .../package_documentation/readers.md | 22 +++++++-------- docs/user_guidance/auditing.md | 9 ++++--- docs/user_guidance/business_rules.md | 7 +++-- docs/user_guidance/data_contract.md | 2 +- docs/user_guidance/file_transformation.md | 2 +- docs/user_guidance/getting_started.md | 6 ++--- docs/user_guidance/implementations/duckdb.md | 2 +- docs/user_guidance/install.md | 27 +++++++++---------- 11 files changed, 48 insertions(+), 39 deletions(-) diff --git a/docs/advanced_guidance/index.md b/docs/advanced_guidance/index.md index 04fef3e..4ffaa8e 100644 --- a/docs/advanced_guidance/index.md +++ b/docs/advanced_guidance/index.md @@ -1,3 +1,7 @@ +--- +title: Advanced Guidance +--- +
    - :material-file-code:{ .lg .middle } __DVE Code Reference Documentation__ diff --git a/docs/advanced_guidance/package_documentation/auditing.md b/docs/advanced_guidance/package_documentation/auditing.md index a022a64..a043e9a 100644 --- a/docs/advanced_guidance/package_documentation/auditing.md +++ b/docs/advanced_guidance/package_documentation/auditing.md @@ -1,5 +1,5 @@ -::: src.dve.core_engine.backends.base.auditing.BaseAuditingManager +::: dve.core_engine.backends.base.auditing.BaseAuditingManager options: heading_level: 3 members: diff --git a/docs/advanced_guidance/package_documentation/index.md b/docs/advanced_guidance/package_documentation/index.md index 4e3263e..c2e76c5 100644 --- a/docs/advanced_guidance/package_documentation/index.md +++ b/docs/advanced_guidance/package_documentation/index.md @@ -1,3 +1,7 @@ +--- +title: Package Documentation +--- +
    - :material-language-python:{ .lg .middle } __Pipeline__ diff --git a/docs/advanced_guidance/package_documentation/readers.md b/docs/advanced_guidance/package_documentation/readers.md index 6a944d3..4f19571 100644 --- a/docs/advanced_guidance/package_documentation/readers.md +++ b/docs/advanced_guidance/package_documentation/readers.md @@ -2,7 +2,7 @@ === "Base" - ::: src.dve.core_engine.backends.readers.csv.CSVFileReader + ::: dve.core_engine.backends.readers.csv.CSVFileReader options: heading_level: 3 merge_init_into_class: true @@ -10,19 +10,19 @@ === "DuckDB" - ::: src.dve.core_engine.backends.implementations.duckdb.readers.csv.DuckDBCSVReader + ::: dve.core_engine.backends.implementations.duckdb.readers.csv.DuckDBCSVReader options: heading_level: 3 members: - __init__ - ::: src.dve.core_engine.backends.implementations.duckdb.readers.csv.PolarsToDuckDBCSVReader + ::: dve.core_engine.backends.implementations.duckdb.readers.csv.PolarsToDuckDBCSVReader options: heading_level: 3 members: - __init__ - ::: src.dve.core_engine.backends.implementations.duckdb.readers.csv.DuckDBCSVRepeatingHeaderReader + ::: dve.core_engine.backends.implementations.duckdb.readers.csv.DuckDBCSVRepeatingHeaderReader options: heading_level: 3 members: @@ -30,7 +30,7 @@ === "Spark" - ::: src.dve.core_engine.backends.implementations.spark.readers.csv.SparkCSVReader + ::: dve.core_engine.backends.implementations.spark.readers.csv.SparkCSVReader options: heading_level: 3 members: @@ -40,7 +40,7 @@ === "DuckDB" - ::: src.dve.core_engine.backends.implementations.duckdb.readers.json.DuckDBJSONReader + ::: dve.core_engine.backends.implementations.duckdb.readers.json.DuckDBJSONReader options: heading_level: 3 members: @@ -48,7 +48,7 @@ === "Spark" - ::: src.dve.core_engine.backends.implementations.spark.readers.json.SparkJSONReader + ::: dve.core_engine.backends.implementations.spark.readers.json.SparkJSONReader options: heading_level: 3 members: @@ -58,7 +58,7 @@ === "Base" - ::: src.dve.core_engine.backends.readers.xml.BasicXMLFileReader + ::: dve.core_engine.backends.readers.xml.BasicXMLFileReader options: heading_level: 3 merge_init_into_class: true @@ -66,7 +66,7 @@ === "DuckDB" - ::: src.dve.core_engine.backends.implementations.duckdb.readers.xml.DuckDBXMLStreamReader + ::: dve.core_engine.backends.implementations.duckdb.readers.xml.DuckDBXMLStreamReader options: heading_level: 3 members: @@ -74,13 +74,13 @@ === "Spark" - ::: src.dve.core_engine.backends.implementations.spark.readers.xml.SparkXMLStreamReader + ::: dve.core_engine.backends.implementations.spark.readers.xml.SparkXMLStreamReader options: heading_level: 3 members: - __init__ - ::: src.dve.core_engine.backends.implementations.spark.readers.xml.SparkXMLReader + ::: dve.core_engine.backends.implementations.spark.readers.xml.SparkXMLReader options: heading_level: 3 members: diff --git a/docs/user_guidance/auditing.md b/docs/user_guidance/auditing.md index 6d6a101..7c54533 100644 --- a/docs/user_guidance/auditing.md +++ b/docs/user_guidance/auditing.md @@ -3,16 +3,17 @@ tags: - Auditing --- -The Auditing objects within the DVE are used to help control and store information about a given submission and what stage it's currently at. In addition to the above, it's also used to store statistics about the submission and the number of validations it has triggered etc. So, for users not interested in using the Error reports stage, you could source information directly from the audit tables. +The Auditing objects within the DVE are used to help control and store information about submitted data and what stage it's currently at. In addition to the above, it's also used to store statistics about the submission and the number of validations it has triggered etc. So, for users not interested in using the Error reports stage, you could source information directly from the audit tables. ## Audit Tables + Currently, these are the audit tables that can be accessed within the DVE: | Table Name | Purpose | | --------------------- | ------- | -| processing_status | Contains information about the submission and what the current processing status is. | -| submission_info | Contains information about the submitted file. | -| submission_statistics | Contains validation statistics for each submission. | +| `processing_status` | Contains information about the submission and what the current processing status is. | +| `submission_info` | Contains information about the submitted file. | +| `submission_statistics` | Contains validation statistics for each submission. | ## Audit Objects diff --git a/docs/user_guidance/business_rules.md b/docs/user_guidance/business_rules.md index 54def88..1dad0f3 100644 --- a/docs/user_guidance/business_rules.md +++ b/docs/user_guidance/business_rules.md @@ -2,6 +2,9 @@ title: Business Rules tags: - Business Rules + - dischema + - Rule Store + - Reference Data --- The Business Rules section contain the rules you want to apply to your dataset. Rule logic might include... @@ -14,7 +17,7 @@ All rules are written in `SQL`. Depending on which [backend implementation](./im When writing the rules, you need to be aware that the expressions are wrapped in `NOT` expression. So, you should write the rules as though you are looking for non problematic values. -When rules are being applied, [Complex Rules](./business_rules.md#complex-rules) are always applied before [Rules](./business_rules.md#rules) and [Filters](./business_rules.md#filters) +When rules are being applied, [Complex Rules](./business_rules.md#complex-rules) are always applied before [Rules](./business_rules.md#rules) and [Filters](./business_rules.md#filters). This page is meant to give you greater details on how you can write your Business Rules. If you want a summary of how the Business Rules work, then please refer to the [Getting Started](./getting_started.md#rules-configuration-introduction) page. @@ -125,7 +128,7 @@ If you need to perform more complex rules, with pre-steps, then see the [Complex ### Types of rejections -You may have noticed the three type of rejections in the example above. For any given rule you can reject a record, the whole file (submission) or just raise a warning. More details in table below: +You may have noticed the field "failure_type" in the example above. For any given rule (filter) you can reject a record, the whole file (submission) or just raise a warning. Here are the details around the currently supported Rejection Types: | Rejection Type | Behaviour | How to set in the rule | | -------------- | --------- | ---------------------- | diff --git a/docs/user_guidance/data_contract.md b/docs/user_guidance/data_contract.md index 21ba19c..fee4e87 100644 --- a/docs/user_guidance/data_contract.md +++ b/docs/user_guidance/data_contract.md @@ -6,7 +6,7 @@ tags: - Domain Types --- -The Data Contract describes the structure (models) of your data and controls how the data should be typecasted. We use [Pydantic](https://docs.pydantic.dev/1.10/) to generate and validate the models. This page is meant to give you greater details on how you should write your Data Contract. If you want a summary of how the Data Contract works, please refer to the [Getting Started](./getting_started.md#rules-configuration-introduction) page. +The Data Contract defines the structure (models) of your data and controls how it is typecast. We use [Pydantic](https://docs.pydantic.dev/1.10/) to generate and validate the models. This page is meant to give you greater details on how you should write your Data Contract. If you want a summary of how the Data Contract works, please refer to the [Getting Started](./getting_started.md#rules-configuration-introduction) page. !!! Note diff --git a/docs/user_guidance/file_transformation.md b/docs/user_guidance/file_transformation.md index 89013ef..74be375 100644 --- a/docs/user_guidance/file_transformation.md +++ b/docs/user_guidance/file_transformation.md @@ -69,7 +69,7 @@ The File Transformation stage within the DVE is used to convert submitted files } ``` -The secondary use of the File Transformation stage is the ability to normalise your data into multiple entities. Imagine you had something like Hospital and Patient data in a single submission. You could split this out into seperate entities so that the validated outputs of the data could be loaded into seperate tables. For example: +The secondary use of the File Transformation stage is the ability to normalise your data into multiple entities. Imagine you had something like Hospital and Patient data in a single submission. You could split this out into seperate entities so that the validated outputs of the data could be loaded into seperate tables (parquet). For example: === "DuckDB" diff --git a/docs/user_guidance/getting_started.md b/docs/user_guidance/getting_started.md index 7ce276a..1993346 100644 --- a/docs/user_guidance/getting_started.md +++ b/docs/user_guidance/getting_started.md @@ -9,7 +9,7 @@ tags: ## Rules Configuration Introduction -To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `contract` (data contract) - this describes the structure of your data and controls how the data should be typecasted. For example, here is a dischema document describing how the DVE might validate data about a movies dataset: +To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `contract` (data contract) - this defines the structure of your data and determines how it is modeled and typecast. For example, here is a dischema document describing how the DVE may validate data about a movies: !!! example "Example `movies.dischema.json`" @@ -72,7 +72,7 @@ For each dataset definition, you will need to provide a `reader_config` which de To learn more about how you can construct your Data Contract please read [here](data_contract.md). -The second part of the dischema are the `business_rules` *or* `tranformations`. This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you've choosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). +The second part of the dischema are the `tranformations` (business_rules). This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you've choosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). !!! example "Example `movies.dischema.json`" ```json @@ -90,7 +90,7 @@ The second part of the dischema are the `business_rules` *or* `tranformations`. } } ``` -You may look at the expression above and think "Hang on! That's the opposite of what you want! You're only getting movies less than 4 hours!", however, all validation rules are wrapped inside a `NOT` expression. So, you write the rules as though you are looking for non problematic values. +You may look at the expression above and think "Hang on! That's the opposite of what you want! You're only getting movies less than 4 hours!", __however, all validation rules are wrapped inside a `NOT` expression__. So, you write the rules as though you are looking for non problematic values. We also offer a feature called `complex_rules`. These are rules where you need to transform the data before you can apply the rule. For instance, you may want to perform a join, aggregate the data, or perform a filter. The complex rules allow you to combine "pre-steps" before you perform the validation. diff --git a/docs/user_guidance/implementations/duckdb.md b/docs/user_guidance/implementations/duckdb.md index 584f1d0..42caa1a 100644 --- a/docs/user_guidance/implementations/duckdb.md +++ b/docs/user_guidance/implementations/duckdb.md @@ -111,7 +111,7 @@ DuckDBRefDataLoader.connection = db_con DuckDBRefDataLoader.dataset_config_uri = Path("path", "to", "my", "rules").as_posix() ``` -The connection passed into the `DuckDBRefDataLoader` object will then be able use various DuckDB readers to load data from an existing table on the connection OR loading data from reference data persisted in either `parquet` or `pyarrow` format. +The connection passed into the `DuckDBRefDataLoader` object will then be able to use various DuckDB readers to load data from an existing table on the connection OR loading data from reference data persisted in either `parquet` or `pyarrow` format. If you want to learn more about the reference data loaders, you can view the advanced user guidance [here](../../advanced_guidance/package_documentation/refdata_loaders.md). diff --git a/docs/user_guidance/install.md b/docs/user_guidance/install.md index e476728..00d7a9c 100644 --- a/docs/user_guidance/install.md +++ b/docs/user_guidance/install.md @@ -17,27 +17,27 @@ You can install the DVE package through python package managers such as [pip](ht === "pip" ```sh - pip install git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + pip install data-validation-engine ``` === "pipx" ```sh - pipx install git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + pipx install data-validation-engine ``` === "uv" Add to your existing `uv` project... ```sh - uv add git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + uv add data-validation-engine ``` ...or you can add via your `pyproject.toml`... ```toml dependencies = [ - nhs-dve @ https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + data-validation-engine ] ``` @@ -53,14 +53,14 @@ You can install the DVE package through python package managers such as [pip](ht Add to your existing `poetry` project... ```sh - poetry add git+https://github.com/NHSDigital/data-validation-engine.git@vMaj.Min.Pat + poetry add data-validation-engine ``` ...or you can add via your `pyproject.toml`... ```toml [tool.poetry.dependencies] - nhs-dve = { git = "https://github.com/NHSDigital/data-validation-engine.git", tag = "vMaj.Min.Pat" } + data-validation-engine = "*" ``` ```sh @@ -71,11 +71,8 @@ You can install the DVE package through python package managers such as [pip](ht poetry install ``` -!!! note - Replace `Maj.Min.Pat` with the version of the DVE you want. We recommend the latest release if you're just starting with the DVE. - !!! info - We are working on getting the DVE available via PyPi and Conda. We will update this page with the relevant instructions once this has been successfully setup. + We are working on getting the DVE available via Conda. We will update this page with the relevant instructions once this has been successfully setup. Python dependencies are listed in the [`pyproject.toml`](https://github.com/NHSDigital/data-validation-engine/blob/main/pyproject.toml). Many of the dependencies are locked to quite restrictive versions due to complexity of this package. Core packages such as Pydantic, Pyspark and DuckDB are unlikely to receive flexible version constraints as changes in those packages could cause the DVE to malfunction. For less important dependencies, we have tried to make the contraints more flexible. Therefore, we would advise you to install the DVE into a seperate environment rather than trying to integrate it into an existing Python environment. @@ -84,8 +81,8 @@ Once you have installed the DVE you are almost ready to use it. To be able to ru ## DVE Version Compatability Matrix -| DVE Version | Python Version | DuckDB Version | Spark Version | -| ------------ | -------------- | -------------- | ------------- | -| >=0.6 | >=3.10,<3.12 | 1.1.* | 3.4.* | -| >=0.2,<0.6 | >=3.10,<3.12 | 1.1.0 | 3.4.4 | -| 0.1 | >=3.7.2,<3.8 | 1.1.0 | 3.2.1 | +| DVE Version | Python Version | DuckDB Version | Spark Version | Pydantic Version | +| ------------ | -------------- | -------------- | ------------- | ---------------- | +| >=0.6 | >=3.10,<3.12 | 1.1.* | 3.4.* | 1.10.15 | +| >=0.2,<0.6 | >=3.10,<3.12 | 1.1.0 | 3.4.4 | 1.10.15 | +| 0.1 | >=3.7.2,<3.8 | 1.1.0 | 3.2.1 | 1.10.15 | From 1b25735a6076f9100fa48576a7086609addf81db Mon Sep 17 00:00:00 2001 From: "george.robertson1" <50412379+georgeRobertson@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:20:36 +0000 Subject: [PATCH 5/5] docs: spag and other fixes --- .../package_documentation/models.md | 6 ++++ docs/index.md | 9 ++---- docs/user_guidance/auditing.md | 11 ++++--- docs/user_guidance/business_rules.md | 14 ++++----- docs/user_guidance/data_contract.md | 4 +-- docs/user_guidance/file_transformation.md | 2 +- docs/user_guidance/getting_started.md | 8 ++--- docs/user_guidance/implementations/duckdb.md | 3 +- docs/user_guidance/implementations/spark.md | 31 +++++++++++++++++-- zensical.toml | 1 + 10 files changed, 59 insertions(+), 30 deletions(-) create mode 100644 docs/advanced_guidance/package_documentation/models.md diff --git a/docs/advanced_guidance/package_documentation/models.md b/docs/advanced_guidance/package_documentation/models.md new file mode 100644 index 0000000..42e9420 --- /dev/null +++ b/docs/advanced_guidance/package_documentation/models.md @@ -0,0 +1,6 @@ + +::: dve.core_engine.models + handler: python + options: + show_root_heading: true + heading_level: 2 diff --git a/docs/index.md b/docs/index.md index dea5827..ba827ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ tags: # Data Validation Engine -The Data Validation Engine (DVE) is a configuration driven data validation library written in [Python](https://www.python.org/), [Pydantic](https://docs.pydantic.dev/latest/) and a SQL backend currently consisting of [DuckDB](https://duckdb.org/) or [Spark](https://spark.apache.org/sql/). The configuration to run validations against a dataset are defined and written in a json document, which we will be referring to as the "dischema". The rules written within the dischema are designed to be run against all incoming data in a given submission - as this allows the DVE to capture all possible issues with the data without the submitter having resubmit the same data repeatedly which is burdensome and time consuming for the submitter and receiver of the data. Additionally, the rules can be configured to have the following behaviour: +The Data Validation Engine (DVE) is a configuration driven data validation library written in [Python](https://www.python.org/), [Pydantic](https://docs.pydantic.dev/latest/) and a SQL backend currently consisting of [DuckDB](https://duckdb.org/) or [Spark](https://spark.apache.org/sql/). The configuration to run validations against a dataset are defined and written in a json document, which we will be referring to as the "dischema". The rules written within the dischema are designed to be run against all incoming data in a given submission - as this allows the DVE to capture all possible issues with the data without the submitter having to resubmit the same data repeatedly which is burdensome and time consuming for both the submitter and receiver of the data. Additionally, the rules can be configured to have the following behaviour: - **File Rejection** - The entire submission will be rejected if the given rule triggers one or more times. - **Row Rejection** - The row that triggered the rule will be rejected. Rows that pass the validation will be flowed through into a validated entity. @@ -23,16 +23,13 @@ The DVE has 3 core components: 1. [File Transformation](user_guidance/file_transformation.md) - Parsing submitted files into a "stringified" (all fields casted to string) parquet format. - ???+ tip - If your files are already in a parquet format, you do not need to use the file transformation and you can move straight onto the Data Contract. - -2. [Data Contract](user_guidance/data_contract.md) - Validates submitted data against a specified datatype and casts successful records to that type. +2. [Data Contract](user_guidance/data_contract.md) - Validates submitted data against a specified datatypes and casts successful records to those types. Additionally providing modelling of your data as well. 3. [Business rules](user_guidance/business_rules.md) - Performs simple and complex validations such as comparisons between fields, entities and/or lookups against reference data. For each component listed above, a [feedback message](user_guidance/feedback_messages.md) is generated whenever a rule is violated. These [feedback messages](user_guidance/feedback_messages.md) can be integrated directly into your system given you can consume `JSONL` files. Alternatively, we offer a fourth component called the [Error Reports](user_guidance/error_reports.md). This component will load the [feedback messages](user_guidance/feedback_messages.md) into an `.xlsx` (Excel) file which could be sent back to the submitter of the data. The excel file is compatible with services that offer spreadsheet reading such as [Microsoft Excel](https://www.microsoft.com/en/microsoft-365/excel), [Google Docs](https://docs.google.com/), [Libre Office Calc](https://www.libreoffice.org/discover/calc/) etc. -To be able to run the DVE out of the box, you will need to choose and install one of the supported Backend Implementations such as [DuckDB](user_guidance/implementations/duckdb.md) or [Spark](user_guidance/implementations/spark.md). If you to need a write a custom backend implementation, you may want to look at the [Advanced User Guidance](advanced_guidance/backends.md) section. +DVE currently comes with two supported backend implementations. These are [DuckDB](user_guidance/implementations/duckdb.md) and [Spark](user_guidance/implementations/spark.md). If you to need a write a custom backend implementation, you may want to look at the [Advanced User Guidance](advanced_guidance/new_backend.md) section. Feel free to use the Table of Contents on the left hand side of the page to navigate to sections of interest or to use the "Next" and "Previous" buttons at the bottom of each page if you want to read through each page in sequential order. diff --git a/docs/user_guidance/auditing.md b/docs/user_guidance/auditing.md index 7c54533..445dbaf 100644 --- a/docs/user_guidance/auditing.md +++ b/docs/user_guidance/auditing.md @@ -9,11 +9,12 @@ The Auditing objects within the DVE are used to help control and store informati Currently, these are the audit tables that can be accessed within the DVE: -| Table Name | Purpose | -| --------------------- | ------- | -| `processing_status` | Contains information about the submission and what the current processing status is. | -| `submission_info` | Contains information about the submitted file. | -| `submission_statistics` | Contains validation statistics for each submission. | +| Table Name | Purpose | When Available | +| ----------------------- | ------- | -------------- | +| `processing_status` | Contains information about the submission and what the current processing status is. | >= File Transformation | +| `submission_info` | Contains information about the submitted file. | >= File Transformation | +| `submission_statistics` | Contains validation statistics for each submission. | >= Error Reports | +| `aggregates` | Contains aggregate counts of errors triggered for a submission | >= Error Reports | ## Audit Objects diff --git a/docs/user_guidance/business_rules.md b/docs/user_guidance/business_rules.md index 1dad0f3..e7bd8b6 100644 --- a/docs/user_guidance/business_rules.md +++ b/docs/user_guidance/business_rules.md @@ -15,7 +15,7 @@ The Business Rules section contain the rules you want to apply to your dataset. All rules are written in `SQL`. Depending on which [backend implementation](./implementations/) you have choosen, the syntax might be different between implementations. -When writing the rules, you need to be aware that the expressions are wrapped in `NOT` expression. So, you should write the rules as though you are looking for non problematic values. +When writing the rules, you need to be aware that the expressions are negated (wrapped in a `NOT` expression). So, you should write the rules as though you are looking for non problematic values. When rules are being applied, [Complex Rules](./business_rules.md#complex-rules) are always applied before [Rules](./business_rules.md#rules) and [Filters](./business_rules.md#filters). @@ -46,7 +46,7 @@ For the simplest rules, you can write them in the filters section. For example, { "entity": "movies", "name": "Ensure movie is less than 4 hours long", - "expression": "duration_minutes > 240", + "expression": "duration_minutes < 240", "failure_type": "record", "error_code": "MOVIE_TOO_LONG", "failure_message": "Movie must be less than 4 hours long.", @@ -77,7 +77,7 @@ For the simplest rules, you can write them in the filters section. For example, { "entity": "movies", "name": "Ensure movie is less than 4 hours long", - "expression": "duration_minutes > 240", + "expression": "duration_minutes < 240", "failure_type": "submission", "error_code": "MOVIE_TOO_LONG", "failure_message": "Movie must be less than 4 hours long.", @@ -108,7 +108,7 @@ For the simplest rules, you can write them in the filters section. For example, { "entity": "movies", "name": "Ensure movie is less than 4 hours long", - "expression": "duration_minutes > 240", + "expression": "duration_minutes < 240", "failure_type": "record", "is_informational": true, "error_code": "MOVIE_TOO_LONG", @@ -205,7 +205,7 @@ The difference between modifiying the existing entity and adding a new one is si !!! warning - If you add columns to an existing entity defined within the contract, that column will be written out with the projected entity. To get around this, you will either need to create new entities *or* you can see the [post rule logic](./business_rules.md#post-rule) section to remove the column. + When adding new columns to an existing entity these will be projected in the final entity. This might be something that you want and have intended (derived fields) but if not, you will need to write [post rule logic](./business_rules.md#post-rule) section to remove the column. ### Operations @@ -215,7 +215,7 @@ For a full list of operations that you can perform during the pre-steps see [Adv When a Business Rule has been finished, "post step rules" can be run. This is useful in situtations where you've created lots of new entities *or* you have added lots of new columns to existing entities. -For new entities, it's advised that you always remove them. In instances where you have derived new columns for existing entities you may not want them to persist the columns in the projected assets. The code snippets below showcases how you can remove columns and new entities: +For new entities, you may not want to persist these in final outputs. If this is the case, then you can add post rules to remove the entity entirely or just a column in any existing entity (other than refdata entities). The code snippets below showcases how you can remove columns and new entities: === "New Column Removal" @@ -362,7 +362,7 @@ For latest supported reference data types, see [Advanced User Guidance: Referenc ## Complex Rules -Complex Rules are recommended when you need to perform a number of "pre-step" operations before you can apply a business rule (filter). For instance, if you needed to add a column, filter and then join you would need to add all these steps into your [Rules](./business_rules.md#rules) section. This might be ok, if you only need a small number of pre-steps or only have a couple of rules. However, when you have lots of rules and more than 1 have a number of operations required, it's best to place these into a [Rulestore](./business_rules.md#rule-stores) and reference them within the complex rules. Otherwise, you could start to make the dischema document completely unmaintainable. +Complex Rules are recommended when you need to perform a number of "pre-step" operations before you can apply a business rule (filter). For instance, if you needed to add a column, filter and then join you would need to add all these steps into your [Rules](./business_rules.md#rules) section. This might be ok, if you only need a small number of pre-steps or only have a couple of rules. However, when you have lots of rules and more than 1 have a number of operations required, it's best to place these into a [Rulestore](./business_rules.md#rule-stores) and reference them within the complex rules. Rules Stores also have other benefits that you can read [here](./business_rules.md#rule-stores). Here is an example of defining a complex rule: diff --git a/docs/user_guidance/data_contract.md b/docs/user_guidance/data_contract.md index fee4e87..b49e12f 100644 --- a/docs/user_guidance/data_contract.md +++ b/docs/user_guidance/data_contract.md @@ -71,7 +71,7 @@ The models within the Data Contract are written under the `datasets` key. For ex } ``` -From the example above, we've built two models from the source data which in turn will provide two seperated entities to work with in the business rules and how the data will be written out at the end of the process. Those models being `"movie"` and `"cast"` with `fields` specifying the name of the columns and the data type they should be casted to. We will look into [data types later in this page](data_contract.md#types). +From the example above, we've built two models from the source data which in turn will provide two seperated entities to work with in the business rules and how the data will be written out at the end of the process. Those models being `"movie"` and `"cast"` with `fields` specifying the name of the columns and the data type they should be cast to. We will look into [data types later in this page](data_contract.md#types). ### Mandatory Fields @@ -213,7 +213,7 @@ Within the `fields` section of the contract you must define what data type a giv ### Constraints -Given the DVE supports Pydantic types, you can use any of the [constrained types available](https://docs.pydantic.dev/1.10/usage/types/#constrained-types). The docs will also show you what `kwarg` arguments are available for each constraint such as min/max length, regex patterns etc. +Given the DVE supports Pydantic types, you can use any of the [constrained types available](https://docs.pydantic.dev/1.10/usage/types/#constrained-types). The Pydantic docs will also show you what `kwarg` arguments are available for each constraint such as min/max length, regex patterns etc. For example, if you wanted to use a `constr` type for a field, you would define it like this: diff --git a/docs/user_guidance/file_transformation.md b/docs/user_guidance/file_transformation.md index 74be375..41dc7de 100644 --- a/docs/user_guidance/file_transformation.md +++ b/docs/user_guidance/file_transformation.md @@ -7,7 +7,7 @@ tags: - Readers --- -The File Transformation stage within the DVE is used to convert submitted files to stringified parquet format. This is critical as the rest of the stages within the DVE are reliant on the data being in parquet format. [Parquet was choosen as it's a very efficient column oriented format](https://www.databricks.com/glossary/what-is-parquet). When specifying which formats you are expecting, you will define it in your dischema like this: +The File Transformation stage within the DVE is used to convert submitted files to stringified parquet format. This is critical as the rest of the stages within the DVE are reliant on the data being in parquet format. [Parquet was chosen as it's a very efficient column oriented format](https://www.databricks.com/glossary/what-is-parquet). When specifying which formats you are expecting, you will define it in your dischema like this: === "DuckDB" diff --git a/docs/user_guidance/getting_started.md b/docs/user_guidance/getting_started.md index 1993346..e938c2d 100644 --- a/docs/user_guidance/getting_started.md +++ b/docs/user_guidance/getting_started.md @@ -9,7 +9,7 @@ tags: ## Rules Configuration Introduction -To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `contract` (data contract) - this defines the structure of your data and determines how it is modeled and typecast. For example, here is a dischema document describing how the DVE may validate data about a movies: +To use the DVE you will need to create a dischema document. The dischema document describes how the DVE should validate your data. It's divided into two primary parts. The first part is the `contract` (data contract) - this defines the structure of your data and determines how it is modeled and typecast. For example, here is a dischema document describing how the DVE may validate data about movies: !!! example "Example `movies.dischema.json`" @@ -63,16 +63,16 @@ Within the example above, there are two parent keys - `schemas` and `datasets`. `schemas` allow you to define custom complex data types. So, in the example above, the field `cast` would be expecting an array of structs containing the actors name, role and the date they joined the movie. -`datasets` describe the actual models for the entities you want to load. In the example above, we only want to load a single entity called `movies` which contains the fields `title, year, genre, duration_minutes, ratings and cast`. However, you could load the complex type `cast` into a seperate entity if you wanted to split your data into seperate entities. This can be useful in situations where a given entity has all the information you need to perform a given validation rule against, making the performance of rule faster & more efficient as there's less data to scan in a given entity. +`datasets` describe the actual models for the entities you want to load. In the example above, we only want to load a single entity called `movies` which contains the fields `title, year, genre, duration_minutes, ratings and cast`. However, you could load the complex type `cast` into a seperate entity if you wanted. This can be useful in situations where a given entity has all the information you need to perform a given validation rule against, making the performance of rule faster & more efficient as there's less data to scan in a given entity. !!! note The "splitting" of entities is considerably more useful in situtations where you want to normalise/de-normalise your data. If you're unfamiliar with this concept, you can read more about it [here](https://en.wikipedia.org/wiki/Database_normalization). However, you should keep in mind potential performance impacts of doing this. If you have rules that requires fields from different entities, you will have to perform a `join` between the split entities to be able to perform the rule. -For each dataset definition, you will need to provide a `reader_config` which describes how to load the data during the [File Transformation](file_transformation.md) stage. So, in the example above, we expect `movies` to come in as a `JSON` file. However, you can add more readers if you have the same data in different data formats (e.g. `csv`, `xml`, `json`). Regardless, of what they submit, the [File Transformation](file_transformation.md) stage will turn their submissions into a "stringified" parquet format which is a requirement for the subsequent stages. +For each dataset definition, you will need to provide a `reader_config` which describes how to load the data during the [File Transformation](file_transformation.md) stage. So, in the example above, we expect `movies` to come in as a `JSON` file. However, you can add more readers if you have the same data in different data formats (e.g. `csv`, `xml`, `json`). Regardless of what file format, the [File Transformation](file_transformation.md) stage will convert the submitted data into a "stringified" parquet format which is a requirement for the subsequent stages. To learn more about how you can construct your Data Contract please read [here](data_contract.md). -The second part of the dischema are the `tranformations` (business_rules). This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you've choosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). +The second part of the dischema are the `tranformations` (Business Rules). This section describes the validation rules you want to apply to entities defined within the `contract`. For example, with our `movies` dataset above, we may want to check that movies in this dataset are less than 4 hours long. The expression to write this check is written in SQL and that syntax may change slightly depending on the SQL backend you've chosen (we currently support [DuckDB](implementations/duckdb.md) and [Spark SQL](implementations/spark.md)). !!! example "Example `movies.dischema.json`" ```json diff --git a/docs/user_guidance/implementations/duckdb.md b/docs/user_guidance/implementations/duckdb.md index 42caa1a..ac75c58 100644 --- a/docs/user_guidance/implementations/duckdb.md +++ b/docs/user_guidance/implementations/duckdb.md @@ -36,8 +36,7 @@ Now you have the DuckDB connection object setup, you are ready to setup the requ ## Generating SubmissionInfo objects -Before we utilise the DVE, we need to generate an iterable object containing `SubmissionInfo` objects. These objects effectively contain the necessery metadata for the DVE to work with a given submission. Here is an example function used to generate SubmissionInfo objects from a given path: - +Before we utilise the DVE, we need to generate an iterable object containing `SubmissionInfo` objects. These objects effectively contain the necessery metadata for the DVE to work with a given submission. Here is an example function used to generate [SubmissionInfo](../../advanced_guidance/package_documentation/models.md#dve.core_engine.models.SubmissionInfo) objects from a given path: ```py import glob from datetime import date, datetime diff --git a/docs/user_guidance/implementations/spark.md b/docs/user_guidance/implementations/spark.md index 75e1f5e..23b82d9 100644 --- a/docs/user_guidance/implementations/spark.md +++ b/docs/user_guidance/implementations/spark.md @@ -9,16 +9,41 @@ You can read more about Spark here with the following links: ## Setting up a Spark Session -For a basic Spark Session setup, you can use the following snippet of code: +!!! note + + The Audit Tables require delta package available with Spark. The example below will include that. + +For a minimal working Spark Session setup with DVE, you can use the following snippet of code: ```py -spark = SparkSession.builder.appName("SimpleApp").getOrCreate() +import os +import tempfile +from pyspark.sql import SparkSession + +def get_spark_session() -> SparkSession: + """Get a configured Spark Session. This MUST be called before any other Spark session is created.""" + temp_dir = tempfile.mkdtemp() + os.environ["PYSPARK_SUBMIT_ARGS"] = " ".join( + [ + "--packages", + "com.databricks:spark-xml_2.12:0.16.0,io.delta:delta-core_2.12:2.4.0", + "pyspark-shell", + ] + ) + spark_session = ( + SparkSession.builder.config("spark.sql.warehouse.dir", temp_dir) + .config("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") + .config( + "spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog" + ) + .getOrCreate() + ) ``` You can learn more about setting up a Spark Session [here](https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.SparkSession.html). !!! warning - If you need to load XML data and the version of spark you're running is <4.0.0, you'll need the `spark-xml` extension. You can read more about it [here](https://github.com/databricks/spark-xml). + If you need to load XML data and the version of spark you're running is < 4.0.0, you'll need the `spark-xml` extension. You can read more about it [here](https://github.com/databricks/spark-xml). The snippet above shows an example of this being installed. ## Generating SubmissionInfo Objects diff --git a/zensical.toml b/zensical.toml index eb4c9af..255a771 100644 --- a/zensical.toml +++ b/zensical.toml @@ -61,6 +61,7 @@ nav = [ {"Feedback" = [ {"Feedback Messages" = "advanced_guidance/package_documentation/feedback_messages.md"}, ]}, + {"Models" = "advanced_guidance/package_documentation/models.md"}, ]}, {"DVE Developer Guidance" = [ {"Implementing a new backend" = "advanced_guidance/new_backend.md"},