diff --git a/README.md b/README.md index e94b29e..2e7690d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ Below is a list of features that we would like to implement or have been request | Uplift to Python 3.11 | 0.2.0 | Yes | | Uplift Pyspark to 3.5 | 0.8.0 | Yes | | Allow DVE to run on Python 3.12+ | 0.8.0 | Yes | -| Upgrade to Pydantic 2.0 | 0.9.0 | Yes | +| Upgrade to Pydantic 2.0 | 0.9.0 | Yes | +| Upgrade DuckDB to v1.4 | 0.10.0 | Yes | | Uplift Pyspark to 4.0+ | TBA | No | | Polars upgrade to v1+ | TBA | No | | DuckDB upgrade to v1.5+ | TBA | No | diff --git a/docs/advanced_guidance/json_schemas/dataset.schema.json b/docs/advanced_guidance/json_schemas/dataset.schema.json index 4e85011..af8b620 100644 --- a/docs/advanced_guidance/json_schemas/dataset.schema.json +++ b/docs/advanced_guidance/json_schemas/dataset.schema.json @@ -10,6 +10,9 @@ }, "transformations": { "$ref": "transformations/transformations.schema.json" + }, + "entity_relationships": { + "$ref": "entity_relationships.schema.json" } }, "required": [ diff --git a/docs/advanced_guidance/json_schemas/entity_relationships.schema.json b/docs/advanced_guidance/json_schemas/entity_relationships.schema.json new file mode 100644 index 0000000..9d4e363 --- /dev/null +++ b/docs/advanced_guidance/json_schemas/entity_relationships.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "data-ingest:entity_relationships.schema.json", + "title": "entity_relationships", + "description": "Description of relationships to link normalised entities back to parent entities.", + "type": "object", + "patternProperties": { + "^[A-Za-z0-9_]+.$": { + "type": "object", + "properties": { + "parent_entity": { + "type": "string" + }, + "join_fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "mandatory": { + "type": "boolean" + }, + "missing_parent_id_error_code": { + "type": "string" + }, + "missing_parent_id_error_message": { + "type": "string" + } + }, + "required": [ + "parent_entity", + "join_fields" + ], + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/docs/user_guidance/install.md b/docs/user_guidance/install.md index 85186cd..2c86b12 100644 --- a/docs/user_guidance/install.md +++ b/docs/user_guidance/install.md @@ -78,11 +78,12 @@ 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 | Pydantic Version | -| ------------ | -------------- | -------------- | --------------- | ---------------- | -| >=0.9.0 | >=3.10,<3.13 | 1.1.3 | >=3.5.0,<=3.5.5 | 2.13.4 | -| >=0.8.0 | >=3.10,<3.13 | 1.1.3 | 3.5.2 | 1.10.19 | -| >=0.7.2 | >=3.10,<3.12 | 1.1.* | 3.4.* | 1.10.16 | -| >=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 | +| DVE Version | Python Version | DuckDB Version | Spark Version | Pydantic Version | +| ------------ | -------------- | ---------------- | --------------- | ---------------- | +| >=0.10.0 | >=3.10,<1.13 | __>=1.4,<1.4.5__ | >=3.5.0,<=3.5.5 | 2.13.4 | +| >=0.9.0 | >=3.10,<3.13 | 1.1.3 | >=3.5.0,<=3.5.5 | __2.13.4__ | +| >=0.8.0 | >=3.10,<3.13 | __1.1.3__ | __3.5.2__ | 1.10.19 | +| >=0.7.2 | >=3.10,<3.12 | 1.1.* | 3.4.* | __1.10.16__ | +| >=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 | diff --git a/poetry.lock b/poetry.lock index 0fedfd4..95befcd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1144,66 +1144,58 @@ files = [ [[package]] name = "duckdb" -version = "1.1.3" +version = "1.4.4" description = "DuckDB in-process database" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.9.0" groups = ["main"] files = [ - {file = "duckdb-1.1.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:1c0226dc43e2ee4cc3a5a4672fddb2d76fd2cf2694443f395c02dd1bea0b7fce"}, - {file = "duckdb-1.1.3-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:7c71169fa804c0b65e49afe423ddc2dc83e198640e3b041028da8110f7cd16f7"}, - {file = "duckdb-1.1.3-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:872d38b65b66e3219d2400c732585c5b4d11b13d7a36cd97908d7981526e9898"}, - {file = "duckdb-1.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25fb02629418c0d4d94a2bc1776edaa33f6f6ccaa00bd84eb96ecb97ae4b50e9"}, - {file = "duckdb-1.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3f5cd604e7c39527e6060f430769b72234345baaa0987f9500988b2814f5e4"}, - {file = "duckdb-1.1.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08935700e49c187fe0e9b2b86b5aad8a2ccd661069053e38bfaed3b9ff795efd"}, - {file = "duckdb-1.1.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f9b47036945e1db32d70e414a10b1593aec641bd4c5e2056873d971cc21e978b"}, - {file = "duckdb-1.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:35c420f58abc79a68a286a20fd6265636175fadeca1ce964fc8ef159f3acc289"}, - {file = "duckdb-1.1.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:4f0e2e5a6f5a53b79aee20856c027046fba1d73ada6178ed8467f53c3877d5e0"}, - {file = "duckdb-1.1.3-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:911d58c22645bfca4a5a049ff53a0afd1537bc18fedb13bc440b2e5af3c46148"}, - {file = "duckdb-1.1.3-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:c443d3d502335e69fc1e35295fcfd1108f72cb984af54c536adfd7875e79cee5"}, - {file = "duckdb-1.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a55169d2d2e2e88077d91d4875104b58de45eff6a17a59c7dc41562c73df4be"}, - {file = "duckdb-1.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d0767ada9f06faa5afcf63eb7ba1befaccfbcfdac5ff86f0168c673dd1f47aa"}, - {file = "duckdb-1.1.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51c6d79e05b4a0933672b1cacd6338f882158f45ef9903aef350c4427d9fc898"}, - {file = "duckdb-1.1.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:183ac743f21c6a4d6adfd02b69013d5fd78e5e2cd2b4db023bc8a95457d4bc5d"}, - {file = "duckdb-1.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:a30dd599b8090ea6eafdfb5a9f1b872d78bac318b6914ada2d35c7974d643640"}, - {file = "duckdb-1.1.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a433ae9e72c5f397c44abdaa3c781d94f94f4065bcbf99ecd39433058c64cb38"}, - {file = "duckdb-1.1.3-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:d08308e0a46c748d9c30f1d67ee1143e9c5ea3fbcccc27a47e115b19e7e78aa9"}, - {file = "duckdb-1.1.3-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5d57776539211e79b11e94f2f6d63de77885f23f14982e0fac066f2885fcf3ff"}, - {file = "duckdb-1.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e59087dbbb63705f2483544e01cccf07d5b35afa58be8931b224f3221361d537"}, - {file = "duckdb-1.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ebf5f60ddbd65c13e77cddb85fe4af671d31b851f125a4d002a313696af43f1"}, - {file = "duckdb-1.1.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4ef7ba97a65bd39d66f2a7080e6fb60e7c3e41d4c1e19245f90f53b98e3ac32"}, - {file = "duckdb-1.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f58db1b65593ff796c8ea6e63e2e144c944dd3d51c8d8e40dffa7f41693d35d3"}, - {file = "duckdb-1.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:e86006958e84c5c02f08f9b96f4bc26990514eab329b1b4f71049b3727ce5989"}, - {file = "duckdb-1.1.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:0897f83c09356206ce462f62157ce064961a5348e31ccb2a557a7531d814e70e"}, - {file = "duckdb-1.1.3-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:cddc6c1a3b91dcc5f32493231b3ba98f51e6d3a44fe02839556db2b928087378"}, - {file = "duckdb-1.1.3-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:1d9ab6143e73bcf17d62566e368c23f28aa544feddfd2d8eb50ef21034286f24"}, - {file = "duckdb-1.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f073d15d11a328f2e6d5964a704517e818e930800b7f3fa83adea47f23720d3"}, - {file = "duckdb-1.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5724fd8a49e24d730be34846b814b98ba7c304ca904fbdc98b47fa95c0b0cee"}, - {file = "duckdb-1.1.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51e7dbd968b393343b226ab3f3a7b5a68dee6d3fe59be9d802383bf916775cb8"}, - {file = "duckdb-1.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00cca22df96aa3473fe4584f84888e2cf1c516e8c2dd837210daec44eadba586"}, - {file = "duckdb-1.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:77f26884c7b807c7edd07f95cf0b00e6d47f0de4a534ac1706a58f8bc70d0d31"}, - {file = "duckdb-1.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4748635875fc3c19a7320a6ae7410f9295557450c0ebab6d6712de12640929a"}, - {file = "duckdb-1.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74e121ab65dbec5290f33ca92301e3a4e81797966c8d9feef6efdf05fc6dafd"}, - {file = "duckdb-1.1.3-cp37-cp37m-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c619e4849837c8c83666f2cd5c6c031300cd2601e9564b47aa5de458ff6e69d"}, - {file = "duckdb-1.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0ba6baa0af33ded836b388b09433a69b8bec00263247f6bf0a05c65c897108d3"}, - {file = "duckdb-1.1.3-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:ecb1dc9062c1cc4d2d88a5e5cd8cc72af7818ab5a3c0f796ef0ffd60cfd3efb4"}, - {file = "duckdb-1.1.3-cp38-cp38-macosx_12_0_universal2.whl", hash = "sha256:5ace6e4b1873afdd38bd6cc8fcf90310fb2d454f29c39a61d0c0cf1a24ad6c8d"}, - {file = "duckdb-1.1.3-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:a1fa0c502f257fa9caca60b8b1478ec0f3295f34bb2efdc10776fc731b8a6c5f"}, - {file = "duckdb-1.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6411e21a2128d478efbd023f2bdff12464d146f92bc3e9c49247240448ace5a6"}, - {file = "duckdb-1.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5336939d83837af52731e02b6a78a446794078590aa71fd400eb17f083dda3e"}, - {file = "duckdb-1.1.3-cp38-cp38-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f549af9f7416573ee48db1cf8c9d27aeed245cb015f4b4f975289418c6cf7320"}, - {file = "duckdb-1.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:2141c6b28162199999075d6031b5d63efeb97c1e68fb3d797279d31c65676269"}, - {file = "duckdb-1.1.3-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:09c68522c30fc38fc972b8a75e9201616b96ae6da3444585f14cf0d116008c95"}, - {file = "duckdb-1.1.3-cp39-cp39-macosx_12_0_universal2.whl", hash = "sha256:8ee97ec337794c162c0638dda3b4a30a483d0587deda22d45e1909036ff0b739"}, - {file = "duckdb-1.1.3-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:a1f83c7217c188b7ab42e6a0963f42070d9aed114f6200e3c923c8899c090f16"}, - {file = "duckdb-1.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aa3abec8e8995a03ff1a904b0e66282d19919f562dd0a1de02f23169eeec461"}, - {file = "duckdb-1.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80158f4c7c7ada46245837d5b6869a336bbaa28436fbb0537663fa324a2750cd"}, - {file = "duckdb-1.1.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:647f17bd126170d96a38a9a6f25fca47ebb0261e5e44881e3782989033c94686"}, - {file = "duckdb-1.1.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:252d9b17d354beb9057098d4e5d5698e091a4f4a0d38157daeea5fc0ec161670"}, - {file = "duckdb-1.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:eeacb598120040e9591f5a4edecad7080853aa8ac27e62d280f151f8c862afa3"}, - {file = "duckdb-1.1.3.tar.gz", hash = "sha256:68c3a46ab08836fe041d15dcbf838f74a990d551db47cb24ab1c4576fc19351c"}, + {file = "duckdb-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e870a441cb1c41d556205deb665749f26347ed13b3a247b53714f5d589596977"}, + {file = "duckdb-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:49123b579e4a6323e65139210cd72dddc593a72d840211556b60f9703bda8526"}, + {file = "duckdb-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e1933fac5293fea5926b0ee75a55b8cfe7f516d867310a5b251831ab61fe62b"}, + {file = "duckdb-1.4.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:707530f6637e91dc4b8125260595299ec9dd157c09f5d16c4186c5988bfbd09a"}, + {file = "duckdb-1.4.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:453b115f4777467f35103d8081770ac2f223fb5799178db5b06186e3ab51d1f2"}, + {file = "duckdb-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a3c8542db7ffb128aceb7f3b35502ebaddcd4f73f1227569306cc34bad06680c"}, + {file = "duckdb-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5ba684f498d4e924c7e8f30dd157da8da34c8479746c5011b6c0e037e9c60ad2"}, + {file = "duckdb-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5536eb952a8aa6ae56469362e344d4e6403cc945a80bc8c5c2ebdd85d85eb64b"}, + {file = "duckdb-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47dd4162da6a2be59a0aef640eb08d6360df1cf83c317dcc127836daaf3b7f7c"}, + {file = "duckdb-1.4.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cb357cfa3403910e79e2eb46c8e445bb1ee2fd62e9e9588c6b999df4256abc1"}, + {file = "duckdb-1.4.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c25d5b0febda02b7944e94fdae95aecf952797afc8cb920f677b46a7c251955"}, + {file = "duckdb-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6703dd1bb650025b3771552333d305d62ddd7ff182de121483d4e042ea6e2e00"}, + {file = "duckdb-1.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:bf138201f56e5d6fc276a25138341b3523e2f84733613fc43f02c54465619a95"}, + {file = "duckdb-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ddcfd9c6ff234da603a1edd5fd8ae6107f4d042f74951b65f91bc5e2643856b3"}, + {file = "duckdb-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6792ca647216bd5c4ff16396e4591cfa9b4a72e5ad7cdd312cec6d67e8431a7c"}, + {file = "duckdb-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f8d55843cc940e36261689054f7dfb6ce35b1f5b0953b0d355b6adb654b0d52"}, + {file = "duckdb-1.4.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65d15c440c31e06baaebfd2c06d71ce877e132779d309f1edf0a85d23c07e92"}, + {file = "duckdb-1.4.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b297eff642503fd435a9de5a9cb7db4eccb6f61d61a55b30d2636023f149855f"}, + {file = "duckdb-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d525de5f282b03aa8be6db86b1abffdceae5f1055113a03d5b50cd2fb8cf2ef8"}, + {file = "duckdb-1.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:50f2eb173c573811b44aba51176da7a4e5c487113982be6a6a1c37337ec5fa57"}, + {file = "duckdb-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:337f8b24e89bc2e12dadcfe87b4eb1c00fd920f68ab07bc9b70960d6523b8bc3"}, + {file = "duckdb-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0509b39ea7af8cff0198a99d206dca753c62844adab54e545984c2e2c1381616"}, + {file = "duckdb-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fb94de6d023de9d79b7edc1ae07ee1d0b4f5fa8a9dcec799650b5befdf7aafec"}, + {file = "duckdb-1.4.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d636ceda422e7babd5e2f7275f6a0d1a3405e6a01873f00d38b72118d30c10b"}, + {file = "duckdb-1.4.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df7351328ffb812a4a289732f500d621e7de9942a3a2c9b6d4afcf4c0e72526"}, + {file = "duckdb-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:6fb1225a9ea5877421481d59a6c556a9532c32c16c7ae6ca8d127e2b878c9389"}, + {file = "duckdb-1.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:f28a18cc790217e5b347bb91b2cab27aafc557c58d3d8382e04b4fe55d0c3f66"}, + {file = "duckdb-1.4.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:25874f8b1355e96178079e37312c3ba6d61a2354f51319dae860cf21335c3a20"}, + {file = "duckdb-1.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:452c5b5d6c349dc5d1154eb2062ee547296fcbd0c20e9df1ed00b5e1809089da"}, + {file = "duckdb-1.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5c2d8a0452df55e092959c0bfc8ab8897ac3ea0f754cb3b0ab3e165cd79aff"}, + {file = "duckdb-1.4.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af6e76fe8bd24875dc56dd8e38300d64dc708cd2e772f67b9fbc635cc3066a3"}, + {file = "duckdb-1.4.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0440f59e0cd9936a9ebfcf7a13312eda480c79214ffed3878d75947fc3b7d6d"}, + {file = "duckdb-1.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:59c8d76016dde854beab844935b1ec31de358d4053e792988108e995b18c08e7"}, + {file = "duckdb-1.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:53cd6423136ab44383ec9955aefe7599b3fb3dd1fe006161e6396d8167e0e0d4"}, + {file = "duckdb-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8097201bc5fd0779d7fcc2f3f4736c349197235f4cb7171622936343a1aa8dbf"}, + {file = "duckdb-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd1be3d48577f5b40eb9706c6b2ae10edfe18e78eb28e31a3b922dcff1183597"}, + {file = "duckdb-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e041f2fbd6888da090eca96ac167a7eb62d02f778385dd9155ed859f1c6b6dc8"}, + {file = "duckdb-1.4.4-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7eec0bf271ac622e57b7f6554a27a6e7d1dd2f43d1871f7962c74bcbbede15ba"}, + {file = "duckdb-1.4.4-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdc4126ec925edf3112bc656ac9ed23745294b854935fa7a643a216e4455af6"}, + {file = "duckdb-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:c9566a4ed834ec7999db5849f53da0a7ee83d86830c33f471bf0211a1148ca12"}, + {file = "duckdb-1.4.4.tar.gz", hash = "sha256:8bba52fd2acb67668a4615ee17ee51814124223de836d9e2fdcbc4c9021b3d3c"}, ] +[package.extras] +all = ["adbc-driver-manager", "fsspec", "ipython", "numpy", "pandas", "pyarrow"] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -3649,4 +3641,4 @@ type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "3c6b964ad86fe375ec1862480b207189085a8d1da8e5017a8545b78dc0ff469b" +content-hash = "a2df40ebbf2383a36c3031ca7d04330dd8e60130b291cc136d25c22647e13716" diff --git a/pyproject.toml b/pyproject.toml index 0ad62d3..ddfd701 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ authors = [ ] readme = "README.md" classifiers = [ + "Development Status :: 4 - Beta", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -35,7 +36,7 @@ python = ">=3.10,<3.13" # breaking changes beyond 3.12 boto3 = ">=1.34.162,<1.36" # breaking change beyond 1.36 botocore = ">=1.34.162,<1.36" # breaking change beyond 1.36 delta-spark = ">=3.0.0,<=3.2.0" -duckdb = "1.1.3" # breaking changes beyond 1.1 +duckdb = ">=1.4,<1.4.5" Jinja2 = "3.1.6" lxml = "6.1.1" numpy = "1.26.4" diff --git a/src/dve/common/error_utils.py b/src/dve/common/error_utils.py index 120c902..23ad714 100644 --- a/src/dve/common/error_utils.py +++ b/src/dve/common/error_utils.py @@ -5,7 +5,7 @@ import logging from collections.abc import Iterable from itertools import chain -from multiprocessing import Queue +from queue import Queue from threading import Thread from typing import Optional, Union diff --git a/src/dve/core_engine/backends/base/rules.py b/src/dve/core_engine/backends/base/rules.py index 9b6b4fe..fb44dff 100644 --- a/src/dve/core_engine/backends/base/rules.py +++ b/src/dve/core_engine/backends/base/rules.py @@ -3,7 +3,7 @@ import logging from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from typing import Any, ClassVar, Generic, NoReturn, Optional, TypeVar from uuid import uuid4 @@ -17,6 +17,7 @@ ) from dve.core_engine.backends.base.core import get_entity_type from dve.core_engine.backends.exceptions import render_error +from dve.core_engine.backends.metadata.reporting import ReportingConfig from dve.core_engine.backends.metadata.rules import ( AbstractStep, Aggregation, @@ -34,6 +35,7 @@ Notification, OneToOneJoin, OrphanIdentification, + OrphanRemoval, ParentMetadata, RenameEntity, Rule, @@ -43,8 +45,11 @@ TableUnion, ) from dve.core_engine.backends.types import Entities, EntityType, StageSuccessful +from dve.core_engine.configuration.v1.hierarchy import EntityHierarchy, HierarchyNode +from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME from dve.core_engine.exceptions import CriticalProcessingError from dve.core_engine.loggers import get_logger +from dve.core_engine.message import FeedbackMessage from dve.core_engine.type_hints import URI, DVEStageName, EntityName, Messages, TemplateVariables T_contra = TypeVar("T_contra", bound=AbstractStep, contravariant=True) @@ -307,7 +312,10 @@ def join_header(self, entities: Entities, *, config: HeaderJoin) -> Messages: """ raise NotImplementedError - def identify_orphans(self, entities: Entities, *, config: OrphanIdentification) -> Messages: + @abstractmethod + def identify_orphans( + self, entities: Entities, *, config: OrphanIdentification + ) -> tuple[Messages, int]: """Identify records in an entity which don't have at least one corresponding match in the target. A new boolean column will be added to `entity` ('IsOrphaned') indicating whether the condition matched. @@ -320,6 +328,18 @@ def identify_orphans(self, entities: Entities, *, config: OrphanIdentification) """ raise NotImplementedError + @abstractmethod + def remove_orphans(self, entities: Entities, *, config: OrphanRemoval) -> Iterator: + """ + Remove orphaned records from an entity based on the orphans found in + identify_orphans method. Returns a generator objects with the records removed + for generating feedback messages from. + + This may not be implemented by some backends. + + """ + raise NotImplementedError + @abstractmethod def union(self, entities: Entities, *, config: TableUnion) -> Messages: """Union two entities together, taking the columns from each by name. @@ -352,6 +372,99 @@ def notify(self, entities: Entities, *, config: Notification) -> Messages: """ + def identify_and_remove_orphans( + self, + working_directory: URI, + entities: Entities, + entity_hierarchy: EntityHierarchy, + key_fields: Optional[dict[str, list[str]]] = None, + ) -> Messages: + """ + Identifies and removes orphan records by traversing the EntityHierarchy object. + An orphan is a child record whose parent FK does not exist in the parent entity. + Processes recursively: removes orphans at each level, then processes children. + """ + + def process_node( + node: HierarchyNode, + orph_messages: Messages | None = None, + ): + """Identify orphans and remove in a given node""" + + if orph_messages is None: + orph_messages = [] + + if node.parent_entity is not None: + self.logger.info(f"Identifying orphans in {node.entity_name}") + + join_expr = " AND ".join( + f"{node.parent_entity}.{k} = {node.entity_name}.{v}" + for k, v in node.join_fields.items() + ) + + _, no_orphs = self.identify_orphans( + entities=entities, + config=OrphanIdentification( + id=list(node.join_fields.values())[0], + entity_name=node.entity_name, + target_name=node.parent_entity, + join_condition=join_expr, + ), + ) + + if no_orphs > 0: + self.logger.info( + f"Removing records with missing parent from {node.entity_name}" + ) + location = list(node.join_fields.values())[0] + with BackgroundMessageWriter( + working_directory=working_directory, + dve_stage=self.__stage_name__, + key_fields=key_fields, + logger=self.logger, + ) as msg_writer: + _orph_records = self.remove_orphans( + entities=entities, + config=OrphanRemoval( + entity_name=node.entity_name, + reporting=ReportingConfig( + emit="record_failure", + code=node.missing_parent_id_error_code, + message=node.missing_parent_id_error_message, + location=location, + ), + ), + ) + # moved to batch the write - risky if large number of + msg_writer.write_queue.put( + [ + FeedbackMessage( + entity=node.entity_name, + record=record, # type: ignore + error_location=location, + error_message=node.missing_parent_id_error_message, + failure_type="record", + error_type="record", + error_code=node.missing_parent_id_error_code, + reporting_field=location, + category="Parent Missing", + ) + for record in _orph_records + ] + ) + + for tree in entity_hierarchy.entity_trees.values(): + for node in tree.iterate_root_down(): + process_node(node) + + _orph_rel = entities.get(ORPHANED_RECORD_ENTITY_NAME) + if _orph_rel: + del entities[ORPHANED_RECORD_ENTITY_NAME] + + entities.update(entities) + + return [] + # pylint: disable=R0912,R0914 def apply_sync_filters( self, diff --git a/src/dve/core_engine/backends/implementations/duckdb/rules.py b/src/dve/core_engine/backends/implementations/duckdb/rules.py index dc73dad..4479846 100644 --- a/src/dve/core_engine/backends/implementations/duckdb/rules.py +++ b/src/dve/core_engine/backends/implementations/duckdb/rules.py @@ -1,6 +1,6 @@ """Business rule definitions for duckdb backend""" -from collections.abc import Callable +from collections.abc import Callable, Iterator from typing import get_type_hints from uuid import uuid4 @@ -50,9 +50,11 @@ Notification, OneToOneJoin, OrphanIdentification, + OrphanRemoval, SemiJoin, TableUnion, ) +from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME, RECORD_INDEX_COLUMN_NAME from dve.core_engine.functions import implementations as functions from dve.core_engine.message import FeedbackMessage from dve.core_engine.templating import template_object @@ -364,7 +366,7 @@ def join_header(self, entities: DuckDBEntities, *, config: HeaderJoin) -> Messag ), ) - target_schema = DDBStruct(dict(zip(target_rel.columns, target_rel.dtypes)))() + target_schema = DDBStruct(dict(zip(target_rel.columns, target_rel.dtypes)))() # type: ignore # pylint:disable=C0301 joined_rel = source_rel.select( StarExpression(exclude=[]), @@ -375,8 +377,11 @@ def join_header(self, entities: DuckDBEntities, *, config: HeaderJoin) -> Messag return [] def identify_orphans( - self, entities: DuckDBEntities, *, config: OrphanIdentification - ) -> Messages: + self, + entities: DuckDBEntities, + *, + config: OrphanIdentification, + ) -> tuple[Messages, int]: """Identify records in an entity which don't have at least one corresponding match in the target. A new boolean column will be added to `entity` ('IsOrphaned') indicating whether the condition matched. @@ -390,41 +395,60 @@ def identify_orphans( target_rel: DuckDBPyRelation = entities[config.target_name] target_rel = target_rel.set_alias(config.target_name) - key_name = f"key_{uuid4().hex}" - source_rel = source_rel.select(f"*, row_number() over () as {key_name}").set_alias( - config.entity_name - ) match_name = f"matched_{uuid4().hex}" target_rel = target_rel.select( StarExpression(exclude=[]), ConstantExpression(1).alias(match_name) ).set_alias(config.target_name) - joined_rel: DuckDBPyRelation = source_rel.join( - target_rel, condition=config.join_condition, how="left" - ).aggregate(f"{key_name}, coalesce(count({match_name})==0, TRUE) AS IsOrphaned") + pk, _fk = config.join_condition.split("=") - if "IsOrphaned" not in source_rel.columns: - result: DuckDBPyRelation = source_rel.join( - joined_rel, condition=key_name, how="left" - ).select(StarExpression(exclude=[key_name])) - else: - result = source_rel.set_alias("source").join( - joined_rel.set_alias("joined"), - condition=f"source.{key_name} = joined.{key_name}", - how="left", + orphaned_rel: DuckDBPyRelation = ( + source_rel.join(target_rel, condition=config.join_condition, how="left") + .aggregate( + f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME}, {config.entity_name}.{config.id}, coalesce(count({match_name}), 0)==0 AS IsOrphaned" # pylint: disable=C0301 ) + .filter("IsOrphaned") + .select( + RECORD_INDEX_COLUMN_NAME, + ConstantExpression(config.entity_name).alias("entity_name"), + ConstantExpression(pk.strip().rsplit(".")[1]).alias("pk"), + ColumnExpression(config.id).alias("pk_value"), # type: ignore + ) + .unique("*") + ) + _orph_records: tuple[int] = orphaned_rel.count(RECORD_INDEX_COLUMN_NAME).fetchone() # type: ignore # pylint: disable=C0301 + if _orph_records: + _no_orphans = _orph_records[0] + else: + _no_orphans = 0 + self.logger.info(f"Found {_no_orphans} orphaned records in {config.entity_name}.") - columns = {name: f"source.{name}" for name in source_rel.columns} - if "IsOrphaned" in source_rel.columns: - columns["IsOrphaned"] = ColumnExpression("source.IsOrphaned") | ColumnExpression("joined.IsOrphaned") # type: ignore # pylint: disable=line-too-long - columns.pop(key_name, None) - - result = result.select( - ",".join([f"{column} as {name}" for name, column in columns.items()]) + if entities.get(ORPHANED_RECORD_ENTITY_NAME) is not None: + entities[ORPHANED_RECORD_ENTITY_NAME] = entities[ORPHANED_RECORD_ENTITY_NAME].union( + orphaned_rel ) + else: + entities[ORPHANED_RECORD_ENTITY_NAME] = orphaned_rel + return [], _no_orphans + + def remove_orphans(self, entities: DuckDBEntities, *, config: OrphanRemoval) -> Iterator: + """Method to remove identified orphans in the orphan tracker entity.""" + orphan_rel = entities[ORPHANED_RECORD_ENTITY_NAME].set_alias("orphan") + filtered_rel = ( + entities[config.entity_name] + .set_alias(config.entity_name) + .join( + orphan_rel, + f"{config.entity_name}.{RECORD_INDEX_COLUMN_NAME} = orphan.{RECORD_INDEX_COLUMN_NAME}", # pylint: disable=C0301 + "anti", + ) + ) - entities[config.new_entity_name or config.entity_name] = result - return [] + entities[config.entity_name] = filtered_rel + + return duckdb_rel_to_dictionaries( + orphan_rel.filter(f"entity_name = '{config.entity_name}'") + ) def union(self, entities: DuckDBEntities, *, config: TableUnion) -> Messages: """Union two entities together, taking the columns from each by name. diff --git a/src/dve/core_engine/backends/implementations/spark/contract.py b/src/dve/core_engine/backends/implementations/spark/contract.py index d2fd9ae..432a731 100644 --- a/src/dve/core_engine/backends/implementations/spark/contract.py +++ b/src/dve/core_engine/backends/implementations/spark/contract.py @@ -156,8 +156,9 @@ def apply_data_contract( fld, fld_info.annotation ).alias(fld) if fld in record_df.columns - else lit(None).cast( - get_type_from_annotation(fld_info.annotation)).alias(fld) + else lit(None) + .cast(get_type_from_annotation(fld_info.annotation)) + .alias(fld) ) for fld, fld_info in entity_fields.items() ], diff --git a/src/dve/core_engine/backends/implementations/spark/rules.py b/src/dve/core_engine/backends/implementations/spark/rules.py index 825ee15..ff15b52 100644 --- a/src/dve/core_engine/backends/implementations/spark/rules.py +++ b/src/dve/core_engine/backends/implementations/spark/rules.py @@ -1,6 +1,6 @@ """Step implementations in Spark.""" -from collections.abc import Callable +from collections.abc import Callable, Iterator from typing import Optional from uuid import uuid4 @@ -41,6 +41,7 @@ Notification, OneToOneJoin, OrphanIdentification, + OrphanRemoval, SelectColumns, SemiJoin, TableUnion, @@ -338,7 +339,8 @@ def union(self, entities: SparkEntities, *, config: TableUnion) -> Messages: def identify_orphans( self, entities: SparkEntities, *, config: OrphanIdentification - ) -> Messages: + ) -> tuple[Messages, int]: + # TODO - adjust this to new setup of identify and remove orphans source_df: DataFrame = entities[config.entity_name] source_df = source_df.alias(config.entity_name) target_df: DataFrame = entities[config.target_name] @@ -371,7 +373,16 @@ def identify_orphans( result = result.select(*[column.alias(name) for name, column in columns.items()]) entities[config.new_entity_name or config.entity_name] = result - return [] + return [], 0 + + def remove_orphans( + self, + entities: SparkEntities, + *, + config: OrphanRemoval, + ) -> Iterator: + # TODO - implement for spark + raise NotImplementedError def filter(self, entities: SparkEntities, *, config: ImmediateFilter) -> Messages: """Filter an entity immediately, and do not emit any messages. diff --git a/src/dve/core_engine/backends/metadata/rules.py b/src/dve/core_engine/backends/metadata/rules.py index f3a6305..ae1c4e6 100644 --- a/src/dve/core_engine/backends/metadata/rules.py +++ b/src/dve/core_engine/backends/metadata/rules.py @@ -558,6 +558,13 @@ class OrphanIdentification(AbstractConditionalJoin): """A step within a rule. This is either a rule config or the literal string 'sync'.""" +class OrphanRemoval(BaseStep): + """Remove an orphan record from the `entity`.""" + + reporting: ReportingConfig + """The reporting information for the row removal.""" + + class Rule(BaseModel): """A rule, made up of multiple steps.""" diff --git a/src/dve/core_engine/configuration/v1/__init__.py b/src/dve/core_engine/configuration/v1/__init__.py index 959596f..b9b08b6 100644 --- a/src/dve/core_engine/configuration/v1/__init__.py +++ b/src/dve/core_engine/configuration/v1/__init__.py @@ -1,9 +1,10 @@ """The loader for the first JSON-based dataset configuration.""" import json -from typing import Any, Optional, Union +from typing import Any, Optional, Type, Union -from pydantic import BaseModel, Field, PrivateAttr, validate_call +from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator, validate_call +from pydantic_core.core_schema import FieldValidationInfo from typing_extensions import Literal from dve.core_engine.backends.base.reference_data import ReferenceConfig, ReferenceConfigUnion @@ -22,7 +23,14 @@ ) from dve.core_engine.configuration.v1.steps import StepConfigUnion from dve.core_engine.message import DataContractErrorDetail -from dve.core_engine.type_hints import EntityName, ErrorCategory, ErrorType, TemplateVariables +from dve.core_engine.type_hints import ( + EntityName, + ErrorCategory, + ErrorCode, + ErrorMessage, + ErrorType, + TemplateVariables, +) from dve.core_engine.validation import RowValidator from dve.parser.file_handling import joinuri, open_stream, resolve_location from dve.parser.type_hints import URI, Extension @@ -38,6 +46,8 @@ FieldName = str """The name of a field within a model/schema.""" +JoinFields = Optional[dict[str, str]] +"""The fields required ( parent > child ) to join a child entity back to the parent""" TypeOrDef = Union[ # pylint: disable=C0103 TypeName, "_CallableTypeDefinition", "_ModelTypeDefinition", "_TypeAliasDefinition" ] @@ -81,6 +91,52 @@ class _TypeAliasDefinition(_BaseTypeDefintion): """The name of the Python type.""" +class _LinkageConfig(BaseModel): + """Specify how to link entities back to parents if required""" + + parent_entity: Optional[EntityName] = None + """The name of the parent entity""" + join_fields: JoinFields = Field(default_factory=dict) + """The fields that can be used to link back to the parent entity""" + is_root_entity: bool = False + """Whether the entity is the highest level parent in a tree""" + mandatory: bool = False + """If the entity is a child, is it a mandatory field of the parent""" + no_valid_records_error_code: Optional[ErrorCode] = "NoValidRecords" + """The error code to emit if the entity has no valid records and is mandatory in the parent entity""" # pylint: disable=C0301 + no_valid_records_error_message: Optional[ErrorMessage] = ( + "parent record removed as no valid child records" + ) + """The error message to emit if the entity has no valid records and is mandatory in the parent entity""" # pylint: disable=C0301 + missing_parent_id_error_code: Optional[ErrorCode] = "MissingParentRecord" + """The error code to emit if the entity contains records that are orphaned by parent record rejections""" # pylint: disable=C0301 + missing_parent_id_error_message: Optional[ErrorMessage] = ( + "Records removed due to no valid parent record" + ) + """The error code to emit if the entity contains records that are orphaned by parent record rejections""" # pylint: disable=C0301 + + @model_validator(mode="after") + def _check_root_no_parent_or_join_keys(self): + if self.is_root_entity and (self.parent_entity or self.join_fields): + raise ValueError( + "If entity is root, neither parent_entity nor join keys should be specified" + ) + return self + + @model_validator(mode="after") + def _check_root_mandatory(self): + if self.is_root_entity and not self.mandatory: + raise ValueError("If entity is root, it must be labelled mandatory") + return self + + @model_validator(mode="after") + def _check_parent_entity_with_join_keys(self): + if self.parent_entity or self.join_fields: + if not (self.parent_entity and self.join_fields): + raise ValueError("Both parent_entity and join_fields must be supplied if one is") + return self + + class _SchemaConfig(BaseModel): """Configuration for a component schema within a dataset.""" @@ -177,6 +233,8 @@ class V1EngineConfig(BaseEngineConfig): default_factory=dict ) """Rule store rules from the loaded rule stores.""" + entity_relationships: dict[EntityName, _LinkageConfig] = Field(default_factory=dict) + """The parent-child relationships linking the defined entities""" @validate_call def _update_rule_store(self, rule_store: dict[RuleName, BusinessComponentSpecConfigUnion]): diff --git a/src/dve/core_engine/configuration/v1/hierarchy.py b/src/dve/core_engine/configuration/v1/hierarchy.py new file mode 100644 index 0000000..3da9f56 --- /dev/null +++ b/src/dve/core_engine/configuration/v1/hierarchy.py @@ -0,0 +1,168 @@ +"""Classes to help determine and store entity hierarchy information.""" + +import json +from typing import Any, Iterable, Optional, Union + +from pydantic import BaseModel, Field + +from dve.core_engine.configuration.v1 import V1EngineConfig, _LinkageConfig +from dve.core_engine.type_hints import EntityName, ErrorCode, ErrorMessage +from dve.metadata_parser.exc import EntityNotFoundError +from dve.parser.file_handling.service import open_stream +from dve.parser.type_hints import URI + + +class HierarchyNode(BaseModel): + """Stores entity hierarchy information""" + + entity_name: str + parent_entity: Optional[str] = None + children: list["HierarchyNode"] = Field(default_factory=list) + mandatory: bool = False + join_fields: dict[str, str] = Field(default_factory=dict) + no_valid_records_error_code: ErrorCode = "NoValidRecords" + no_valid_records_error_message: ErrorMessage = "parent record removed as no valid child records" + missing_parent_id_error_code: Optional[ErrorCode] = "MissingParentRecord" + missing_parent_id_error_message: Optional[ErrorMessage] = ( + "Records removed due to no valid parent record" + ) + + def get_descendents(self) -> list["HierarchyNode"]: + """Recursively list all descendents of the node""" + descendents = [] + for node in self.children: # type: ignore + descendents.append(node) + descendents.extend(node.get_descendents()) + return descendents + + def get_descendent_names(self) -> list[str]: + """Recursively list all names of descendents of the node""" + return [node.entity_name for node in self.get_descendents()] + + def get_node(self, entity_name: str) -> Union["HierarchyNode", None]: + """Recursively search for node and return if found""" + node = None + if self.entity_name == entity_name: + return self + for child in self.children: # type: ignore + node = child.get_node(entity_name) + if node: + break + return node + + def add_child_node(self, parent_entity: str, child_info: "HierarchyNode") -> None: + """Add a child node if the parent exists in the hierarchy""" + try: + self.get_node(parent_entity).children.append(child_info) # type: ignore + except AttributeError as exc: + raise EntityNotFoundError( + f"Can't find parent node {parent_entity} in {self.entity_name}" + ) from exc + + def as_dict(self) -> dict[str, dict[str, Any]]: + """Get dictionary representation of entity hierarchy""" + child_dict: dict[str, dict[str, Any]] = {} + for node in self.children: # type: ignore + child_dict.update(node.as_dict()) + + ret_dict = self.model_dump(exclude={"entity_name", "children"}) + ret_dict.update({"children": child_dict}) + + return {self.entity_name: ret_dict} + + def _get_full_tree(self): + """Get all nodes in tree, including the root""" + desc = self.get_descendents() + desc.insert(0, self) + return desc + + def iterate_root_down(self): + """Iterate through nodes from root to lowest descendent""" + yield from self._get_full_tree() + + def iterate_lowest_descendent_up(self): + """Iterate through nodes from lowest descendent to root""" + yield from self._get_full_tree()[::-1] + + +class EntityHierarchy: + """Determines and stores entity hierarchy information from config""" + + def __init__(self, entity_trees: dict[EntityName, HierarchyNode]): + self.entity_trees = entity_trees + + @staticmethod + def determine_trees( + all_datasets: Iterable[str], entity_relationships: dict[str, _LinkageConfig] + ) -> dict[EntityName, HierarchyNode]: + """Determine the entity hierarchy trees and store as HierarchyNodes""" + root_entities: dict[str, _LinkageConfig] = dict( + filter(lambda x: x[1].is_root_entity, entity_relationships.items()) + ) + top_level_parents: dict[EntityName, HierarchyNode] = { + entity_name: HierarchyNode( + entity_name=entity_name, + parent_entity=None, + **config.model_dump( + exclude={ + "parent_entity", + "missing_parent_id_error_code", + "missing_parent_id_error_message", + } + ), + missing_parent_id_error_code=None, + missing_parent_id_error_message=None, + ) + for entity_name, config in root_entities.items() + } + + if default_roots := [ + entity_name for entity_name in all_datasets if entity_name not in entity_relationships + ]: + for entity_name in default_roots: + top_level_parents[entity_name] = HierarchyNode( + entity_name=entity_name, + parent_entity=None, + missing_parent_id_error_code=None, + missing_parent_id_error_message=None, + ) + + for name, linkage_detail in entity_relationships.items(): + for main_entity, parent_node in top_level_parents.items(): + if ( + linkage_detail.parent_entity == main_entity + or linkage_detail.parent_entity in parent_node.get_descendent_names() + ): + parent_node.add_child_node( + linkage_detail.parent_entity, + HierarchyNode(entity_name=name, **linkage_detail.model_dump()), + ) + break + else: + raise EntityNotFoundError( + f"Can't find parent entity {linkage_detail.parent_entity} defined to " + + f"establish hierarchy for {name} - please ensure it is defined above " + + "any child entities in the dischema." + ) + return top_level_parents + + @classmethod + def from_dischema(cls, dischema_uri: URI): + """Create entity hierarchy direct from dischema""" + with open_stream(dischema_uri) as dischema: + config_dict = json.load(dischema) + all_datasets = config_dict.get("contract", {}).get("datasets", {}).keys() + entity_relationships = { + k: _LinkageConfig(**v) for k, v in config_dict.get("entity_relationships", {}).items() + } + return cls(entity_trees=cls.determine_trees(all_datasets, entity_relationships)) + + @classmethod + def from_engine_config(cls, engine_config: V1EngineConfig): + """Create entity hierarchy direct from engine config""" + return cls( + entity_trees=cls.determine_trees( + all_datasets=engine_config.contract.datasets.keys(), + entity_relationships=engine_config.entity_relationships, + ) + ) diff --git a/src/dve/core_engine/constants.py b/src/dve/core_engine/constants.py index a2a4a65..3581088 100644 --- a/src/dve/core_engine/constants.py +++ b/src/dve/core_engine/constants.py @@ -6,3 +6,6 @@ CONTRACT_ERROR_VALUE_FIELD_NAME: str = "__error_value" """The name of the field that can be used to extract the field value that caused a pydantic validation error""" + +ORPHANED_RECORD_ENTITY_NAME: str = "orphaned_record_tracker" +"""Name of entity to keep track of records where there is a missing parent record""" diff --git a/src/dve/core_engine/models.py b/src/dve/core_engine/models.py index bba2986..49dba23 100644 --- a/src/dve/core_engine/models.py +++ b/src/dve/core_engine/models.py @@ -82,7 +82,9 @@ def _ensure_just_file_stem( @property def file_name_with_ext(self): """Return file name with extension.""" - return f"{self.file_name}.{self.file_extension}" + if self.file_extension: + return f"{self.file_name}.{self.file_extension}" + return self.file_name @classmethod def from_metadata_file(cls, submission_id: str, metadata_uri: Location): diff --git a/src/dve/core_engine/type_hints.py b/src/dve/core_engine/type_hints.py index 154ada6..e369ff4 100644 --- a/src/dve/core_engine/type_hints.py +++ b/src/dve/core_engine/type_hints.py @@ -133,7 +133,7 @@ """A string indicating the field that the error pertains to.""" FieldValue = Optional[Any] """The value that caused the error.""" -ErrorCategory = Literal["Blank", "Wrong format", "Bad value", "Bad file"] +ErrorCategory = Literal["Blank", "Wrong format", "Bad value", "Bad file", "Parent Missing"] """A string indicating the category of the error.""" RecordIndex = Optional[int] """The record index that the error relates to (if applicable)""" diff --git a/src/dve/pipeline/pipeline.py b/src/dve/pipeline/pipeline.py index a9be3ff..1739416 100644 --- a/src/dve/pipeline/pipeline.py +++ b/src/dve/pipeline/pipeline.py @@ -34,6 +34,7 @@ from dve.core_engine.backends.readers.utilities import get_all_model_fields from dve.core_engine.backends.types import EntityType from dve.core_engine.backends.utilities import stringify_model +from dve.core_engine.configuration.v1.hierarchy import EntityHierarchy from dve.core_engine.exceptions import CriticalProcessingError from dve.core_engine.loggers import get_logger from dve.core_engine.message import FeedbackMessage @@ -215,10 +216,10 @@ def write_file_to_parquet( for model_name, model in models.items(): self._logger.info(f"Transforming {model_name} to stringified parquet") - reader: BaseFileReader = load_reader( - dataset, model_name, ext, self.backend_reader_kwargs - ) try: + reader: BaseFileReader = load_reader( + dataset, model_name, ext, self.backend_reader_kwargs + ) if not entity_type: reader.write_parquet( reader.read_to_py_iterator( @@ -241,6 +242,7 @@ def write_file_to_parquet( f"{out}{model_name}", ) except MessageBearingError as exc: + self._logger.error(f"Unable to process {model_name}", exc_info=exc) errors.extend(exc.messages) return list(dict.fromkeys(errors)) # remove any duplicate errors @@ -596,8 +598,13 @@ def apply_business_rules( # pylint: disable=R0914 key_fields = {model: conf.reporting_fields for model, conf in model_config.items()} + entity_hierarchy = EntityHierarchy.from_engine_config(config) + _errors_uri, rules_success = self.step_implementations.apply_rules( # type: ignore - working_directory, entity_manager, rules, key_fields + working_directory, + entity_manager, + rules, + key_fields, ) rule_messages = load_feedback_messages( @@ -636,6 +643,13 @@ def apply_business_rules( # pylint: disable=R0914 projected ) + self.step_implementations.identify_and_remove_orphans( # type: ignore + working_directory, + entity_manager.entities, + entity_hierarchy, + key_fields, + ) + submission_status.number_of_records = self.get_entity_count( entity=entity_manager.entities[f"""Original{rules.global_variables.get( 'entity', @@ -832,14 +846,18 @@ def error_report( .agg(pl.col("Count").sum()) # type: ignore .iter_rows(named=True) } + submission_rejections = err_types.get( + ErrorReportCategories.FILE_REJECTION.reporting_name, 0 + ) sub_stats = SubmissionStatisticsRecord( submission_id=submission_info.submission_id, record_count=submission_status.number_of_records, - number_submission_rejections=err_types.get( - ErrorReportCategories.FILE_REJECTION.reporting_name, 0 - ), - number_record_rejections=err_types.get( - ErrorReportCategories.RECORD_REJECTION.reporting_name, 0 + number_submission_rejections=submission_rejections, + number_record_rejections=( + submission_status.number_of_records + if submission_rejections > 0 else err_types.get( # type: ignore + ErrorReportCategories.RECORD_REJECTION.reporting_name, 0 + ) ), number_warnings=err_types.get(ErrorReportCategories.WARNING.reporting_name, 0), ) diff --git a/src/dve/pipeline/utils.py b/src/dve/pipeline/utils.py index e6122c2..832baa5 100644 --- a/src/dve/pipeline/utils.py +++ b/src/dve/pipeline/utils.py @@ -11,9 +11,11 @@ import dve.core_engine.backends.implementations.duckdb # pylint: disable=unused-import import dve.core_engine.backends.implementations.spark # pylint: disable=unused-import import dve.parser.file_handling as fh +from dve.core_engine.backends.exceptions import MessageBearingError from dve.core_engine.backends.readers import _READER_REGISTRY from dve.core_engine.configuration.v1 import SchemaName, V1EngineConfig, _ModelConfig from dve.core_engine.loggers import get_logger +from dve.core_engine.message import FeedbackMessage from dve.core_engine.type_hints import URI, SubmissionResult from dve.metadata_parser.model_generator import JSONtoPyd @@ -52,7 +54,31 @@ def load_reader( backend_reader_kwargs: Optional[dict[str, Any]] = None, ): """Loads the readers for the diven feed, model name and file extension""" - reader_config = dataset[model_name].reader_config[f".{file_extension.lower()}"] + try: + reader_config = dataset[model_name].reader_config[f".{file_extension.lower()}"] + except KeyError as exc: + if file_extension: + err_msg = ( + f"The supplied file extension `{file_extension}`" + + f" is not a supported file format for {model_name}." + ) + else: + err_msg = "No supplied file extension. Unable to parse file without a file extension." + + raise MessageBearingError( + f"The file extension provided ({file_extension}) is not supported for this collection.", + messages=[ + FeedbackMessage( + entity=model_name, + record=None, + failure_type="submission", + error_location="Whole File", + error_code="InvalidFileExtension", + error_message=err_msg, + ) + ], + ) from exc + reader = _READER_REGISTRY[reader_config.reader]( **reader_config.kwargs_, **backend_reader_kwargs if backend_reader_kwargs else {} ) diff --git a/src/dve/reporting/excel_report.py b/src/dve/reporting/excel_report.py index 5876cdd..e0c4891 100644 --- a/src/dve/reporting/excel_report.py +++ b/src/dve/reporting/excel_report.py @@ -153,17 +153,21 @@ def _add_submission_info(self, status: str, summary: Worksheet): ), # pylint: disable=C0301 ] ) - if status not in ( + if status in ( ErrorReportStatus.PROCESSING_FAILED, ErrorReportStatus.FILE_REJECTION, ): - summary.append( - [ - "", - "Total Number of Records Rejected", - self.submission_status.number_of_records_rejected, - ] - ) + _records_rejected = self.submission_status.number_of_records + else: + _records_rejected = self.submission_status.number_of_records_rejected + summary.append( + [ + "", + "Total Number of Records Rejected", + _records_rejected, + ] + ) + summary.append(["", ""]) diff --git a/tests/features/animals.feature b/tests/features/animals.feature index d68ddbf..2b49184 100644 --- a/tests/features/animals.feature +++ b/tests/features/animals.feature @@ -55,5 +55,5 @@ Feature: Pipeline tests using the animal dataset | parameter | value | | record_count | 7 | | number_submission_rejections | 1 | - | number_record_rejections | 2 | + | number_record_rejections | 7 | | number_warnings | 1 | diff --git a/tests/features/flights.feature b/tests/features/flights.feature new file mode 100644 index 0000000..a041feb --- /dev/null +++ b/tests/features/flights.feature @@ -0,0 +1,121 @@ +Feature: Pipeline tests using the flights dataset + Test hierarchical record rejection and ensuring that records are removed correctly including + any "orphan" records generated from record removal in parent entities. + + Scenario: A perfect flights file + Given I submit the flights file perfect_flights.xml for processing + And A duckdb pipeline is configured with schema file 'flights.dischema.json' + And I add initial audit entries for the submission + Then the latest audit record for the submission is marked with processing status file_transformation + When I run the file transformation phase + Then the country entity is stored as a parquet after the file_transformation phase + And the airport entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase + And the flights entity is stored as a parquet after the file_transformation phase + And the passengers entity is stored as a parquet after the file_transformation phase + And the latest audit record for the submission is marked with processing status data_contract + When I run the data contract phase + Then there are no file rejections from the data_contract phase + And there are no record rejections from the data_contract phase + When I run the business rules phase + Then there are no file rejections from the business_rules phase + And there are no record rejections from the business_rules phase + When I run the error report phase + Then An error report is produced + # TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets + # And The statistics entry for the submission shows the following information + # | parameter | value | + # | record_count | 1 | + # | number_file_rejections | 0 | + # | number_record_rejections | 0 | + + Scenario: A flights submission where the root record is rejected + Given I submit the flights file missing_country_id.xml for processing + And A duckdb pipeline is configured with schema file 'flights.dischema.json' + And I add initial audit entries for the submission + Then the latest audit record for the submission is marked with processing status file_transformation + When I run the file transformation phase + Then the country entity is stored as a parquet after the file_transformation phase + And the airport entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase + And the flights entity is stored as a parquet after the file_transformation phase + And the passengers entity is stored as a parquet after the file_transformation phase + And the latest audit record for the submission is marked with processing status data_contract + When I run the data contract phase + Then there are no file rejections from the data_contract phase + And there is 1 record rejection from the data_contract phase + When I run the business rules phase + Then there are errors with the following details and associated error_count from the business_rules phase + | ErrorType | ErrorCode | error_count | + | record | AG1 | 3 | + | record | SG1 | 15 | + | record | FG1 | 10 | + | record | PG1 | 25 | + When I run the error report phase + Then An error report is produced + # TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets + # And The statistics entry for the submission shows the following information + # | parameter | value | + # | record_count | 1 | + # | number_file_rejections | 0 | + # | number_record_rejections | 1 | + + Scenario: A flights submission where a child primary key is rejected + Given I submit the flights file missing_flight_id.xml for processing + And A duckdb pipeline is configured with schema file 'flights.dischema.json' + And I add initial audit entries for the submission + Then the latest audit record for the submission is marked with processing status file_transformation + When I run the file transformation phase + Then the country entity is stored as a parquet after the file_transformation phase + And the airport entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase + And the flights entity is stored as a parquet after the file_transformation phase + And the passengers entity is stored as a parquet after the file_transformation phase + And the latest audit record for the submission is marked with processing status data_contract + When I run the data contract phase + Then there are no file rejections from the data_contract phase + And there are no record rejections from the data_contract phase + When I run the business rules phase + Then there are errors with the following details and associated error_count from the business_rules phase + | ErrorType | ErrorCode | error_count | + | record | F1 | 1 | + | record | PG1 | 3 | + When I run the error report phase + Then An error report is produced + #TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets + # And The statistics entry for the submission shows the following information + # | parameter | value | + # | record_count | 1 | + # | number_file_rejections | 0 | + # | number_record_rejections | 1 | + + Scenario: A flights submission with a mixture of group and record rejections + Given I submit the flights file mixture_of_group_rej_and_bi_rej.xml for processing + And A duckdb pipeline is configured with schema file 'flights.dischema.json' + And I add initial audit entries for the submission + Then the latest audit record for the submission is marked with processing status file_transformation + When I run the file transformation phase + Then the country entity is stored as a parquet after the file_transformation phase + And the airport entity is stored as a parquet after the file_transformation phase + And the flights entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase + And the passengers entity is stored as a parquet after the file_transformation phase + And the latest audit record for the submission is marked with processing status data_contract + When I run the data contract phase + Then there are no file rejections from the data_contract phase + And there are no record rejections from the data_contract phase + When I run the business rules phase + Then there are errors with the following details and associated error_count from the business_rules phase + | ErrorType | ErrorCode | error_count | + | record | F1 | 1 | + | record | PG1 | 3 | + | record | P1 | 1 | + | record | S1 | 7 | + When I run the error report phase + Then An error report is produced +# TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets +# And The statistics entry for the submission shows the following information +# | parameter | value | +# | record_count | 1 | +# | number_file_rejections | 0 | +# | number_record_rejections | 1 | diff --git a/tests/features/movies.feature b/tests/features/movies.feature index 750975e..88c0218 100644 --- a/tests/features/movies.feature +++ b/tests/features/movies.feature @@ -42,7 +42,7 @@ Feature: Pipeline tests using the movies dataset | parameter | value | | record_count | 5 | | number_submission_rejections | 1 | - | number_record_rejections | 3 | + | number_record_rejections | 5 | | number_warnings | 2 | And the error aggregates are persisted @@ -81,7 +81,7 @@ Feature: Pipeline tests using the movies dataset | parameter | value | | record_count | 5 | | number_submission_rejections | 1 | - | number_record_rejections | 3 | + | number_record_rejections | 5 | | number_warnings | 2 | And the error aggregates are persisted diff --git a/tests/features/planets.feature b/tests/features/planets.feature index b37b60b..0c4b21d 100644 --- a/tests/features/planets.feature +++ b/tests/features/planets.feature @@ -43,7 +43,9 @@ Feature: Pipeline tests using the planets dataset And I add initial audit entries for the submission Then the latest audit record for the submission is marked with processing status file_transformation When I run the file transformation phase - Then the latest audit record for the submission is marked with processing status failed + Then the latest audit record for the submission is marked with processing status error_report + When I run the error report phase + Then An error report is produced Scenario: Handle a file with duplicated extension provided (spark) Given I submit the planets file planets.csv.csv for processing diff --git a/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py b/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py index 35007a9..5447e14 100644 --- a/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py +++ b/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py @@ -1,6 +1,7 @@ """Test DuckDB backend steps.""" # pylint: disable=redefined-outer-name,unused-import,line-too-long +import tempfile from pathlib import Path from typing import Iterator, List, Optional, Set, Tuple, Type @@ -39,6 +40,10 @@ SemiJoin, TableUnion, ) +from dve.core_engine.constants import ORPHANED_RECORD_ENTITY_NAME +from dve.core_engine.configuration.v1.hierarchy import ( + EntityHierarchy, HierarchyNode +) from dve.core_engine.type_hints import MultipleExpressions from tests.test_core_engine.test_backends.fixtures import ( duckdb_connection, @@ -581,91 +586,108 @@ def test_header_multi_rows_raises( DUCKDB_STEP_BACKEND.join_header(entities, config=header_join) -def test_orphans_planets_satellites( - planets_rel: DuckDBPyRelation, largest_satellites_rel: DuckDBPyRelation -): - """Test a basic orphan idenfitication from satellites to planets.""" - # Each satellite _must_ have a planet. - join = OrphanIdentification( - entity_name="satellites", - target_name="planets", - join_condition="satellites.planet == planets.planet", - ) - entities = EntityManager( - { - "planets": planets_rel.filter(ColumnExpression("Planet") != ConstantExpression("Mars")), - "satellites": largest_satellites_rel, - } - ) - - DUCKDB_STEP_BACKEND.evaluate(entities, config=join) - actual_rel = ( - entities["satellites"] - .filter(ColumnExpression("IsOrphaned")) - .select(ColumnExpression("name")) - ) - actual_rows = sorted(actual_rel.df().to_dict(orient="records"), key=lambda row: row["name"]) - - expected_rel = largest_satellites_rel.filter( - ColumnExpression("Planet") == ConstantExpression("Mars") - ).select(ColumnExpression("name")) - expected_rows = sorted(expected_rel.df().to_dict(orient="records"), key=lambda row: row["name"]) - - assert actual_rows == expected_rows - - -def test_chained_orphans_planets_satellites( - planets_rel: DuckDBPyRelation, largest_satellites_rel: DuckDBPyRelation -): - """Test a basic chained orphan idenfitication from satellites to planets.""" - join = OrphanIdentification( - entity_name="satellites", - target_name="planets", - join_condition="satellites.planet == planets.planet", - ) - entities = EntityManager( - { - "planets": planets_rel.filter(ColumnExpression("planet") != ConstantExpression("Mars")), - "satellites": largest_satellites_rel, - } - ) - DUCKDB_STEP_BACKEND.evaluate(entities, config=join) - entities["planets"] = planets_rel.filter( - ColumnExpression("planet") != ConstantExpression("Earth") - ) - DUCKDB_STEP_BACKEND.evaluate(entities, config=join) - - actual_rel = ( - entities["satellites"] - .filter(ColumnExpression("IsOrphaned")) - .select(ColumnExpression("name")) - ) - actual_rows = sorted(actual_rel.df().to_dict(orient="records"), key=lambda row: row["name"]) - - expected_rel = largest_satellites_rel.filter( - ColumnExpression("Planet").isin(ConstantExpression("Mars"), ConstantExpression("Earth")) - ).select(ColumnExpression("name")) - expected_rows = sorted(expected_rel.df().to_dict(orient="records"), key=lambda row: row["name"]) - - assert actual_rows == expected_rows - - -def test_orphans_missing_entities_raises( - planets_rel: DuckDBPyRelation, satellites_rel: DuckDBPyRelation -): - """Test that trying to join orphans from missing entities raises correctly.""" - join = OrphanIdentification( - entity_name="planets", - target_name="satellites", - join_condition="planets.planet == satellites.planet", - ) +class TestOrphanRecords: + """ + Check that Orphan records identification and removal is working as expected. - entities = EntityManager({"planets": planets_rel}) - with pytest.raises(MissingEntity): - DUCKDB_STEP_BACKEND.identify_orphans(entities, config=join) - entities = EntityManager({"satellites": satellites_rel}) - with pytest.raises(MissingEntity): - DUCKDB_STEP_BACKEND.identify_orphans(entities, config=join) + Current scenarios are: + Flight ID 1 = Perfect Record - no orphans + Flight ID 2 = Record rejected at the flights entity, therefore, two expected orphans in passengers and food entities. + """ + mod_flights_df = pl.DataFrame([ + {'flight_id': 1, '__record_index__': 1}, + ]) + mod_passengers_df = pl.DataFrame([ + {'flight_id': 1, 'passenger_id': 1, '__record_index__': 1}, + {'flight_id': 1, 'passenger_id': 2, '__record_index__': 2}, + {'flight_id': 2, 'passenger_id': 3, '__record_index__': 3}, + ]) + mod_food_df = pl.DataFrame([ + {'passenger_id': 1, 'food_id': 1, '__record_index__': 1}, + {'passenger_id': 1, 'food_id': 2, '__record_index__': 2}, + {'passenger_id': 3, 'food_id': 3, '__record_index__': 3}, + ]) + + def test_identify_orphan_record_single_entity(self): + """Ensure that a single one-to-one check works to identify orphan records.""" + with duckdb.connect() as cnn: + cnn.register("mod_flights", self.mod_flights_df) + cnn.register("mod_passengers", self.mod_passengers_df) + + mod_entities = EntityManager( + entities={ + "flights": cnn.sql("SELECT * FROM mod_flights"), + "passengers": cnn.sql("SELECT * FROM mod_passengers"), + } + ) + + rules = DuckDBStepImplementations(connection=cnn) + _msgs = rules.identify_orphans( + mod_entities.entities, + config=OrphanIdentification( + id="flight_id", + entity_name="passengers", + target_name="flights", + join_condition="passengers.flight_id = flights.flight_id" + ) + ) + result = mod_entities[ORPHANED_RECORD_ENTITY_NAME] + assert result.count("*").fetchone()[0] == 1 # type: ignore + assert result.select("entity_name").unique("*").count("*").fetchone()[0] == 1 # type: ignore + + def test_identify_and_remove_orphans(self): + with duckdb.connect() as cnn: + cnn.register("mod_flights", self.mod_flights_df) + cnn.register("mod_passengers", self.mod_passengers_df) + cnn.register("mod_food", self.mod_food_df) + + mod_entities = EntityManager( + entities={ + "flights": cnn.sql("SELECT * FROM mod_flights"), + "passengers": cnn.sql("SELECT * FROM mod_passengers"), + "food": cnn.sql("SELECT * FROM mod_food"), + } + ) + + rules = DuckDBStepImplementations(connection=cnn) + hierarchy = EntityHierarchy({ + "flights": HierarchyNode( + entity_name="flights", + children=[ + HierarchyNode( + entity_name="passengers", + parent_entity="flights", + children=[ + HierarchyNode( + entity_name="food", + parent_entity="passengers", + children=[], + join_fields={"passenger_id": "passenger_id"}, + mandatory=False + ) + ], + join_fields={"flight_id": "flight_id"}, + mandatory=True + ) + ] + ) + }) + + with tempfile.TemporaryDirectory() as wd: + rules.identify_and_remove_orphans( + wd, + mod_entities.entities, + hierarchy + ) + + flights_rel = mod_entities["flights"] + assert flights_rel.select("__record_index__").unique("*").count("*").fetchone()[0] == 1 # type: ignore + + passenger_rel = mod_entities["passengers"] + assert passenger_rel.select("__record_index__").unique("*").count("*").fetchone()[0] == 2 # type: ignore + + food_rel = mod_entities["food"] + assert food_rel.select("__record_index__").unique("*").count("*").fetchone()[0] == 2 # type: ignore def test_has_match_planets_satellites( diff --git a/tests/test_core_engine/test_backends/test_implementations/test_spark/test_rules.py b/tests/test_core_engine/test_backends/test_implementations/test_spark/test_rules.py index 673e611..654dc08 100644 --- a/tests/test_core_engine/test_backends/test_implementations/test_spark/test_rules.py +++ b/tests/test_core_engine/test_backends/test_implementations/test_spark/test_rules.py @@ -568,6 +568,7 @@ def test_header_multi_rows_raises(planets_df: DataFrame, value_literal_1_header: SPARK_STEP_BACKEND.join_header(entities, config=header_join) +@pytest.mark.skip(reason="Logic is no longer valid") def test_orphans_planets_satellites(planets_df: DataFrame, largest_satellites_df: DataFrame): """Test a basic orphan idenfitication from satellites to planets.""" # Each satellite _must_ have a planet. @@ -593,6 +594,7 @@ def test_orphans_planets_satellites(planets_df: DataFrame, largest_satellites_df assert actual_rows == expected_rows +@pytest.mark.skip(reason="Logic is no longer valid") def test_chained_orphans_planets_satellites( planets_df: DataFrame, largest_satellites_df: DataFrame ): @@ -623,6 +625,7 @@ def test_chained_orphans_planets_satellites( assert actual_rows == expected_rows +@pytest.mark.skip(reason="Logic is no longer valid") def test_orphans_missing_entities_raises(planets_df: DataFrame, satellites_df: DataFrame): """Test that trying to join orphans from missing entities raises correctly.""" join = OrphanIdentification( diff --git a/tests/test_core_engine/test_hierarchy.py b/tests/test_core_engine/test_hierarchy.py new file mode 100644 index 0000000..edf7d4c --- /dev/null +++ b/tests/test_core_engine/test_hierarchy.py @@ -0,0 +1,372 @@ +import json +import pytest +from tempfile import NamedTemporaryFile +from dve.core_engine.configuration.v1 import V1EngineConfig +from dve.core_engine.configuration.v1.hierarchy import EntityHierarchy + +CONFIG_WITHOUT_LINKAGE = """{ + "contract": { + "schemas": {}, + "datasets": { + "animals": { + "fields": { + "name": "str", + "height": "float", + "weight": "float", + "region": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "animal", + "root_tag": "animals" + } + } + }, + "mandatory_fields": [ + "name" + ] + } + } + }, + "transformations": { + "filters": [ + { + "entity": "animals", + "name": "check_valid_region", + "expression": "lower(region) in ('africa', 'asia')", + "error_code": "ANE01", + "failure_message": "Record rejected - `{{ region }}` is not in a valid region." + }, + { + "entity": "animals", + "name": "check_for_pets", + "expression": "lower(name) != 'human'", + "error_code": "ANE02", + "failure_message": "Submission Rejected - 'Human' is not a valid animal.", + "failure_type": "submission" + }, + { + "entity": "animals", + "name": "check_valid_weight", + "expression": "weight > 0", + "error_code": "ANE03", + "failure_message": "Warning - `{{ weight }}` is below zero.", + "is_informational": true + } + ] + } +}""" + +CONFIG_WITH_LINKAGE = """{ + "contract": { + "schemas": {}, + "datasets": { + "ds_001": { + "fields": { + "ds_001_id": "str", + "patient_id": "str", + "address": "str", + "name": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "001", + "root_tag": "header" + } + } + }, + "mandatory_fields": [ + "ds_001_id", + "patient_id", + "address", + "name" + ] + }, + "ds_002": { + "fields": { + "ds_002_id": "str", + "gp_name": "str", + "gp_address": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "002", + "root_tag": "header" + } + } + }, + "mandatory_fields": [ + "ds_002_id", + "gp_name", + "gp_address" + ] + }, + "ds_003": { + "fields": { + "ds_003_id": "str", + "ds_001_id": "str", + "total_income": "int" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "003", + "root_tag": "header" + } + } + }, + "mandatory_fields": [ + "ds_003_id", + "ds_001_id" + ] + }, + "ds_101": { + "fields": { + "ds_001_id": "str", + "referral_id": "int", + "consultant_name": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "101", + "root_tag": "header" + } + } + }, + "mandatory_fields": [ + "referral_id", + "ds_001_id" + ] + }, + "ds_201": { + "fields": { + "ds_201_id": "str", + "ds_101_id": "str", + "contact_date": "date" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "201", + "root_tag": "header" + } + } + }, + "mandatory_fields": [ + "ds_101_id", + "ds_201_id", + "contact_date" + ] + }, + "ds_202": { + "fields": { + "ds_202_id": "str", + "ds_201_id": "str", + "contact_name": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "202", + "root_tag": "header" + } + } + }, + "mandatory_fields": [ + "ds_202_id", + "ds_201_id" + ] + } + } + }, + "transformations": { + "filters": [ + { + "entity": "001", + "name": "check_name", + "expression": "len(name) > 2", + "error_code": "CHECK1", + "failure_message": "Record rejected - `{{ name }}` is not valid." + } + ] + }, + "entity_relationships": { + "ds_003": { + "parent_entity": "ds_001", + "join_fields": {"ds_001_id": "ds_001_id"}, + "mandatory": false, + "missing_parent_id_error_code": "DS003NoParent", + "missing_parent_id_error_message": "record removed as no parent" + }, + "ds_101": { + "parent_entity": "ds_001", + "join_fields": {"ds_001_id": "ds_001_id"}, + "mandatory_entity": true, + "no_valid_records_error_code": "DS101NOVALIDRECS", + "no_valid_records_error_message": "{{ ds_001_id }} removed as no valid ds_101 records", + "missing_parent_id_error_code": "DS101NoParent", + "missing_parent_id_error_message": "record removed as no parent" + }, + "ds_201": { + "parent_entity": "ds_101", + "join_fields": {"referral_id": "ds_101_id"}, + "mandatory": false, + "missing_parent_id_error_code": "DS201NoParent", + "missing_parent_id_error_message": "record removed as no parent" + }, + "ds_202": { + "parent_entity": "ds_201", + "join_fields": {"ds_201_id": "ds_201_id"}, + "mandatory": true + } + } +}""" + +def test_no_linkage_config_load(): + config = V1EngineConfig(location="", + **json.loads(CONFIG_WITHOUT_LINKAGE)) + assert len(config.contract.datasets) == 1 + hierarchy = EntityHierarchy.from_engine_config(config) + assert len(hierarchy.entity_trees) == 1 + assert not hierarchy.entity_trees.get("animals").children + + +def test_linkage_config_load(): + config = V1EngineConfig(location="", + **json.loads(CONFIG_WITH_LINKAGE)) + assert len(config.contract.datasets) == 6 + with NamedTemporaryFile("w") as tmp: + tmp.write(CONFIG_WITH_LINKAGE) + tmp.flush() + hierarchy = EntityHierarchy.from_dischema(tmp.name) + assert len(hierarchy.entity_trees) == 2 + assert not hierarchy.entity_trees.get("ds_002").children + assert len(hierarchy.entity_trees.get("ds_001").get_descendents()) == 4 + children_001 = sorted(hierarchy.entity_trees.get("ds_001").children, key=lambda x: x.entity_name) + dict_rep_001 = hierarchy.entity_trees.get("ds_001").as_dict() + assert len(children_001) == 2 + assert children_001[0].entity_name == "ds_003" + assert not children_001[0].children + assert children_001[1].entity_name == "ds_101" + assert dict_rep_001 == json.loads(""" +{ + "ds_001": { + "parent_entity": null, + "join_fields": {}, + "mandatory": false, + "no_valid_records_error_code": "NoValidRecords", + "no_valid_records_error_message": "parent record removed as no valid child records", + "missing_parent_id_error_code": null, + "missing_parent_id_error_message": null, + "children": { + "ds_003": { + "parent_entity": "ds_001", + "join_fields": { + "ds_001_id": "ds_001_id" + }, + "mandatory": false, + "no_valid_records_error_code": "NoValidRecords", + "no_valid_records_error_message": "parent record removed as no valid child records", + "missing_parent_id_error_code": "DS003NoParent", + "missing_parent_id_error_message": "record removed as no parent", + "children": {} + }, + "ds_101": { + "parent_entity": "ds_001", + "join_fields": { + "ds_001_id": "ds_001_id" + }, + "mandatory": false, + "no_valid_records_error_code": "DS101NOVALIDRECS", + "no_valid_records_error_message": "{{ ds_001_id }} removed as no valid ds_101 records", + "missing_parent_id_error_code": "DS101NoParent", + "missing_parent_id_error_message": "record removed as no parent", + "children": { + "ds_201": { + "parent_entity": "ds_101", + "join_fields": { + "referral_id": "ds_101_id" + }, + "mandatory": false, + "no_valid_records_error_code": "NoValidRecords", + "no_valid_records_error_message": "parent record removed as no valid child records", + "missing_parent_id_error_code": "DS201NoParent", + "missing_parent_id_error_message": "record removed as no parent", + "children": { + "ds_202": { + "parent_entity": "ds_201", + "join_fields": { + "ds_201_id": "ds_201_id" + }, + "mandatory": true, + "no_valid_records_error_code": "NoValidRecords", + "no_valid_records_error_message": "parent record removed as no valid child records", + "missing_parent_id_error_code": "MissingParentRecord", + "missing_parent_id_error_message": "Records removed due to no valid parent record", + "children": {} + } + } + } + } + } + } + } + }""" + ) + + dict_rep_101 = dict_rep_001["ds_001"]["children"]["ds_101"] + children_101 = children_001[1].children + assert len(children_101) == 1 + assert children_101[0].entity_name == "ds_201" + assert children_101[0].children[0].entity_name == "ds_202" + assert not children_101[0].children[0].children + assert dict_rep_101 == json.loads(""" + { "parent_entity": "ds_001", + "join_fields": { + "ds_001_id": "ds_001_id" + }, + "mandatory": false, + "no_valid_records_error_code": "DS101NOVALIDRECS", + "no_valid_records_error_message": "{{ ds_001_id }} removed as no valid ds_101 records", + "missing_parent_id_error_code": "DS101NoParent", + "missing_parent_id_error_message": "record removed as no parent", + "children": { + "ds_201": { + "parent_entity": "ds_101", + "join_fields": { + "referral_id": "ds_101_id" + }, + "mandatory": false, + "no_valid_records_error_code": "NoValidRecords", + "no_valid_records_error_message": "parent record removed as no valid child records", + "missing_parent_id_error_code": "DS201NoParent", + "missing_parent_id_error_message": "record removed as no parent", + "children": { + "ds_202": { + "parent_entity": "ds_201", + "join_fields": { + "ds_201_id": "ds_201_id" + }, + "mandatory": true, + "no_valid_records_error_code": "NoValidRecords", + "no_valid_records_error_message": "parent record removed as no valid child records", + "missing_parent_id_error_code": "MissingParentRecord", + "missing_parent_id_error_message": "Records removed due to no valid parent record", + "children": {} + } + } + } + } + }""") + \ No newline at end of file diff --git a/tests/test_pipeline/test_pipeline_utils.py b/tests/test_pipeline/test_pipeline_utils.py new file mode 100644 index 0000000..fc28306 --- /dev/null +++ b/tests/test_pipeline/test_pipeline_utils.py @@ -0,0 +1,36 @@ +from dve.core_engine.backends.exceptions import MessageBearingError +from dve.core_engine.configuration.v1 import _ModelConfig, _ReaderConfig +from dve.pipeline.utils import load_reader + +import pytest + + +class TestLoadReader: + test_model_config = _ModelConfig( + fields={"test": "str"}, + reporting_fields=["test"], + key_field="test", + reader_config={ + ".csv": _ReaderConfig(reader="TestCsvReader"), + } + ) + + def test_invalid_load_reader_with_file_ext(self): + with pytest.raises(MessageBearingError) as exc_info: + load_reader( + {"test": self.test_model_config}, + "test_model", + "jpeg" + ) + + assert exc_info.value.messages[0].error_message == "The supplied file extension `jpeg` is not a supported file format for test_model." + + def test_invalid_load_reader_missing_file_ext(self): + with pytest.raises(MessageBearingError) as exc_info: + load_reader( + {"test": self.test_model_config}, + "test_model", + "" + ) + + assert exc_info.value.messages[0].error_message == "No supplied file extension. Unable to parse file without a file extension." diff --git a/tests/testdata/flights/flights.dischema.json b/tests/testdata/flights/flights.dischema.json new file mode 100644 index 0000000..fd9e94b --- /dev/null +++ b/tests/testdata/flights/flights.dischema.json @@ -0,0 +1,181 @@ +{ + "contract": { + "schemas": { + "passengers": { + "fields": { + "flight_id": "int", + "passenger_id": "int", + "passenger_name": "str" + } + } + }, + "error_details": "flights_contract_error_details.json", + "datasets": { + "country": { + "fields": { + "country_id": "int", + "country_name": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "country", + "root_tag": "country" + } + } + }, + "key_field": "country_id", + "mandatory_fields": [ + "country_id", + "country_name" + ] + }, + "airport": { + "fields": { + "country_id": "int", + "airport_id": "int", + "airport_name": "str", + "postcode": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "airport", + "root_tag": "country" + } + } + }, + "key_field": "airport_id" + }, + "staff": { + "fields": { + "airport_id": "int", + "staff_id": "int", + "staff_name": "str", + "role": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "staff_member", + "root_tag": "country" + } + } + }, + "key_field": "staff_id" + }, + "flights": { + "fields": { + "airport_id": "int", + "flight_id": "int", + "destination": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "flight", + "root_tag": "country" + } + } + }, + "key_field": "flight_id" + }, + "passengers": { + "fields": { + "flight_id": "int", + "passenger_id": "int", + "passenger_name": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "passenger", + "root_tag": "country" + } + } + }, + "key_field": "passenger_id" + } + } + }, + "transformations": { + "filters": [ + { + "entity": "flights", + "name": "flight_missing_id", + "expression": "flight_id IS NOT NULL", + "failure_type": "record", + "failure_message": "Record Rejected - Flight is missing an id", + "reporting_field": "flight_id", + "reporting_entity": "flights", + "category": "Blank", + "error_code": "F1" + }, + { + "entity": "passengers", + "name": "passenger_name_is_null", + "expression": "passenger_name IS NOT NULL", + "failure_type": "record", + "failure_message": "Record Rejected - Passenger Name is missing", + "reporting_field": "passenger_name", + "reporting_entity": "passengers", + "category": "Blank", + "error_code": "P1" + }, + { + "entity": "staff", + "name": "staff_id_is_null", + "expression": "staff_id IS NOT NULL", + "failure_type": "record", + "failure_message": "Record Rejected - staff_id is missing", + "reporting_field": "passenger_name", + "reporting_entity": "passengers", + "category": "Blank", + "error_code": "S1" + } + ] + }, + "entity_relationships": { + "airport": { + "parent_entity": "country", + "join_fields": { + "country_id": "country_id" + }, + "mandatory": true, + "missing_parent_id_error_code": "AG1", + "missing_parent_id_error_message": "Group rejected - No valid country group found country" + }, + "staff": { + "parent_entity": "airport", + "join_fields": { + "airport_id": "airport_id" + }, + "mandatory": true, + "missing_parent_id_error_code": "SG1", + "missing_parent_id_error_message": "Group rejected - No valid airport group found for staff" + }, + "flights": { + "parent_entity": "airport", + "join_fields": { + "airport_id": "airport_id" + }, + "mandatory": false, + "missing_parent_id_error_code": "FG1", + "missing_parent_id_error_message": "Group rejected - No valid airport group found for flight" + }, + "passengers": { + "parent_entity": "flights", + "join_fields": { + "flight_id": "flight_id" + }, + "mandatory": false, + "missing_parent_id_error_code": "PG1", + "missing_parent_id_error_message": "Group rejected - No valid flight group found for passenger" + } + } +} \ No newline at end of file diff --git a/tests/testdata/flights/flights_contract_error_details.json b/tests/testdata/flights/flights_contract_error_details.json new file mode 100644 index 0000000..f3c7e3f --- /dev/null +++ b/tests/testdata/flights/flights_contract_error_details.json @@ -0,0 +1,8 @@ +{ + "country_id": { + "Blank": { + "error_code": "C1", + "error_message": "Record Rejected - Country is missing an id" + } + } +} \ No newline at end of file diff --git a/tests/testdata/flights/missing_country_id.xml b/tests/testdata/flights/missing_country_id.xml new file mode 100644 index 0000000..14ccec2 --- /dev/null +++ b/tests/testdata/flights/missing_country_id.xml @@ -0,0 +1,322 @@ + + + England + + + 1 + 1 + Heathrow + TW6 1EW + + + 1 + 1 + Paris + + + 1 + 1 + John + + + 1 + 2 + Jane + + + 1 + 3 + Peter + + + + + 1 + 2 + Madrid + + + 2 + 4 + Homer + + + 2 + 5 + Marge + + + + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + + 3 + 10 + Kevin + Manager + + + 3 + 11 + Laura + Pilot + + + 3 + 12 + Michael + Air Traffic Controller + + + 3 + 13 + Natalie + Ground Crew + + + 3 + 14 + Oliver + Security + + + 3 + 15 + Paula + Engineer + + + + + diff --git a/tests/testdata/flights/missing_flight_id.xml b/tests/testdata/flights/missing_flight_id.xml new file mode 100644 index 0000000..fdd7cf7 --- /dev/null +++ b/tests/testdata/flights/missing_flight_id.xml @@ -0,0 +1,322 @@ + + + 1 + England + + + 1 + 1 + Heathrow + TW6 1EW + + + 1 + Paris + + + 1 + 1 + John + + + 1 + 2 + Jane + + + 1 + 3 + Peter + + + + + 1 + 2 + Madrid + + + 2 + 4 + Homer + + + 2 + 5 + Marge + + + + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + + 3 + 10 + Kevin + Manager + + + 3 + 11 + Laura + Pilot + + + 3 + 12 + Michael + Air Traffic Controller + + + 3 + 13 + Natalie + Ground Crew + + + 3 + 14 + Oliver + Security + + + 3 + 15 + Paula + Engineer + + + + + diff --git a/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml b/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml new file mode 100644 index 0000000..3e40b18 --- /dev/null +++ b/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml @@ -0,0 +1,314 @@ + + + 1 + England + + + 1 + 1 + Heathrow + TW6 1EW + + + 1 + Paris + + + 1 + 1 + John + + + 1 + 2 + Jane + + + 1 + 3 + Peter + + + + + 1 + 2 + Madrid + + + 2 + 4 + Homer + + + 2 + 5 + + + + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + + 3 + Kevin + Manager + + + 3 + Laura + Pilot + + + 3 + Michael + Air Traffic Controller + + + 3 + Natalie + Ground Crew + + + 3 + Oliver + Security + + + 3 + Paula + Engineer + + + + + diff --git a/tests/testdata/flights/perfect_flights.xml b/tests/testdata/flights/perfect_flights.xml new file mode 100644 index 0000000..88346dc --- /dev/null +++ b/tests/testdata/flights/perfect_flights.xml @@ -0,0 +1,323 @@ + + + 1 + England + + + 1 + 1 + Heathrow + TW6 1EW + + + 1 + 1 + Paris + + + 1 + 1 + John + + + 1 + 2 + Jane + + + 1 + 3 + Peter + + + + + 1 + 2 + Madrid + + + 2 + 4 + Homer + + + 2 + 5 + Marge + + + + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + + 3 + 10 + Kevin + Manager + + + 3 + 11 + Laura + Pilot + + + 3 + 12 + Michael + Air Traffic Controller + + + 3 + 13 + Natalie + Ground Crew + + + 3 + 14 + Oliver + Security + + + 3 + 15 + Paula + Engineer + + + + +