From ff096b51b5c306d62eded24098af2b402a4fecb2 Mon Sep 17 00:00:00 2001 From: M7XBeast Date: Sun, 11 Oct 2026 17:37:40 +0300 Subject: [PATCH] Initial publish of dbt_f2dq (f2dq) package v0.12.1. Clean package repo for Gitea installs via packages.yml git URL. Co-authored-by: Cursor --- .github/ISSUE_TEMPLATE/bug_report.md | 23 ++ .../ISSUE_TEMPLATE/documentation-request.md | 12 + .github/ISSUE_TEMPLATE/feature_request.md | 20 ++ .github/pull_request_template.md | 6 + .github/workflows/generate-docs.yml | 55 ++++ .github/workflows/run-db-tests.yml | 144 +++++++++ .gitignore | 24 ++ LICENSE | 38 +++ Makefile | 34 +++ README.md | 60 ++++ analysis/.gitkeep | 0 data/.gitkeep | 0 dbt_project.yml | 73 +++++ integration_tests/.gitignore | 7 + integration_tests/README.md | 1 + integration_tests/analysis/.gitkeep | 0 integration_tests/dbt_project.yml | 116 ++++++++ integration_tests/macros/.gitkeep | 0 .../macros/create_test_source_tables.sql | 28 ++ integration_tests/macros/drop_all_schemas.sql | 48 +++ integration_tests/macros/my_metrics.sql | 29 ++ integration_tests/macros/test_utils.sql | 20 ++ .../macros/trigger_schema_change.sql | 15 + .../models/metrics/f2dq_metrics.yml | 30 ++ .../models/monitoring/schema.yml | 27 ++ .../models/monitoring/test_f2dq_anomalies.sql | 9 + .../models/monitoring/test_f2dq_metrics.sql | 11 + .../monitoring/test_f2dq_table_samples.sql | 9 + .../monitoring/test_f2dq_test_history.sql | 10 + .../models/monitoring/test_f2dq_z_score.sql | 22 ++ .../cleaning/sanitized_user_data.sql | 4 + .../models/public_macros/cleaning/schema.yml | 7 + .../public_macros/filtering/deduplicated.sql | 16 + .../public_macros/filtering/duplicates.sql | 2 + .../models/public_macros/filtering/schema.yml | 11 + .../public_macros/normalizing/schema.yml | 7 + .../normalizing/us_states_normalized.sql | 29 ++ .../public_macros/validating/schema.yml | 27 ++ .../validating/validated_date_and_time.sql | 14 + .../validating/validated_emails.sql | 7 + .../validating/validated_ips.sql | 10 + .../validating/validated_numbers.sql | 13 + .../validating/validated_uuids.sql | 8 + integration_tests/models/sources/schema.yml | 18 ++ .../models/transformed/buy_events.sql | 12 + .../models/transformed/schema.yml | 49 +++ integration_tests/packages.yml | 5 + integration_tests/pytest.ini | 5 + integration_tests/python_tests/__init__.py | 0 integration_tests/python_tests/conftest.py | 14 + integration_tests/python_tests/run_all_dbs.sh | 5 + .../python_tests/test_cleaners.py | 14 + .../python_tests/test_filters.py | 25 ++ .../python_tests/test_monitoring.py | 88 ++++++ .../python_tests/test_normalizers.py | 14 + .../python_tests/test_validate.py | 17 ++ .../python_tests/utils/__init__.py | 0 integration_tests/python_tests/utils/run.py | 23 ++ integration_tests/requirements.txt | 3 + integration_tests/seeds/.gitkeep | 0 .../seeds/monitoring/expected_anomalies.csv | 29 ++ .../seeds/monitoring/expected_metrics.csv | 281 ++++++++++++++++++ .../monitoring/expected_table_samples.csv | 5 + .../monitoring/expected_test_history.csv | 35 +++ .../seeds/monitoring/expected_z_score.csv | 125 ++++++++ .../seeds/monitoring/sample_table.csv | 10 + .../seeds/monitoring/sample_with_anomaly.csv | 13 + .../monitoring/sample_without_time_filter.csv | 20 ++ .../cleaning/expected_sample_user_data.csv | 21 ++ .../cleaning/sample_user_data.csv | 21 ++ .../public_macros/filtering/duplicated.csv | 9 + .../filtering/expected_deduplicated.csv | 18 ++ .../filtering/expected_duplicates.csv | 7 + .../normalizing/abbreviated_us_states.csv | 52 ++++ .../expected_us_states_normalized.csv | 52 ++++ .../normalizing/us_states_normalization.csv | 52 ++++ .../expected_validated_credit_cards.csv | 11 + .../expected_validated_date_and_time.csv | 13 + .../validating/expected_validated_emails.csv | 5 + .../validating/expected_validated_ips.csv | 29 ++ .../validating/expected_validated_numbers.csv | 12 + .../validating/expected_validated_uuids.csv | 8 + .../validating/validate_date_and_time.csv | 13 + .../validating/validate_emails.csv | 5 + .../public_macros/validating/validate_ip.csv | 29 ++ .../validating/validate_numbers.csv | 12 + .../validating/validate_uuid.csv | 8 + integration_tests/snapshots/.gitkeep | 0 integration_tests/tests/.gitkeep | 0 macros/.gitkeep | 0 macros/config/get_model_config.sql | 41 +++ macros/db/bigquery/quote_column_name.sql | 4 + macros/db/bigquery/quote_string.sql | 4 + .../bigquery/split_and_return_nth_value.sql | 3 + macros/db/core/escape.sql | 4 + macros/db/core/identifier_mapping.sql | 10 + macros/db/core/quote_column_name.sql | 10 + macros/db/core/quote_string.sql | 13 + macros/db/core/split_and_return_nth_value.sql | 7 + macros/db/postgres/escape.sql | 1 + macros/db/postgres/postgres_type_db.sql | 3 + macros/db/postgres/quote_string.sql | 3 + macros/db/redshift/escape.sql | 2 + macros/db/redshift/quote_string.sql | 2 + macros/db/snowflake/identifier_mapping.sql | 8 + macros/meta/get_monitored.sql | 97 ++++++ macros/meta/information_schema.sql | 32 ++ macros/meta/monitored_model_queries.sql | 10 + macros/meta/save_monitored.sql | 17 ++ macros/meta/table_name.sql | 33 ++ .../metrics/base/build_in/column_default.sql | 91 ++++++ .../base/build_in/optional_column_metrics.sql | 104 +++++++ .../base/build_in/optional_table_metrics.sql | 7 + .../metrics/base/build_in/table_default.sql | 33 ++ macros/metrics/base/expression.sql | 102 +++++++ .../metrics/base/internal_model_template.sql | 31 ++ macros/metrics/base/queries.sql | 75 +++++ macros/post_hook/f2dq_monitored.sql | 11 + .../cleaning/clean_additional_whitespace.sql | 23 ++ macros/public/cleaning/clean_blacklist.sql | 31 ++ .../cleaning/clean_capitalize_words.sql | 3 + macros/public/filtering/get_duplicates.sql | 23 ++ macros/public/filtering/remove_duplicates.sql | 15 + .../public/normalizing/normalize_values.sql | 32 ++ macros/public/store/export_alerts.sql | 22 ++ macros/public/store/export_table_samples.sql | 15 + macros/public/store/export_tests_history.sql | 25 ++ macros/public/store/generate_overview.sql | 93 ++++++ macros/public/validating/regex_dict.sql | 65 ++++ macros/public/validating/valid_with_regex.sql | 93 ++++++ macros/run_end/save_results_history.sql | 112 +++++++ macros/samples/internal_model_template.sql | 54 ++++ macros/store/insert_list_to_table.sql | 42 +++ macros/tests/test_metrics.sql | 106 +++++++ macros/utils/agate/row_value.sql | 20 ++ macros/utils/bool_to_string.sql | 8 + macros/utils/column_types.sql | 102 +++++++ macros/utils/comma_delimited_list.sql | 5 + macros/utils/comparison_text.sql | 5 + macros/utils/compat_dual_read.sql | 58 ++++ .../deduplication/add_duplication_context.sql | 13 + macros/utils/depends_macro.sql | 6 + macros/utils/dict_from_list.sql | 14 + macros/utils/fivetran_utils/json_extract.sql | 40 +++ macros/utils/fivetran_utils/percentile.sql | 60 ++++ macros/utils/for_loops.sql | 4 + macros/utils/formulas.sql | 10 + macros/utils/generate_alert_message.sql | 65 ++++ macros/utils/get_database.sql | 16 + macros/utils/in_compile.sql | 16 + macros/utils/is_list.sql | 7 + macros/utils/json/to_single_json.sql | 22 ++ macros/utils/mock/empty_tables.sql | 67 +++++ macros/utils/monitored_config.sql | 38 +++ macros/utils/quote.sql | 13 + macros/utils/regular_expression.sql | 15 + macros/utils/time_macros.sql | 100 +++++++ macros/utils/used_types.sql | 100 +++++++ models/alerts/f2dq_alerts.sql | 34 +++ models/alerts/f2dq_anomalies.sql | 55 ++++ models/alerts/f2dq_schema_changes.sql | 167 +++++++++++ models/alerts/f2dq_test_runs.sql | 13 + models/alerts/f2dq_z_score.sql | 76 +++++ models/internal/f2dq_run_started_at.sql | 7 + .../base/f2dq_last_base_metrics_part0.sql | 1 + .../base/f2dq_last_base_metrics_part1.sql | 1 + .../base/f2dq_last_base_metrics_part2.sql | 1 + .../base/f2dq_last_base_metrics_part3.sql | 1 + .../base/f2dq_last_base_metrics_thread0.sql | 1 + .../base/f2dq_last_base_metrics_thread1.sql | 1 + .../base/f2dq_last_base_metrics_thread2.sql | 1 + .../base/f2dq_last_base_metrics_thread3.sql | 1 + .../samples/f2dq_last_table_samples.sql | 1 + .../samples/f2dq_last_table_samples_part.sql | 7 + models/meta/f2dq_columns.sql | 39 +++ models/meta/f2dq_monitored.sql | 23 ++ models/meta/f2dq_selected.sql | 6 + models/metrics/final/f2dq_metrics.sql | 1 + .../for_anomalies/f2dq_last_metrics.sql | 12 + .../metrics/for_anomalies/f2dq_last_stats.sql | 104 +++++++ .../metrics/types/base/f2dq_base_metrics.sql | 54 ++++ .../types/samples/f2dq_table_samples.sql | 17 ++ .../types/schema/f2dq_columns_over_time.sql | 35 +++ packages.yml | 3 + profiles.yml | 60 ++++ snapshots/.gitkeep | 0 static/lineage_graph.png | Bin 0 -> 122924 bytes tests/.gitkeep | 0 188 files changed, 5251 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/documentation-request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/generate-docs.yml create mode 100644 .github/workflows/run-db-tests.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 analysis/.gitkeep create mode 100644 data/.gitkeep create mode 100644 dbt_project.yml create mode 100644 integration_tests/.gitignore create mode 100644 integration_tests/README.md create mode 100644 integration_tests/analysis/.gitkeep create mode 100644 integration_tests/dbt_project.yml create mode 100644 integration_tests/macros/.gitkeep create mode 100644 integration_tests/macros/create_test_source_tables.sql create mode 100644 integration_tests/macros/drop_all_schemas.sql create mode 100644 integration_tests/macros/my_metrics.sql create mode 100644 integration_tests/macros/test_utils.sql create mode 100644 integration_tests/macros/trigger_schema_change.sql create mode 100644 integration_tests/models/metrics/f2dq_metrics.yml create mode 100644 integration_tests/models/monitoring/schema.yml create mode 100644 integration_tests/models/monitoring/test_f2dq_anomalies.sql create mode 100644 integration_tests/models/monitoring/test_f2dq_metrics.sql create mode 100644 integration_tests/models/monitoring/test_f2dq_table_samples.sql create mode 100644 integration_tests/models/monitoring/test_f2dq_test_history.sql create mode 100644 integration_tests/models/monitoring/test_f2dq_z_score.sql create mode 100644 integration_tests/models/public_macros/cleaning/sanitized_user_data.sql create mode 100644 integration_tests/models/public_macros/cleaning/schema.yml create mode 100644 integration_tests/models/public_macros/filtering/deduplicated.sql create mode 100644 integration_tests/models/public_macros/filtering/duplicates.sql create mode 100644 integration_tests/models/public_macros/filtering/schema.yml create mode 100644 integration_tests/models/public_macros/normalizing/schema.yml create mode 100644 integration_tests/models/public_macros/normalizing/us_states_normalized.sql create mode 100644 integration_tests/models/public_macros/validating/schema.yml create mode 100644 integration_tests/models/public_macros/validating/validated_date_and_time.sql create mode 100644 integration_tests/models/public_macros/validating/validated_emails.sql create mode 100644 integration_tests/models/public_macros/validating/validated_ips.sql create mode 100644 integration_tests/models/public_macros/validating/validated_numbers.sql create mode 100644 integration_tests/models/public_macros/validating/validated_uuids.sql create mode 100644 integration_tests/models/sources/schema.yml create mode 100644 integration_tests/models/transformed/buy_events.sql create mode 100644 integration_tests/models/transformed/schema.yml create mode 100644 integration_tests/packages.yml create mode 100644 integration_tests/pytest.ini create mode 100644 integration_tests/python_tests/__init__.py create mode 100644 integration_tests/python_tests/conftest.py create mode 100755 integration_tests/python_tests/run_all_dbs.sh create mode 100644 integration_tests/python_tests/test_cleaners.py create mode 100644 integration_tests/python_tests/test_filters.py create mode 100644 integration_tests/python_tests/test_monitoring.py create mode 100644 integration_tests/python_tests/test_normalizers.py create mode 100644 integration_tests/python_tests/test_validate.py create mode 100644 integration_tests/python_tests/utils/__init__.py create mode 100644 integration_tests/python_tests/utils/run.py create mode 100644 integration_tests/requirements.txt create mode 100644 integration_tests/seeds/.gitkeep create mode 100644 integration_tests/seeds/monitoring/expected_anomalies.csv create mode 100644 integration_tests/seeds/monitoring/expected_metrics.csv create mode 100644 integration_tests/seeds/monitoring/expected_table_samples.csv create mode 100644 integration_tests/seeds/monitoring/expected_test_history.csv create mode 100644 integration_tests/seeds/monitoring/expected_z_score.csv create mode 100644 integration_tests/seeds/monitoring/sample_table.csv create mode 100644 integration_tests/seeds/monitoring/sample_with_anomaly.csv create mode 100644 integration_tests/seeds/monitoring/sample_without_time_filter.csv create mode 100644 integration_tests/seeds/public_macros/cleaning/expected_sample_user_data.csv create mode 100644 integration_tests/seeds/public_macros/cleaning/sample_user_data.csv create mode 100644 integration_tests/seeds/public_macros/filtering/duplicated.csv create mode 100644 integration_tests/seeds/public_macros/filtering/expected_deduplicated.csv create mode 100644 integration_tests/seeds/public_macros/filtering/expected_duplicates.csv create mode 100644 integration_tests/seeds/public_macros/normalizing/abbreviated_us_states.csv create mode 100644 integration_tests/seeds/public_macros/normalizing/expected_us_states_normalized.csv create mode 100644 integration_tests/seeds/public_macros/normalizing/us_states_normalization.csv create mode 100644 integration_tests/seeds/public_macros/validating/expected_validated_credit_cards.csv create mode 100644 integration_tests/seeds/public_macros/validating/expected_validated_date_and_time.csv create mode 100644 integration_tests/seeds/public_macros/validating/expected_validated_emails.csv create mode 100644 integration_tests/seeds/public_macros/validating/expected_validated_ips.csv create mode 100644 integration_tests/seeds/public_macros/validating/expected_validated_numbers.csv create mode 100644 integration_tests/seeds/public_macros/validating/expected_validated_uuids.csv create mode 100644 integration_tests/seeds/public_macros/validating/validate_date_and_time.csv create mode 100644 integration_tests/seeds/public_macros/validating/validate_emails.csv create mode 100644 integration_tests/seeds/public_macros/validating/validate_ip.csv create mode 100644 integration_tests/seeds/public_macros/validating/validate_numbers.csv create mode 100644 integration_tests/seeds/public_macros/validating/validate_uuid.csv create mode 100644 integration_tests/snapshots/.gitkeep create mode 100644 integration_tests/tests/.gitkeep create mode 100644 macros/.gitkeep create mode 100644 macros/config/get_model_config.sql create mode 100644 macros/db/bigquery/quote_column_name.sql create mode 100644 macros/db/bigquery/quote_string.sql create mode 100644 macros/db/bigquery/split_and_return_nth_value.sql create mode 100644 macros/db/core/escape.sql create mode 100644 macros/db/core/identifier_mapping.sql create mode 100644 macros/db/core/quote_column_name.sql create mode 100644 macros/db/core/quote_string.sql create mode 100644 macros/db/core/split_and_return_nth_value.sql create mode 100644 macros/db/postgres/escape.sql create mode 100644 macros/db/postgres/postgres_type_db.sql create mode 100644 macros/db/postgres/quote_string.sql create mode 100644 macros/db/redshift/escape.sql create mode 100644 macros/db/redshift/quote_string.sql create mode 100644 macros/db/snowflake/identifier_mapping.sql create mode 100644 macros/meta/get_monitored.sql create mode 100644 macros/meta/information_schema.sql create mode 100644 macros/meta/monitored_model_queries.sql create mode 100644 macros/meta/save_monitored.sql create mode 100644 macros/meta/table_name.sql create mode 100644 macros/metrics/base/build_in/column_default.sql create mode 100644 macros/metrics/base/build_in/optional_column_metrics.sql create mode 100644 macros/metrics/base/build_in/optional_table_metrics.sql create mode 100644 macros/metrics/base/build_in/table_default.sql create mode 100644 macros/metrics/base/expression.sql create mode 100644 macros/metrics/base/internal_model_template.sql create mode 100644 macros/metrics/base/queries.sql create mode 100644 macros/post_hook/f2dq_monitored.sql create mode 100644 macros/public/cleaning/clean_additional_whitespace.sql create mode 100644 macros/public/cleaning/clean_blacklist.sql create mode 100644 macros/public/cleaning/clean_capitalize_words.sql create mode 100644 macros/public/filtering/get_duplicates.sql create mode 100644 macros/public/filtering/remove_duplicates.sql create mode 100644 macros/public/normalizing/normalize_values.sql create mode 100644 macros/public/store/export_alerts.sql create mode 100644 macros/public/store/export_table_samples.sql create mode 100644 macros/public/store/export_tests_history.sql create mode 100644 macros/public/store/generate_overview.sql create mode 100644 macros/public/validating/regex_dict.sql create mode 100644 macros/public/validating/valid_with_regex.sql create mode 100644 macros/run_end/save_results_history.sql create mode 100644 macros/samples/internal_model_template.sql create mode 100644 macros/store/insert_list_to_table.sql create mode 100644 macros/tests/test_metrics.sql create mode 100644 macros/utils/agate/row_value.sql create mode 100644 macros/utils/bool_to_string.sql create mode 100644 macros/utils/column_types.sql create mode 100644 macros/utils/comma_delimited_list.sql create mode 100644 macros/utils/comparison_text.sql create mode 100644 macros/utils/compat_dual_read.sql create mode 100644 macros/utils/deduplication/add_duplication_context.sql create mode 100644 macros/utils/depends_macro.sql create mode 100644 macros/utils/dict_from_list.sql create mode 100644 macros/utils/fivetran_utils/json_extract.sql create mode 100644 macros/utils/fivetran_utils/percentile.sql create mode 100644 macros/utils/for_loops.sql create mode 100644 macros/utils/formulas.sql create mode 100644 macros/utils/generate_alert_message.sql create mode 100644 macros/utils/get_database.sql create mode 100644 macros/utils/in_compile.sql create mode 100644 macros/utils/is_list.sql create mode 100644 macros/utils/json/to_single_json.sql create mode 100644 macros/utils/mock/empty_tables.sql create mode 100644 macros/utils/monitored_config.sql create mode 100644 macros/utils/quote.sql create mode 100644 macros/utils/regular_expression.sql create mode 100644 macros/utils/time_macros.sql create mode 100644 macros/utils/used_types.sql create mode 100644 models/alerts/f2dq_alerts.sql create mode 100644 models/alerts/f2dq_anomalies.sql create mode 100644 models/alerts/f2dq_schema_changes.sql create mode 100644 models/alerts/f2dq_test_runs.sql create mode 100644 models/alerts/f2dq_z_score.sql create mode 100644 models/internal/f2dq_run_started_at.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_part0.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_part1.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_part2.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_part3.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_thread0.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_thread1.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_thread2.sql create mode 100644 models/internal/metrics/base/f2dq_last_base_metrics_thread3.sql create mode 100644 models/internal/samples/f2dq_last_table_samples.sql create mode 100644 models/internal/samples/f2dq_last_table_samples_part.sql create mode 100644 models/meta/f2dq_columns.sql create mode 100644 models/meta/f2dq_monitored.sql create mode 100644 models/meta/f2dq_selected.sql create mode 100644 models/metrics/final/f2dq_metrics.sql create mode 100644 models/metrics/for_anomalies/f2dq_last_metrics.sql create mode 100644 models/metrics/for_anomalies/f2dq_last_stats.sql create mode 100644 models/metrics/types/base/f2dq_base_metrics.sql create mode 100644 models/metrics/types/samples/f2dq_table_samples.sql create mode 100644 models/metrics/types/schema/f2dq_columns_over_time.sql create mode 100644 packages.yml create mode 100644 profiles.yml create mode 100644 snapshots/.gitkeep create mode 100644 static/lineage_graph.png create mode 100644 tests/.gitkeep diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..534c261 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: bug +assignees: '' + +--- + +**Describe the bug** +Description of what the bug is. + +**Expected behavior** +Description of what you expected to happen. + +**To Reproduce** +Steps to reproduce the behavior: + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Logs and additional context** +If application, any other context, logs etc.here diff --git a/.github/ISSUE_TEMPLATE/documentation-request.md b/.github/ISSUE_TEMPLATE/documentation-request.md new file mode 100644 index 0000000..483998e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation-request.md @@ -0,0 +1,12 @@ +--- +name: Documentation request +about: Describe this issue template's purpose here. +title: "[DOCUMENTATION]" +labels: documentation +assignees: '' + +--- + +**Tell us about the documentation you'd like us to add or update** + +**Is anything not clear or outdates in the current version of docs?** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..946d347 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE]" +labels: '' +assignees: '' + +--- + +**Tell us about the problem you're trying to solve** +What are you trying to do, and why is it hard? A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you’d like** +A clear and concise description of what you want to see happen, or the change you would like to see + +**Describe the alternative you’ve considered or used** +A clear and concise description of any alternative solutions or features you've considered or are using today. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a91d94a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,6 @@ +## What +*Describe what the change is solving* +*It helps to add screenshots if it affects the frontend.* + +## How +*Describe the solution* diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml new file mode 100644 index 0000000..1330e2c --- /dev/null +++ b/.github/workflows/generate-docs.yml @@ -0,0 +1,55 @@ +name: Generate Documentation + +on: [push] + +env: + DBT_PROFILES_DIR: ./ + DBT_VERSION: 1.7 + PYTHON_VERSION: "3.8.x" + +jobs: + generate-docs: + runs-on: ubuntu-latest + if: github.event_name == 'push' + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + steps: + - name: Check out + uses: actions/checkout@v2 + + - uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies and run + run: | + pip install protobuf==4.25.3 dbt-postgres==$DBT_VERSION + dbt deps + dbt run + + - name: Generate Documentation + run: dbt docs generate + + - name: Copy files + if: github.ref == 'refs/heads/main' + run: 'mkdir docs && cp target/{catalog.json,index.html,manifest.json,run_results.json} docs/' + shell: bash + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' && !env.ACT + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs diff --git a/.github/workflows/run-db-tests.yml b/.github/workflows/run-db-tests.yml new file mode 100644 index 0000000..610de31 --- /dev/null +++ b/.github/workflows/run-db-tests.yml @@ -0,0 +1,144 @@ +name: Run package tests for all dbs + +on: [push, delete] + +env: + DBT_PROFILES_DIR: ${{ github.workspace }}/ + SNOWFLAKE_F2DQ_TESTING_ACCOUNT: ${{ secrets.SNOWFLAKE_F2DQ_TESTING_ACCOUNT }} + F2DQ_TESTING_USER: ${{ secrets.F2DQ_TESTING_USER }} + F2DQ_TESTING_PASSWORD: ${{ secrets.F2DQ_TESTING_PASSWORD }} + REDSHIFT_F2DQ_TESTING_HOST: ${{ secrets.REDSHIFT_F2DQ_TESTING_HOST }} + BIGQUERY_TESTING_TYPE: ${{ secrets.BIGQUERY_TESTING_TYPE }} + BIGQUERY_TESTING_PROJECT_ID: ${{ secrets.BIGQUERY_TESTING_PROJECT_ID }} + BIGQUERY_TESTING_PRIVATE_KEY_ID: ${{ secrets.BIGQUERY_TESTING_PRIVATE_KEY_ID }} + BIGQUERY_TESTING_PRIVATE_KEY: ${{ secrets.BIGQUERY_TESTING_PRIVATE_KEY }} + BIGQUERY_TESTING_CLIENT_EMAIL: ${{ secrets.BIGQUERY_TESTING_CLIENT_EMAIL }} + BIGQUERY_TESTING_CLIENT_ID: ${{ secrets.BIGQUERY_TESTING_CLIENT_ID }} + BIGQUERY_TESTING_AUTH_URI: ${{ secrets.BIGQUERY_TESTING_AUTH_URI }} + BIGQUERY_TESTING_TOKEN_URI: ${{ secrets.BIGQUERY_TESTING_TOKEN_URI }} + BIGQUERY_TESTING_AUTH_PROVIDER_X509_CERT_URL: ${{ secrets.BIGQUERY_TESTING_AUTH_PROVIDER_X509_CERT_URL }} + BIGQUERY_TESTING_CLIENT_X509_CERT_URL: ${{ secrets.BIGQUERY_TESTING_CLIENT_X509_CERT_URL }} + DBT_VERSION: 1.7 + PYTHON_VERSION: "3.8.x" + +jobs: + test-postgres: + runs-on: ubuntu-latest + if: github.event_name == 'push' + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + steps: + - name: Check out + uses: actions/checkout@v2 + + - uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + working-directory: ./integration_tests + run: | + pip install -r requirements.txt + pip install dbt-postgres==$DBT_VERSION + dbt deps + + - name: Test DB + working-directory: ./integration_tests/python_tests + run: pytest --db postgres --source_schema dq + + test-other-dbs: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.repository == 're-data/dbt-re-data' && github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + database: [snowflake, bigquery, redshift] + steps: + - name: Check out + uses: actions/checkout@v2 + + - uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v3.x + + - name: Set the DQ_SCHEMA environment variable + shell: bash + run: | + echo "DQ_SCHEMA=dq_${GITHUB_REF_SLUG//[^[:alnum:]]/_}" >> $GITHUB_ENV + + - name: Print DQ_SCHEMA + run: | + echo $DQ_SCHEMA + + - name: Install dependencies + working-directory: ./integration_tests + run: | + pip install -r requirements.txt + pip install dbt-${{ matrix.database }}==$DBT_VERSION + dbt deps + + - name: Drop schemas + working-directory: ./integration_tests + run: | + dbt run-operation drop_all_schemas --args "{ schema_name: ${{ env.DQ_SCHEMA }} }" --profile f2dq_${{ matrix.database }} --vars "{ source_schema: ${{ env.DQ_SCHEMA }} }" + + - name: Create Schemas if needed + if: matrix.database == 'redshift' + working-directory: ./integration_tests + run: | + dbt run-operation create_required_schemas --args "{ schema_name: ${{ env.DQ_SCHEMA }} }" --profile f2dq_${{ matrix.database }} --vars "{ source_schema: ${{ env.DQ_SCHEMA }} }" + + - name: Test DB + working-directory: ./integration_tests/python_tests + run: | + pytest --db ${{ matrix.database }} --source_schema ${{ env.DQ_SCHEMA }} + + clean-up-schemas: + runs-on: ubuntu-latest + if: github.event_name == 'delete' && github.repository == 're-data/dbt-re-data' && github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + database: [snowflake, bigquery, redshift] + steps: + - name: Check out + uses: actions/checkout@v2 + + - uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v3.x + + - name: Set the DQ_SCHEMA environment variable + shell: bash + run: | + echo "DQ_SCHEMA=dq_${GITHUB_EVENT_REF_SLUG//[^[:alnum:]]/_}" >> $GITHUB_ENV + + - name: Print DQ_SCHEMA + run: | + echo $DQ_SCHEMA + + - name: Install dependencies and drop branch schema + working-directory: ./integration_tests + run: | + pip install -r requirements.txt + pip install dbt-${{ matrix.database }}==$DBT_VERSION + dbt deps + dbt run-operation drop_all_schemas --args "{ schema_name: ${{ env.DQ_SCHEMA }} }" --profile f2dq_${{ matrix.database }} --vars "{ source_schema: ${{ env.DQ_SCHEMA }} }" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e9bf5e --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# dbt build artefacts +target/ +dbt_modules/ +dbt_packages/ +logs/ +# Keep package model sources under models/logs/ (not the dbt logs/ dir) + +# Local / secrets +.user.yml +.env +.env.* +*.pem +*.key + +# Python / IDE +*.pyc +__pycache__/ +*.egg-info +.python-version +venv/ +.venv/ +.idea/ +.vscode/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6292c40 --- /dev/null +++ b/LICENSE @@ -0,0 +1,38 @@ +MIT License + +Copyright (c) 2020 redata-team (original re_data project) +Copyright (c) 2026 fast2.dev (f2dq fork) + +This package is a fork of re_data (https://github.com/re-data/re-data). +The fork is maintained by fast2.dev and ships under the same MIT license +as the upstream. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================================================== +Third party Apache 2.0 licenses +======================================================================== + +The following files contain significant parts of code licensed under third +party Apache 2.0 License (inherited from the upstream re_data project): + +macros/public/validating/regex_dict.sql +macros/utils/fivetran_utils/percentile.sql +macros/utils/fivetran_utils/json_extract.sql diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3341de --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ + +# Maybe change this if you are not running on a Mac +CONTAINER_ARCH = linux/amd64 + +.PHONY: help run-all-ci test-postgres generate-docs + +help: + $(info ${HELP_MESSAGE}) + @exit 0 + + +# Run GitHub Actions CI jobs locally +run-all-ci: test-postgres generate-docs + @echo "All CI steps completed." + +test-postgres: + @echo "Running test-postgres job..." + act -j test-postgres --container-architecture $(CONTAINER_ARCH) + +generate-docs: + @echo "Running generate-docs job..." + act -j generate-docs --container-architecture $(CONTAINER_ARCH) + + +define HELP_MESSAGE +Usage: $ make [TARGETS] + +TARGETS + help Shows this help message + run-all-ci Runs all CI steps + test-postgres Runs test-postgres job + generate-docs Generates documentation + +endef diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b948c5 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# dbt_f2dq (`f2dq`) + +Modernized fork of [re_data](https://github.com/re-data/re-data) for fast2.dev Data Quality (f2dq). + +- **dbt package name:** `f2dq` (what `dbt_project.yml` / `detect_namespace()` use) +- **Hub-style identity:** `dbt_f2dq` +- **Version:** `0.12.1` +- **Requires:** dbt-core `>=1.0,<2.0`, `dbt_utils==1.4.1` + +## Install (git — recommended) + +```yaml +packages: + - git: "https://gitea.m7xlab.top/fast2dev/dbt-f2dq.git" + revision: main # or a tag like v0.12.1 +``` + +Then: + +```bash +dbt deps +``` + +Pin a release tag in production; use `main` only if you want floating tip. + +## Install (local path — active development) + +Prefer a local path while editing the package itself: + +```yaml +packages: + - local: /path/to/dbt_f2dq +``` + +## Dual-read (migration) + +Node config keys accept either prefix: + +- `+f2dq_monitored` / `+re_data_monitored` +- `+f2dq_time_filter` / `+re_data_time_filter` +- (same for metrics, columns, anomaly_detector, owners) + +Vars: `f2dq:` preferred; boolean enable flags also honour `re_data:` +(`store_table_samples`, `save_test_history`, etc.) via `f2dq.f2dq_var()`. + +## Artefacts + +Exports default to `target/f2dq/*.json` (`overview`, `monitored`, `alerts`, +`tests_history`, `table_samples`). Enable samples + test history: + +```yaml +vars: + f2dq:store_table_samples: true + f2dq:save_test_history: true +``` + +## Repository + +- Public clone URL: `https://gitea.m7xlab.top/fast2dev/dbt-f2dq.git` +- SSH (when port 22 is reachable): `git@gitea.m7xlab.top:fast2dev/dbt-f2dq.git` diff --git a/analysis/.gitkeep b/analysis/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dbt_project.yml b/dbt_project.yml new file mode 100644 index 0000000..db121f2 --- /dev/null +++ b/dbt_project.yml @@ -0,0 +1,73 @@ +name: "f2dq" +version: "0.12.1" # dbt_f2dq: modernized fork (dbt_utils 1.4.1); dual-read re_data_* keys +config-version: 2 + +require-dbt-version: [">=1.0.0", "<2.0.0"] + +profile: "f2dq_postgres" + +target-path: "target" # directory which will store compiled SQL files +clean-targets: ["target", "dbt_modules", "dbt_packages"] + +on-run-end: + # Dual-read: f2dq:save_test_history or legacy re_data:save_test_history + - "{% if var('f2dq:save_test_history') or var('re_data:save_test_history', false) %} {{ f2dq.save_test_history(results) }} {% endif %}" + +vars: + f2dq:max_columns_in_query: 10 + f2dq:time_window_end: '{{ run_started_at.strftime("%Y-%m-%d 00:00:00") }}' + f2dq:time_window_start: '{{ (run_started_at - modules.datetime.timedelta(1)).strftime("%Y-%m-%d 00:00:00") }}' + f2dq:anomaly_detection_look_back_days: 30 + f2dq:select: null + f2dq:anomalies_filtered: f2dq_anomalies + + f2dq:alerting_z_score: 3 + + f2dq:save_test_history: false + f2dq:show_warns_as_alerts: false + + f2dq:anomaly_detector: + name: modified_z_score + threshold: 3 + + f2dq:store_table_samples: false + + f2dq:metrics_groups: + table_metrics: + table: + - row_count + - freshness + + column_metrics: + column: + numeric: + - min + - max + - avg + - stddev + - variance + - nulls_count + - nulls_percent + text: + - min_length + - max_length + - avg_length + - nulls_count + - missing_count + - nulls_percent + - missing_percent + boolean: + - count_true + - count_false + - nulls_count + - nulls_percent + + f2dq:default_metrics: + - table_metrics + - column_metrics + +models: + f2dq: + +schema: f2 + internal: + +schema: f2_internal diff --git a/integration_tests/.gitignore b/integration_tests/.gitignore new file mode 100644 index 0000000..eb35edc --- /dev/null +++ b/integration_tests/.gitignore @@ -0,0 +1,7 @@ + +target/ +dbt_modules/ +dbt_packages/ +logs/ +.user.yml +package-lock.yml diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 0000000..bcea1f9 --- /dev/null +++ b/integration_tests/README.md @@ -0,0 +1 @@ +f2dq integration tests — dbt project for running f2dq dbt-package integration tests diff --git a/integration_tests/analysis/.gitkeep b/integration_tests/analysis/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration_tests/dbt_project.yml b/integration_tests/dbt_project.yml new file mode 100644 index 0000000..cd32cd1 --- /dev/null +++ b/integration_tests/dbt_project.yml @@ -0,0 +1,116 @@ +name: "f2dq_integration_tests" +version: "1.0" +config-version: 2 + +# Changed when running tests +profile: "f2dq_postgres" + +target-path: "target" +clean-targets: ["target", "dbt_modules", "dbt_packages"] + +models: + f2dq: + enabled: true + +sources: + f2dq_integration_tests: + +f2dq_monitored: true + +f2dq_time_filter: null + +vars: + f2dq:store_table_samples: true + f2dq:anomaly_detector: + name: modified_z_score + threshold: 0.6 + f2dq:max_columns_in_query: 1 + + f2dq:select: + - tag:testtag + - sample_table + - sample_without_time_filter + - sample_with_anomaly + - f2dq_source_test_table + + f2dq:metrics_groups: + integration_test_group: + table: + - row_count + - freshness + - my_distinct_table_rows + + column: + numeric: + - min + - max + - avg + - stddev + - variance + - nulls_count + - nulls_percent + - diff # my own custom metric + + text: + - min_length + - max_length + - avg_length + - nulls_count + - nulls_percent + - missing_percent + - missing_count + + f2dq:default_metrics: + - integration_test_group + +seeds: + +schema: seeds + +quote_columns: false + + f2dq_integration_tests: + monitoring: + sample_with_anomaly: + +f2dq_monitored: true + +f2dq_time_filter: creation_time + + sample_without_time_filter: + +f2dq_monitored: true + +f2dq_time_filter: null + + sample_table: + +f2dq_monitored: true + +f2dq_time_filter: creation_time + + +f2dq_columns: + - event_type + - value1 + - value2 + - null_value + + +f2dq_metrics: + table: + - my_custom_table_metric # my own custom metric + - distinct_table_rows + column: + event_type: + - regex_test: + regex: ([A-Za-z0-9]+) + - match_regex: + regex: ^sell + - match_regex_percent: + regex: ^sell + - not_match_regex: + regex: ^buy + - not_match_regex_percent: + regex: ^buy + - distinct_values + - duplicate_values + - duplicate_rows + - unique_rows + + expected_z_score: + +column_types: + time_window_end: "TIMESTAMP" + + expected_metrics: + +column_types: + time_window_start: "TIMESTAMP" + time_window_end: "TIMESTAMP" diff --git a/integration_tests/macros/.gitkeep b/integration_tests/macros/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration_tests/macros/create_test_source_tables.sql b/integration_tests/macros/create_test_source_tables.sql new file mode 100644 index 0000000..ef22220 --- /dev/null +++ b/integration_tests/macros/create_test_source_tables.sql @@ -0,0 +1,28 @@ + + +{% macro create_test_source_tables() %} + + {% set create_table %} + CREATE SCHEMA IF NOT EXISTS {{target.schema}}; + DROP TABLE IF EXISTS {{target.schema}}.f2dq_source_test_table; + CREATE TABLE IF NOT EXISTS {{target.schema}}.f2dq_source_test_table ( + number {{ f2dq.integer_type() }}, + description {{ f2dq.string_type() }}, + created_at {{ f2dq.timestamp_type() }} + ); + INSERT INTO {{target.schema}}.f2dq_source_test_table (number, description, created_at) VALUES + (1, 'one', current_timestamp), + (2, 'two', current_timestamp), + (3, 'three', current_timestamp), + (4, 'four', current_timestamp), + (5, 'five', current_timestamp), + (6, 'six', current_timestamp), + (7, 'seven', current_timestamp), + (8, 'eight', current_timestamp), + (9, 'nine', current_timestamp), + (10, 'ten', current_timestamp + ); + {% endset %} + {% do run_query(create_table) %} + +{% endmacro %} \ No newline at end of file diff --git a/integration_tests/macros/drop_all_schemas.sql b/integration_tests/macros/drop_all_schemas.sql new file mode 100644 index 0000000..bc8f391 --- /dev/null +++ b/integration_tests/macros/drop_all_schemas.sql @@ -0,0 +1,48 @@ +{% macro get_schemas_used(schema_name) %} + {% set schemas = [ + schema_name, + schema_name + '_re', + schema_name + '_re_internal', + schema_name + '_raw', + schema_name + '_expected', + schema_name + '_dbt_test__audit', + schema_name + '_seeds' + ] %} + {{ return (schemas) }} +{% endmacro %} + +{% macro drop_all_schemas(schema_name) %} + {% set schemas_to_drop = get_schemas_used(schema_name) %} + {{ adapter.dispatch('drop_all_schemas')(schemas_to_drop) }} +{% endmacro %} + +{% macro default__drop_all_schemas(schemas_to_drop) %} + {% for schema in schemas_to_drop %} + {% set relation = api.Relation.create(database=target.database, schema=schema) %} + {% do adapter.drop_schema(relation) %} + {% endfor %} +{% endmacro %} + +{% macro redshift__drop_all_schemas(schemas_to_drop) %} + {# + dropping schemas with adapter.drop_schema doesn't seem to work with redshift + so we default to issuing DDL commands to redshift + #} + {% set drop_query %} + {% for schema in schemas_to_drop %} + drop schema if exists {{schema}} cascade; + {% endfor %} + {% endset %} + {% do run_query(drop_query) %} +{% endmacro %} + +{% macro create_required_schemas(schema_name) %} + {# required to manually create schemas used for redshift tests #} + {% set schemas_to_drop = get_schemas_used(schema_name) %} + {% set create_query %} + {% for schema in schemas_to_drop %} + create schema if not exists {{schema}}; + {% endfor %} + {% endset %} + {% do run_query(create_query) %} +{% endmacro %} \ No newline at end of file diff --git a/integration_tests/macros/my_metrics.sql b/integration_tests/macros/my_metrics.sql new file mode 100644 index 0000000..77754e6 --- /dev/null +++ b/integration_tests/macros/my_metrics.sql @@ -0,0 +1,29 @@ +{% macro f2dq_metric_diff(context) %} + max({{context.column_name}}) - min({{context.column_name}}) +{% endmacro %} + +{% macro f2dq_metric_my_custom_table_metric(context) %} + 1000 +{% endmacro %} + +{% macro f2dq_metric_regex_test(context) %} + {{ regex_test(context.column_name, context.config.regex) }} +{% endmacro %} + +{% macro regex_test(column_name, pattern) %} + coalesce( + sum( + case when {{ f2dq.regex_match_expression(column_name, pattern) }} + then 1 + else 0 + end + ), 0 + ) +{% endmacro %} + +{% macro f2dq_metric_my_distinct_table_rows(context) %} + with temp_table AS ( + select distinct * from {{ context.table_name }} + ) + select coalesce(count(*), 0) FROM temp_table +{% endmacro %} \ No newline at end of file diff --git a/integration_tests/macros/test_utils.sql b/integration_tests/macros/test_utils.sql new file mode 100644 index 0000000..87ae590 --- /dev/null +++ b/integration_tests/macros/test_utils.sql @@ -0,0 +1,20 @@ + +{% macro to_big_integer(field) %} + cast (round({{field}} * 1000) as integer) as {{field}} +{% endmacro %} + +{% macro clean_table_name(field) %} + upper( + {{- + f2dq.clean_blacklist( + f2dq.split_and_return_nth_value(field, '.', 3), + ['"', '`'], + '' + ) + -}} + ) +{% endmacro %} + +{% macro clean_column_name(field) %} + case when ({{ field }} = '' or {{ field }} is null ) then '---' else upper({{field}}) end +{% endmacro %} diff --git a/integration_tests/macros/trigger_schema_change.sql b/integration_tests/macros/trigger_schema_change.sql new file mode 100644 index 0000000..4273f3e --- /dev/null +++ b/integration_tests/macros/trigger_schema_change.sql @@ -0,0 +1,15 @@ + +{% macro schema_change_buy_events_add_column() %} + {% set alter_table %} + alter table {{ ref('buy_events')}} add column sample_column boolean + {% endset %} + {% do run_query(alter_table) %} +{% endmacro %} + + +{% macro schema_change_buy_events_drop_column() %} + {% set alter_table %} + alter table {{ ref('buy_events')}} drop column sample_column + {% endset %} + {% do run_query(alter_table) %} +{% endmacro %} \ No newline at end of file diff --git a/integration_tests/models/metrics/f2dq_metrics.yml b/integration_tests/models/metrics/f2dq_metrics.yml new file mode 100644 index 0000000..d306a0d --- /dev/null +++ b/integration_tests/models/metrics/f2dq_metrics.yml @@ -0,0 +1,30 @@ + +version: 2 + +models: + - name: f2dq_metrics + tests: + - f2dq.metric_expression_is_true: + table: ref('buy_events') + metric: max_length + column_name: event_type + expression: value = 3 + + - f2dq.metric_equal_to: + table: ref('buy_events') + metric: max_length + column_name: event_type + value: 3 + + - f2dq.metric_in_range: + table: ref('buy_events') + metric: max_length + column_name: event_type + min_value: 3 + max_value: 3 + + - f2dq.metric_expression_is_true: + table: ref('buy_events') + metric: row_count + expression: value > 0 and value < 10 + condition: time_window_start >= '2021-05-02' \ No newline at end of file diff --git a/integration_tests/models/monitoring/schema.yml b/integration_tests/models/monitoring/schema.yml new file mode 100644 index 0000000..6612d9f --- /dev/null +++ b/integration_tests/models/monitoring/schema.yml @@ -0,0 +1,27 @@ +version: 2 + +models: + - name: test_f2dq_metrics + tests: + - dbt_utils.equality: + compare_model: ref('expected_metrics') + + - name: test_f2dq_z_score + tests: + - dbt_utils.equality: + compare_model: ref('expected_z_score') + + - name: test_f2dq_anomalies + tests: + - dbt_utils.equality: + compare_model: ref('expected_anomalies') + + - name: test_f2dq_test_history + tests: + - dbt_utils.equality: + compare_model: ref('expected_test_history') + + - name: test_f2dq_table_samples + tests: + - dbt_utils.equality: + compare_model: ref('expected_table_samples') \ No newline at end of file diff --git a/integration_tests/models/monitoring/test_f2dq_anomalies.sql b/integration_tests/models/monitoring/test_f2dq_anomalies.sql new file mode 100644 index 0000000..0205867 --- /dev/null +++ b/integration_tests/models/monitoring/test_f2dq_anomalies.sql @@ -0,0 +1,9 @@ + +select + {{ clean_table_name('table_name') }} as table_name, + {{ clean_column_name('column_name') }} as column_name, + metric, + anomaly_detector, + interval_length_sec + +from {{ ref('f2dq_anomalies') }} \ No newline at end of file diff --git a/integration_tests/models/monitoring/test_f2dq_metrics.sql b/integration_tests/models/monitoring/test_f2dq_metrics.sql new file mode 100644 index 0000000..5fa5773 --- /dev/null +++ b/integration_tests/models/monitoring/test_f2dq_metrics.sql @@ -0,0 +1,11 @@ + +select + {{ clean_table_name('table_name') }} as table_name, + {{ clean_column_name('column_name') }} as column_name, + metric, + time_window_start, + time_window_end, + {{ to_big_integer('value') }}, + interval_length_sec + +from {{ ref('f2dq_metrics') }} \ No newline at end of file diff --git a/integration_tests/models/monitoring/test_f2dq_table_samples.sql b/integration_tests/models/monitoring/test_f2dq_table_samples.sql new file mode 100644 index 0000000..1c2dc19 --- /dev/null +++ b/integration_tests/models/monitoring/test_f2dq_table_samples.sql @@ -0,0 +1,9 @@ + +select + {{ clean_table_name('table_name') }} as table_name, + length(sample_data) as sample_data_length +from {{ ref('f2dq_table_samples') }} +where {{ clean_table_name('table_name') }} != 'SAMPLE_WITHOUT_TIME_FILTER' + +-- SAMPLE_WITHOUT_TIME_FILTER because this table doesn't have a time filter, it's not possible to say how +-- exactly the sampel of it should look like. \ No newline at end of file diff --git a/integration_tests/models/monitoring/test_f2dq_test_history.sql b/integration_tests/models/monitoring/test_f2dq_test_history.sql new file mode 100644 index 0000000..a87bd54 --- /dev/null +++ b/integration_tests/models/monitoring/test_f2dq_test_history.sql @@ -0,0 +1,10 @@ + +select + {{ clean_table_name('table_name') }} as table_name, + {{ clean_column_name('column_name') }} as column_name, + right(test_name, 15) as test_name, + status, + {{ clean_column_name('message') }} as message, + cast (failures_count as integer) as failures_count, + severity +from {{ ref('f2dq_test_history') }} \ No newline at end of file diff --git a/integration_tests/models/monitoring/test_f2dq_z_score.sql b/integration_tests/models/monitoring/test_f2dq_z_score.sql new file mode 100644 index 0000000..03b0c8b --- /dev/null +++ b/integration_tests/models/monitoring/test_f2dq_z_score.sql @@ -0,0 +1,22 @@ + +{% set values_compare = [ + 'z_score_value', + 'modified_z_score_value', + 'last_value', + 'last_avg', + 'last_stddev', + 'last_median', + 'last_iqr', + 'last_median_absolute_deviation', + 'last_mean_absolute_deviation', +] %} + +select + {{ clean_table_name('table_name') }} as table_name, + {{ clean_column_name('column_name') }} as column_name, + metric, + time_window_end, + {% for col in values_compare %}{{ to_big_integer(col) }},{% endfor %} + interval_length_sec + +from {{ ref('f2dq_z_score') }} \ No newline at end of file diff --git a/integration_tests/models/public_macros/cleaning/sanitized_user_data.sql b/integration_tests/models/public_macros/cleaning/sanitized_user_data.sql new file mode 100644 index 0000000..2ee0021 --- /dev/null +++ b/integration_tests/models/public_macros/cleaning/sanitized_user_data.sql @@ -0,0 +1,4 @@ +select + {{ f2dq.clean_capitalize_words(f2dq.clean_additional_whitespaces('full_name')) }} as full_name, + {{ f2dq.clean_blacklist('email', ['^[a-zA-Z0-9_.+-]+'], '*****') }} as email +from {{ ref('sample_user_data') }} diff --git a/integration_tests/models/public_macros/cleaning/schema.yml b/integration_tests/models/public_macros/cleaning/schema.yml new file mode 100644 index 0000000..2e08bb1 --- /dev/null +++ b/integration_tests/models/public_macros/cleaning/schema.yml @@ -0,0 +1,7 @@ +version: 2 + +models: + - name: sanitized_user_data + tests: + - dbt_utils.equality: + compare_model: ref('expected_sample_user_data') \ No newline at end of file diff --git a/integration_tests/models/public_macros/filtering/deduplicated.sql b/integration_tests/models/public_macros/filtering/deduplicated.sql new file mode 100644 index 0000000..4807131 --- /dev/null +++ b/integration_tests/models/public_macros/filtering/deduplicated.sql @@ -0,0 +1,16 @@ +with x as + {{ f2dq.filter_remove_duplicates( + ref('duplicated'), ['transaction_id'], ['creation_time']) }} + +select *, 'take_first' as use_case from x + +union all + +select *, 'take_last' as use_case from {{ f2dq.filter_remove_duplicates( + ref('duplicated'), ['transaction_id'], ['creation_time desc']) }} duplicates + + +union all + +select *, 'take_all_statuses' as use_case from {{ f2dq.filter_remove_duplicates( + ref('duplicated'), ['transaction_id', 'status'], ['creation_time desc']) }} duplicates \ No newline at end of file diff --git a/integration_tests/models/public_macros/filtering/duplicates.sql b/integration_tests/models/public_macros/filtering/duplicates.sql new file mode 100644 index 0000000..42e2459 --- /dev/null +++ b/integration_tests/models/public_macros/filtering/duplicates.sql @@ -0,0 +1,2 @@ + {{ f2dq.filter_get_duplicates( + ref('duplicated'), ['transaction_id'], ['creation_time']) }} diff --git a/integration_tests/models/public_macros/filtering/schema.yml b/integration_tests/models/public_macros/filtering/schema.yml new file mode 100644 index 0000000..cf92f9b --- /dev/null +++ b/integration_tests/models/public_macros/filtering/schema.yml @@ -0,0 +1,11 @@ +version: 2 + +models: + - name: deduplicated + tests: + - dbt_utils.equality: + compare_model: ref('expected_deduplicated') + - name: duplicates + tests: + - dbt_utils.equality: + compare_model: ref('expected_duplicates') \ No newline at end of file diff --git a/integration_tests/models/public_macros/normalizing/schema.yml b/integration_tests/models/public_macros/normalizing/schema.yml new file mode 100644 index 0000000..6c66f79 --- /dev/null +++ b/integration_tests/models/public_macros/normalizing/schema.yml @@ -0,0 +1,7 @@ +version: 2 + +models: + - name: us_states_normalized + tests: + - dbt_utils.equality: + compare_model: ref('expected_us_states_normalized') \ No newline at end of file diff --git a/integration_tests/models/public_macros/normalizing/us_states_normalized.sql b/integration_tests/models/public_macros/normalizing/us_states_normalized.sql new file mode 100644 index 0000000..1381443 --- /dev/null +++ b/integration_tests/models/public_macros/normalizing/us_states_normalized.sql @@ -0,0 +1,29 @@ +with us_states_normalization_cte as ( + select source, target from {{ ref('us_states_normalization') }} +) + +{% set us_states_mapping = {'Ala.': 'Alabama', 'Alaska': 'Alaska', 'Ariz.': 'Arizona', 'Ark.': 'Arkansas', 'Calif.': 'California', 'Colo.': 'Colorado', 'Conn.': 'Connecticut', + 'Del.': 'Delaware', 'D.C.': 'District of Columbia', 'Fla.': 'Florida', 'Ga.': 'Georgia', 'Hawaii': 'Hawaii', 'Idaho': 'Idaho', 'Ill.': 'Illinois', 'Ind.': 'Indiana', + 'Iowa': 'Iowa', 'Kans.': 'Kansas', 'Ky.': 'Kentucky', 'La.': 'Louisiana', 'Maine': 'Maine', 'Md.': 'Maryland', 'Mass.': 'Massachusetts', 'Mich.': 'Michigan', + 'Minn.': 'Minnesota', 'Miss.': 'Mississippi', 'Mo.': 'Missouri', 'Mont.': 'Montana', 'Nebr.': 'Nebraska', 'Nev.': 'Nevada', 'N.H.': 'New Hampshire', 'N.J.': 'New Jersey', + 'N.M.': 'New Mexico', 'N.Y.': 'New York', 'N.C.': 'North Carolina', 'N.D.': 'North Dakota', 'Ohio': 'Ohio', 'Okla.': 'Oklahoma', 'Ore.': 'Oregon', 'Pa.': 'Pennsylvania', + 'R.I.': 'Rhode Island', 'S.C.': 'South Carolina', 'S.D.': 'South Dakota', 'Tenn.': 'Tennessee', 'Tex.': 'Texas', 'Utah': 'Utah', 'Vt.': 'Vermont', 'Va.': 'Virginia', + 'Wash.': 'Washington', 'W.Va.': 'West Virginia', 'Wis.': 'Wisconsin', 'Wyo.': 'Wyoming'} +%} + + +{# + We have three ways of passing the source used for normalization + 1. passing a dbt model using ref('') which is of type Relation. + 2. passing a common table expression that contains the source mapping + Note: model or cte must include "source" and "target" column names used for normalization in 1. & 2. repectively + 3. passing a dictionary of values that map from source -> target ie {[source]: [target]} + #} + +select distinct * from ( + select state, code, state__normalized from {{ f2dq.normalize_values(ref('abbreviated_us_states'), 'state', ref('us_states_normalization')) }} s + union all + select state, code, state__normalized from {{ f2dq.normalize_values(ref('abbreviated_us_states'), 'state', 'us_states_normalization_cte') }} s + union all + select state, code, state__normalized from {{ f2dq.normalize_values(ref('abbreviated_us_states'), 'state', us_states_mapping) }} s +) as normalized \ No newline at end of file diff --git a/integration_tests/models/public_macros/validating/schema.yml b/integration_tests/models/public_macros/validating/schema.yml new file mode 100644 index 0000000..50a3a5d --- /dev/null +++ b/integration_tests/models/public_macros/validating/schema.yml @@ -0,0 +1,27 @@ +version: 2 + +models: + - name: validated_emails + tests: + - dbt_utils.equality: + compare_model: ref('expected_validated_emails') + + - name: validated_numbers + tests: + - dbt_utils.equality: + compare_model: ref('expected_validated_numbers') + + - name: validated_date_and_time + tests: + - dbt_utils.equality: + compare_model: ref('expected_validated_date_and_time') + + - name: validated_ips + tests: + - dbt_utils.equality: + compare_model: ref('expected_validated_ips') + + - name: validated_uuids + tests: + - dbt_utils.equality: + compare_model: ref('expected_validated_uuids') \ No newline at end of file diff --git a/integration_tests/models/public_macros/validating/validated_date_and_time.sql b/integration_tests/models/public_macros/validating/validated_date_and_time.sql new file mode 100644 index 0000000..8f54c9b --- /dev/null +++ b/integration_tests/models/public_macros/validating/validated_date_and_time.sql @@ -0,0 +1,14 @@ +with + all_rows as ( + select * from {{ ref('validate_date_and_time') }} + ) + +select *, + case when {{ f2dq.valid_date_eu('date_time') }} then 1 else 0 end as valid_date_eu, + case when {{ f2dq.valid_date_us('date_time') }} then 1 else 0 end as valid_date_us, + case when {{ f2dq.valid_date_inverse('date_time') }} then 1 else 0 end as valid_date_inverse, + case when {{ f2dq.valid_date_iso_8601('date_time') }} then 1 else 0 end as valid_date_iso_8601, + case when {{ f2dq.valid_time_24h('date_time') }} then 1 else 0 end as valid_time_24h, + case when {{ f2dq.valid_time_12h('date_time') }} then 1 else 0 end as valid_time_12h, + case when {{ f2dq.valid_time('date_time') }} then 1 else 0 end as valid_time + from all_rows diff --git a/integration_tests/models/public_macros/validating/validated_emails.sql b/integration_tests/models/public_macros/validating/validated_emails.sql new file mode 100644 index 0000000..aaed247 --- /dev/null +++ b/integration_tests/models/public_macros/validating/validated_emails.sql @@ -0,0 +1,7 @@ +with + all_emails as ( + select * from {{ ref('validate_emails') }} + ) + +select *, case when {{ f2dq.valid_email('email') }} then 1 else 0 end as email_valid + from all_emails diff --git a/integration_tests/models/public_macros/validating/validated_ips.sql b/integration_tests/models/public_macros/validating/validated_ips.sql new file mode 100644 index 0000000..9d8425b --- /dev/null +++ b/integration_tests/models/public_macros/validating/validated_ips.sql @@ -0,0 +1,10 @@ +with + all_rows as ( + select * from {{ ref('validate_ip') }} + ) + +select *, + case when {{ f2dq.valid_ip_v4('ip_address') }} then 1 else 0 end as valid_ip_v4, + case when {{ f2dq.valid_ip_v6('ip_address') }} then 1 else 0 end as valid_ip_v6, + case when {{ f2dq.valid_ip('ip_address') }} then 1 else 0 end as valid_ip + from all_rows diff --git a/integration_tests/models/public_macros/validating/validated_numbers.sql b/integration_tests/models/public_macros/validating/validated_numbers.sql new file mode 100644 index 0000000..a4c9c63 --- /dev/null +++ b/integration_tests/models/public_macros/validating/validated_numbers.sql @@ -0,0 +1,13 @@ +with + all_num_rows as ( + select * from {{ ref('validate_numbers') }} + ) + +select *, + case when {{ f2dq.valid_number('number') }} then 1 else 0 end as is_number, + case when {{ f2dq.valid_number_decimal_point('number') }} then 1 else 0 end as is_number_decimal_point, + case when {{ f2dq.valid_number_decimal_comma('number') }} then 1 else 0 end as is_number_decimal_comma, + case when {{ f2dq.valid_number_percentage('number') }} then 1 else 0 end as is_percentage, + case when {{ f2dq.valid_number_percentage_point('number') }} then 1 else 0 end as is_percentage_decimal_point, + case when {{ f2dq.valid_number_percentage_comma('number') }} then 1 else 0 end as is_percentage_decimal_comma + from all_num_rows diff --git a/integration_tests/models/public_macros/validating/validated_uuids.sql b/integration_tests/models/public_macros/validating/validated_uuids.sql new file mode 100644 index 0000000..711a041 --- /dev/null +++ b/integration_tests/models/public_macros/validating/validated_uuids.sql @@ -0,0 +1,8 @@ +with + all_rows as ( + select * from {{ ref('validate_uuid') }} + ) + +select *, + case when {{ f2dq.valid_uuid('uuid') }} then 1 else 0 end as valid_uuid + from all_rows diff --git a/integration_tests/models/sources/schema.yml b/integration_tests/models/sources/schema.yml new file mode 100644 index 0000000..26cec14 --- /dev/null +++ b/integration_tests/models/sources/schema.yml @@ -0,0 +1,18 @@ +version: 2 + +sources: + - name: "{{ var('source_schema', target.schema) }}" + tables: + - name: f2dq_source_test_table + columns: + - name: number + tests: + - not_null + - unique + + - name: description + tests: + - not_null + - unique + + \ No newline at end of file diff --git a/integration_tests/models/transformed/buy_events.sql b/integration_tests/models/transformed/buy_events.sql new file mode 100644 index 0000000..46800b6 --- /dev/null +++ b/integration_tests/models/transformed/buy_events.sql @@ -0,0 +1,12 @@ +{{ + config( + f2dq_monitored=true, + f2dq_time_filter='creation_time', + f2dq_anomaly_detector={'name': 'z_score', 'threshold': 0.5}, + materialized='table', + tags=['testtag'] + ) +}} +select * +from {{ ref('sample_with_anomaly') }} +where event_type = 'buy' \ No newline at end of file diff --git a/integration_tests/models/transformed/schema.yml b/integration_tests/models/transformed/schema.yml new file mode 100644 index 0000000..125dbe9 --- /dev/null +++ b/integration_tests/models/transformed/schema.yml @@ -0,0 +1,49 @@ +version: 2 + +models: + - name: buy_events + tests: + - f2dq.assert_in_range: + metric: row_count + min_value: 0 + max_value: 10 + + - f2dq.assert_equal: + metric: row_count + value: 1 + + - f2dq.assert_false: + metric: freshness + expression: value is null + + - f2dq.assert_greater_equal: + metric: my_distinct_table_rows + value: 10 + + columns: + - name: value1 + tests: + - f2dq.assert_in_range: + metric: nulls_percent + min_value: 0 + max_value: 10 + + - f2dq.assert_true: + metric: nulls_percent + expression: value = 0 + + - f2dq.assert_less: + metric: min + value: 100 + condition: time_window_start = '2021-05-02' + + - f2dq.assert_less_equal: + metric: min + value: 107 + + - name: value2 + tests: + - f2dq.assert_greater_equal: + metric: min + value: 200 + condition: time_window_start = '2021-05-02' diff --git a/integration_tests/packages.yml b/integration_tests/packages.yml new file mode 100644 index 0000000..891773a --- /dev/null +++ b/integration_tests/packages.yml @@ -0,0 +1,5 @@ +packages: + - local: ../ + + - package: dbt-labs/dbt_utils + version: [">=1.0.0", "<1.2.0"] diff --git a/integration_tests/pytest.ini b/integration_tests/pytest.ini new file mode 100644 index 0000000..b80dabf --- /dev/null +++ b/integration_tests/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +norecursedirs = dbt_modules models target logs data analysis macros snapshots tests +python_files = test_*.py +python_functions = test* +addopts = --capture=no --durations=0 \ No newline at end of file diff --git a/integration_tests/python_tests/__init__.py b/integration_tests/python_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/integration_tests/python_tests/conftest.py b/integration_tests/python_tests/conftest.py new file mode 100644 index 0000000..222cda2 --- /dev/null +++ b/integration_tests/python_tests/conftest.py @@ -0,0 +1,14 @@ +import pytest + +def pytest_addoption(parser): + parser.addoption("--db", action="store") + parser.addoption("--source_schema", action="store") + + +@pytest.fixture() +def db(pytestconfig): + return pytestconfig.getoption("db") + +@pytest.fixture() +def source_schema(pytestconfig): + return pytestconfig.getoption("source_schema") \ No newline at end of file diff --git a/integration_tests/python_tests/run_all_dbs.sh b/integration_tests/python_tests/run_all_dbs.sh new file mode 100755 index 0000000..e271867 --- /dev/null +++ b/integration_tests/python_tests/run_all_dbs.sh @@ -0,0 +1,5 @@ +pytest --db postgres $@ & +pytest --db snowflake $@ & +pytest --db bigquery $@ & +pytest --db redshift $@ & +wait diff --git a/integration_tests/python_tests/test_cleaners.py b/integration_tests/python_tests/test_cleaners.py new file mode 100644 index 0000000..7fc9bbe --- /dev/null +++ b/integration_tests/python_tests/test_cleaners.py @@ -0,0 +1,14 @@ +from .utils.run import dbt_seed, dbt_run, dbt_test + +def test_cleaners(db, source_schema, debug=True): + dbt_vars = { + 'source_schema': source_schema + } + + print (f"Running setup and tests for {db}") + + dbt_seed(f'--select public_macros.cleaning', db, dbt_vars) + dbt_run(f'--select sanitized_user_data+', db, dbt_vars) + dbt_test(f'--select sanitized_user_data', db, dbt_vars) + + print (f"Running tests completed for {db}") diff --git a/integration_tests/python_tests/test_filters.py b/integration_tests/python_tests/test_filters.py new file mode 100644 index 0000000..92764e1 --- /dev/null +++ b/integration_tests/python_tests/test_filters.py @@ -0,0 +1,25 @@ +from .utils.run import dbt_seed, dbt_run, dbt_test + +def test_deduplication(db, source_schema, debug=True): + dbt_vars = { + 'source_schema': source_schema + } + + print (f"Running setup and tests for {db}") + + dbt_seed(f'--select public_macros.filtering', db, dbt_vars) + dbt_run(f'--select deduplicated', db, dbt_vars) + dbt_test(f'--select deduplicated', db, dbt_vars) + +def test_get_duplicates(db, source_schema, debug=True): + dbt_vars = { + 'source_schema': source_schema + } + + print (f"Running setup and tests for {db}") + + dbt_seed(f'--select public_macros.filtering', db, dbt_vars) + dbt_run(f'--select duplicates', db, dbt_vars) + dbt_test(f'--select duplicates', db, dbt_vars) + + print (f"Running tests completed for {db}") diff --git a/integration_tests/python_tests/test_monitoring.py b/integration_tests/python_tests/test_monitoring.py new file mode 100644 index 0000000..a8a4a21 --- /dev/null +++ b/integration_tests/python_tests/test_monitoring.py @@ -0,0 +1,88 @@ +import os +import copy +import yaml +import json +from datetime import datetime, timedelta +from .utils.run import dbt_seed, dbt_run, dbt_test, dbt_command, dbt_build + +RUN_TIME = datetime(2021, 5, 2, 0, 0, 0) + +DBT_VARS = { + 'f2dq:time_window_start': (RUN_TIME - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"), + 'f2dq:time_window_end': RUN_TIME.strftime("%Y-%m-%d %H:%M:%S"), + 'f2dq:save_test_history': True +} + +def test_monitoring(db, source_schema): + DBT_VARS.update({'source_schema': source_schema}) + + load_deps = 'dbt deps' + assert os.system(load_deps) == 0 + + dbt_vars = copy.deepcopy(DBT_VARS) + + print (f"Running setup and tests for {db}") + + dbt_seed('--select monitoring', db, dbt_vars) + dbt_run('--models transformed', db, dbt_vars) + dbt_command( + f'dbt run-operation create_test_source_tables', + db, dbt_vars + ) + + print (f"Computing f2dq metrics for {db}") + dbt_run('--select package:f2dq', db, dbt_vars) + + dbt_command( + f'dbt run-operation schema_change_buy_events_add_column', + db, dbt_vars + ) + + # update dbts_vars to run dbt for next day of data + dbt_vars['f2dq:time_window_start'] = dbt_vars['f2dq:time_window_end'] + dbt_vars['f2dq:time_window_end'] = (RUN_TIME + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + + dbt_command( + 'dbt run --select package:f2dq --fail-fast', + db, dbt_vars + ) + + dbt_command( + 'dbt run --select monitoring.*', db, dbt_vars + ) + + dbt_test('--select test_f2dq_anomalies test_f2dq_metrics test_f2dq_z_score test_f2dq_table_samples f2dq_metrics transformed', db, dbt_vars) + # dbt build will "duplicate" saved test result history + dbt_build('--select test_f2dq_anomalies test_f2dq_metrics test_f2dq_z_score test_f2dq_table_samples f2dq_metrics transformed', db, dbt_vars) + + # tests test_history seperately, because those are actually added to DB after running + # dbt test command + dbt_test('--select test_f2dq_test_history', db, dbt_vars) + + op_vars = { + 'start_date': RUN_TIME.strftime("%Y-%m-%d"), + 'end_date': (RUN_TIME + timedelta(days=1)).strftime("%Y-%m-%d"), + 'interval': 'days:1' + } + op_vars = yaml.dump(op_vars) + + dbt_command( + f'dbt run-operation generate_overview --args "{op_vars}"', + db, dbt_vars + ) + + overview = json.load(open(f'../target/f2dq/overview.json')) + expected_types = ['metric', 'schema_change', 'schema', 'alert', 'anomaly'] + all_types = set() + + # some simple check for now + for obj in overview: + all_types.add(obj['type']) + assert obj['table_name'] + assert 'column_name' in obj + assert 'computed_on' in obj + + assert len(overview) > 100 + assert sorted(all_types) == sorted(expected_types) + + print (f"Running tests completed for {db}") diff --git a/integration_tests/python_tests/test_normalizers.py b/integration_tests/python_tests/test_normalizers.py new file mode 100644 index 0000000..32c53d0 --- /dev/null +++ b/integration_tests/python_tests/test_normalizers.py @@ -0,0 +1,14 @@ +from .utils.run import dbt_seed, dbt_run, dbt_test + +def test_normalizers(db, source_schema, debug=True): + dbt_vars = { + 'source_schema': source_schema + } + + print (f"Running setup and tests for {db}") + + dbt_seed(f'--select public_macros.normalizing', db, dbt_vars) + dbt_run(f'--select us_states_normalized+', db, dbt_vars) + dbt_test(f'--models us_states_normalized', db, dbt_vars) + + print (f"Running tests completed for {db}") diff --git a/integration_tests/python_tests/test_validate.py b/integration_tests/python_tests/test_validate.py new file mode 100644 index 0000000..b5a4f95 --- /dev/null +++ b/integration_tests/python_tests/test_validate.py @@ -0,0 +1,17 @@ +from .utils.run import dbt_seed, dbt_run, dbt_test + +def test_validate_regex(db, source_schema, debug=True): + dbt_vars = { + 'source_schema': source_schema + } + + print (f"Running setup and tests for {db}") + + dbt_seed( + f'--select public_macros.validating', db, dbt_vars + ) + + dbt_run(f'--select public_macros.validating', db, dbt_vars) + dbt_test(f'--select public_macros.validating', db, dbt_vars) + + print (f"Running tests completed for {db}") \ No newline at end of file diff --git a/integration_tests/python_tests/utils/__init__.py b/integration_tests/python_tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/integration_tests/python_tests/utils/run.py b/integration_tests/python_tests/utils/run.py new file mode 100644 index 0000000..1c5a8e0 --- /dev/null +++ b/integration_tests/python_tests/utils/run.py @@ -0,0 +1,23 @@ +import os +import yaml + +def dbt_command(command, for_db, dbt_vars, threads=None): + debug = 'DBT_MACRO_DEBUGGING=1 ' + profile_part = f' --profile f2dq_{for_db}' + yaml_vars = yaml.dump(dbt_vars) + cmd = f'{debug} {command} --vars "{yaml_vars}" {profile_part}' + if threads: + cmd += f' --threads {threads}' + assert os.system(cmd) == 0 + +def dbt_seed(args, for_db, dbt_vars): + dbt_command(f'dbt seed --full-refresh {args}', for_db, dbt_vars, threads=4) + +def dbt_run(args, for_db, dbt_vars): + dbt_command(f'dbt run --full-refresh --fail-fast {args}', for_db, dbt_vars, threads=4) + +def dbt_test(args, for_db, dbt_vars): + dbt_command(f'dbt test --store-failures --fail-fast {args}', for_db, dbt_vars, threads=4) + +def dbt_build(args, for_db, dbt_vars): + dbt_command(f'dbt build --full-refresh --store-failures --fail-fast {args}', for_db, dbt_vars, threads=4) diff --git a/integration_tests/requirements.txt b/integration_tests/requirements.txt new file mode 100644 index 0000000..7bc7cc6 --- /dev/null +++ b/integration_tests/requirements.txt @@ -0,0 +1,3 @@ +protobuf==4.25.3 +pytest==6.2.5 +pyyaml==6.0 diff --git a/integration_tests/seeds/.gitkeep b/integration_tests/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration_tests/seeds/monitoring/expected_anomalies.csv b/integration_tests/seeds/monitoring/expected_anomalies.csv new file mode 100644 index 0000000..07aeb7f --- /dev/null +++ b/integration_tests/seeds/monitoring/expected_anomalies.csv @@ -0,0 +1,29 @@ +table_name,column_name,metric,anomaly_detector,interval_length_sec +BUY_EVENTS,VALUE2,min,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +BUY_EVENTS,VALUE2,avg,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +BUY_EVENTS,---,freshness,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +BUY_EVENTS,VALUE1,min,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +BUY_EVENTS,VALUE1,max,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +BUY_EVENTS,VALUE1,avg,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +BUY_EVENTS,VALUE2,max,"{""name"": ""z_score"", ""threshold"": 0.5}",86400 +SAMPLE_TABLE,VALUE1,avg,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,unique_rows,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_rows,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,VALUE1,max,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex_percent,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,VALUE1,diff,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,VALUE1,stddev,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,avg_length,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex_percent,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,VALUE1,variance,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,max_length,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_TABLE,EVENT_TYPE,distinct_values,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,---,freshness,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,VALUE2,avg,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,VALUE1,max,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,VALUE2,min,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,VALUE1,avg,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,VALUE2,max,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 +SAMPLE_WITH_ANOMALY,VALUE1,min,"{""name"": ""modified_z_score"", ""threshold"": 0.6}",86400 diff --git a/integration_tests/seeds/monitoring/expected_metrics.csv b/integration_tests/seeds/monitoring/expected_metrics.csv new file mode 100644 index 0000000..3f8b738 --- /dev/null +++ b/integration_tests/seeds/monitoring/expected_metrics.csv @@ -0,0 +1,281 @@ +table_name,column_name,metric,time_window_start,time_window_end,value,interval_length_sec +SAMPLE_WITH_ANOMALY,VALUE1,min,2021-05-01 00:00:00,2021-05-02 00:00:00,107000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,max,2021-05-01 00:00:00,2021-05-02 00:00:00,107000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,107000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE1,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE1,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,min,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +SAMPLE_WITH_ANOMALY,VALUE2,max,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +SAMPLE_WITH_ANOMALY,VALUE2,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +SAMPLE_WITH_ANOMALY,VALUE2,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE2,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE2,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,min_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,max_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,avg_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,missing_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,missing_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,---,row_count,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +SAMPLE_WITH_ANOMALY,---,freshness,2021-05-01 00:00:00,2021-05-02 00:00:00,41308000,86400 +SAMPLE_WITH_ANOMALY,---,my_distinct_table_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,12000,86400 +BUY_EVENTS,VALUE1,min,2021-05-01 00:00:00,2021-05-02 00:00:00,107000,86400 +BUY_EVENTS,VALUE1,max,2021-05-01 00:00:00,2021-05-02 00:00:00,107000,86400 +BUY_EVENTS,VALUE1,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,107000,86400 +BUY_EVENTS,VALUE1,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +BUY_EVENTS,VALUE1,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +BUY_EVENTS,VALUE1,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,VALUE1,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,VALUE1,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,VALUE2,min,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +BUY_EVENTS,VALUE2,max,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +BUY_EVENTS,VALUE2,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +BUY_EVENTS,VALUE2,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +BUY_EVENTS,VALUE2,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +BUY_EVENTS,VALUE2,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,VALUE2,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,VALUE2,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,min_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +BUY_EVENTS,EVENT_TYPE,max_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +BUY_EVENTS,EVENT_TYPE,avg_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +BUY_EVENTS,EVENT_TYPE,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,missing_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,missing_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +BUY_EVENTS,---,row_count,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +BUY_EVENTS,---,freshness,2021-05-01 00:00:00,2021-05-02 00:00:00,41308000,86400 +BUY_EVENTS,---,my_distinct_table_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,10000,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__min,2021-05-01 00:00:00,2021-05-02 00:00:00,990,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__max,2021-05-01 00:00:00,2021-05-02 00:00:00,4990,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__avg,2021-05-01 00:00:00,2021-05-02 00:00:00,3306,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,1668,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__variance,2021-05-01 00:00:00,2021-05-02 00:00:00,2784,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__diff,2021-05-01 00:00:00,2021-05-02 00:00:00,4000,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__min_length,2021-05-01 00:00:00,2021-05-02 00:00:00,10000,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__max_length,2021-05-01 00:00:00,2021-05-02 00:00:00,17000,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__avg_length,2021-05-01 00:00:00,2021-05-02 00:00:00,14105,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__missing_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__missing_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__min_length,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__max_length,2021-05-01 00:00:00,2021-05-02 00:00:00,5000,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__avg_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3053,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__missing_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__missing_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__row_count,2021-05-01 00:00:00,2021-05-02 00:00:00,19000,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__freshness,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__my_distinct_table_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,19000,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__min,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__max,2021-05-01 00:00:00,2021-05-02 00:00:00,10000,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__avg,2021-05-01 00:00:00,2021-05-02 00:00:00,5500,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,3028,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__variance,2021-05-01 00:00:00,2021-05-02 00:00:00,9167,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__diff,2021-05-01 00:00:00,2021-05-02 00:00:00,9000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__min_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__max_length,2021-05-01 00:00:00,2021-05-02 00:00:00,5000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__avg_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3900,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__missing_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__missing_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__row_count,2021-05-01 00:00:00,2021-05-02 00:00:00,10000,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__freshness,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__my_distinct_table_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,10000,86400 +SAMPLE_TABLE,VALUE1,min,2021-05-01 00:00:00,2021-05-02 00:00:00,100000,86400 +SAMPLE_TABLE,VALUE1,max,2021-05-01 00:00:00,2021-05-02 00:00:00,200000,86400 +SAMPLE_TABLE,VALUE1,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,127500,86400 +SAMPLE_TABLE,VALUE1,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,48563,86400 +SAMPLE_TABLE,VALUE1,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,2358333,86400 +SAMPLE_TABLE,VALUE1,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,VALUE1,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,VALUE1,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,100000,86400 +SAMPLE_TABLE,VALUE2,min,2021-05-01 00:00:00,2021-05-02 00:00:00,109000,86400 +SAMPLE_TABLE,VALUE2,max,2021-05-01 00:00:00,2021-05-02 00:00:00,209000,86400 +SAMPLE_TABLE,VALUE2,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,180750,86400 +SAMPLE_TABLE,VALUE2,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,47975,86400 +SAMPLE_TABLE,VALUE2,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,2301583,86400 +SAMPLE_TABLE,VALUE2,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,VALUE2,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,VALUE2,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,100000,86400 +SAMPLE_TABLE,NULL_VALUE,min,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,max,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,avg,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,stddev,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,variance,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,4000,86400 +SAMPLE_TABLE,NULL_VALUE,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,100000,86400 +SAMPLE_TABLE,NULL_VALUE,diff,2021-05-01 00:00:00,2021-05-02 00:00:00,,86400 +SAMPLE_TABLE,EVENT_TYPE,min_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +SAMPLE_TABLE,EVENT_TYPE,max_length,2021-05-01 00:00:00,2021-05-02 00:00:00,4000,86400 +SAMPLE_TABLE,EVENT_TYPE,avg_length,2021-05-01 00:00:00,2021-05-02 00:00:00,3250,86400 +SAMPLE_TABLE,EVENT_TYPE,nulls_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,nulls_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,missing_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,missing_count,2021-05-01 00:00:00,2021-05-02 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,regex_test,2021-05-01 00:00:00,2021-05-02 00:00:00,4000,86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,25000,86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex_percent,2021-05-01 00:00:00,2021-05-02 00:00:00,25000,86400 +SAMPLE_TABLE,EVENT_TYPE,distinct_values,2021-05-01 00:00:00,2021-05-02 00:00:00,2000,86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_values,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,3000,86400 +SAMPLE_TABLE,EVENT_TYPE,unique_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,1000,86400 +SAMPLE_TABLE,---,row_count,2021-05-01 00:00:00,2021-05-02 00:00:00,4000,86400 +SAMPLE_TABLE,---,freshness,2021-05-01 00:00:00,2021-05-02 00:00:00,40765000,86400 +SAMPLE_TABLE,---,my_distinct_table_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,9000,86400 +SAMPLE_TABLE,---,my_custom_table_metric,2021-05-01 00:00:00,2021-05-02 00:00:00,1000000,86400 +SAMPLE_TABLE,---,distinct_table_rows,2021-05-01 00:00:00,2021-05-02 00:00:00,4000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,min,2021-05-02 00:00:00,2021-05-03 00:00:00,98000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,max,2021-05-02 00:00:00,2021-05-03 00:00:00,98000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,98000,86400 +SAMPLE_WITH_ANOMALY,VALUE1,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE1,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE1,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,min,2021-05-02 00:00:00,2021-05-03 00:00:00,205000,86400 +SAMPLE_WITH_ANOMALY,VALUE2,max,2021-05-02 00:00:00,2021-05-03 00:00:00,205000,86400 +SAMPLE_WITH_ANOMALY,VALUE2,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,205000,86400 +SAMPLE_WITH_ANOMALY,VALUE2,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE2,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_WITH_ANOMALY,VALUE2,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,min_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,max_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,avg_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,missing_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,missing_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITH_ANOMALY,---,row_count,2021-05-02 00:00:00,2021-05-03 00:00:00,1000,86400 +SAMPLE_WITH_ANOMALY,---,freshness,2021-05-02 00:00:00,2021-05-03 00:00:00,41065000,86400 +SAMPLE_WITH_ANOMALY,---,my_distinct_table_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,12000,86400 +BUY_EVENTS,VALUE1,min,2021-05-02 00:00:00,2021-05-03 00:00:00,98000,86400 +BUY_EVENTS,VALUE1,max,2021-05-02 00:00:00,2021-05-03 00:00:00,98000,86400 +BUY_EVENTS,VALUE1,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,98000,86400 +BUY_EVENTS,VALUE1,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +BUY_EVENTS,VALUE1,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +BUY_EVENTS,VALUE1,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,VALUE1,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,VALUE1,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,VALUE2,min,2021-05-02 00:00:00,2021-05-03 00:00:00,205000,86400 +BUY_EVENTS,VALUE2,max,2021-05-02 00:00:00,2021-05-03 00:00:00,205000,86400 +BUY_EVENTS,VALUE2,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,205000,86400 +BUY_EVENTS,VALUE2,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +BUY_EVENTS,VALUE2,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +BUY_EVENTS,VALUE2,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,VALUE2,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,VALUE2,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,min_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +BUY_EVENTS,EVENT_TYPE,max_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +BUY_EVENTS,EVENT_TYPE,avg_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +BUY_EVENTS,EVENT_TYPE,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,missing_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,EVENT_TYPE,missing_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +BUY_EVENTS,---,row_count,2021-05-02 00:00:00,2021-05-03 00:00:00,1000,86400 +BUY_EVENTS,---,freshness,2021-05-02 00:00:00,2021-05-03 00:00:00,41065000,86400 +BUY_EVENTS,---,my_distinct_table_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,10000,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__min,2021-05-02 00:00:00,2021-05-03 00:00:00,990,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__max,2021-05-02 00:00:00,2021-05-03 00:00:00,4990,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__avg,2021-05-02 00:00:00,2021-05-03 00:00:00,3306,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,1668,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__variance,2021-05-02 00:00:00,2021-05-03 00:00:00,2784,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__diff,2021-05-02 00:00:00,2021-05-03 00:00:00,4000,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__min_length,2021-05-02 00:00:00,2021-05-03 00:00:00,10000,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__max_length,2021-05-02 00:00:00,2021-05-03 00:00:00,17000,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__avg_length,2021-05-02 00:00:00,2021-05-03 00:00:00,14105,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__missing_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__missing_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__min_length,2021-05-02 00:00:00,2021-05-03 00:00:00,1000,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__max_length,2021-05-02 00:00:00,2021-05-03 00:00:00,5000,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__avg_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3053,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__missing_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__missing_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__row_count,2021-05-02 00:00:00,2021-05-03 00:00:00,19000,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__freshness,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__my_distinct_table_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,19000,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__min,2021-05-02 00:00:00,2021-05-03 00:00:00,1000,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__max,2021-05-02 00:00:00,2021-05-03 00:00:00,10000,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__avg,2021-05-02 00:00:00,2021-05-03 00:00:00,5500,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,3028,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__variance,2021-05-02 00:00:00,2021-05-03 00:00:00,9167,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__diff,2021-05-02 00:00:00,2021-05-03 00:00:00,9000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__min_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__max_length,2021-05-02 00:00:00,2021-05-03 00:00:00,5000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__avg_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3900,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__missing_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__missing_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__row_count,2021-05-02 00:00:00,2021-05-03 00:00:00,10000,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__freshness,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__my_distinct_table_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,10000,86400 +SAMPLE_TABLE,VALUE1,min,2021-05-02 00:00:00,2021-05-03 00:00:00,100000,86400 +SAMPLE_TABLE,VALUE1,max,2021-05-02 00:00:00,2021-05-03 00:00:00,210000,86400 +SAMPLE_TABLE,VALUE1,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,142500,86400 +SAMPLE_TABLE,VALUE1,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,49917,86400 +SAMPLE_TABLE,VALUE1,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,2491667,86400 +SAMPLE_TABLE,VALUE1,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,VALUE1,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,VALUE1,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,110000,86400 +SAMPLE_TABLE,VALUE2,min,2021-05-02 00:00:00,2021-05-03 00:00:00,109000,86400 +SAMPLE_TABLE,VALUE2,max,2021-05-02 00:00:00,2021-05-03 00:00:00,209000,86400 +SAMPLE_TABLE,VALUE2,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,180750,86400 +SAMPLE_TABLE,VALUE2,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,47975,86400 +SAMPLE_TABLE,VALUE2,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,2301583,86400 +SAMPLE_TABLE,VALUE2,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,VALUE2,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,VALUE2,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,100000,86400 +SAMPLE_TABLE,NULL_VALUE,min,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,max,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,avg,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,stddev,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,variance,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_TABLE,NULL_VALUE,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,4000,86400 +SAMPLE_TABLE,NULL_VALUE,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,100000,86400 +SAMPLE_TABLE,NULL_VALUE,diff,2021-05-02 00:00:00,2021-05-03 00:00:00,,86400 +SAMPLE_TABLE,EVENT_TYPE,min_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +SAMPLE_TABLE,EVENT_TYPE,max_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +SAMPLE_TABLE,EVENT_TYPE,avg_length,2021-05-02 00:00:00,2021-05-03 00:00:00,3000,86400 +SAMPLE_TABLE,EVENT_TYPE,nulls_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,nulls_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,missing_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,missing_count,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,regex_test,2021-05-02 00:00:00,2021-05-03 00:00:00,4000,86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex_percent,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,EVENT_TYPE,distinct_values,2021-05-02 00:00:00,2021-05-03 00:00:00,1000,86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_values,2021-05-02 00:00:00,2021-05-03 00:00:00,1000,86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,4000,86400 +SAMPLE_TABLE,EVENT_TYPE,unique_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,0,86400 +SAMPLE_TABLE,---,row_count,2021-05-02 00:00:00,2021-05-03 00:00:00,4000,86400 +SAMPLE_TABLE,---,freshness,2021-05-02 00:00:00,2021-05-03 00:00:00,40765000,86400 +SAMPLE_TABLE,---,my_distinct_table_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,9000,86400 +SAMPLE_TABLE,---,my_custom_table_metric,2021-05-02 00:00:00,2021-05-03 00:00:00,1000000,86400 +SAMPLE_TABLE,---,distinct_table_rows,2021-05-02 00:00:00,2021-05-03 00:00:00,4000,86400 diff --git a/integration_tests/seeds/monitoring/expected_table_samples.csv b/integration_tests/seeds/monitoring/expected_table_samples.csv new file mode 100644 index 0000000..07c8f0b --- /dev/null +++ b/integration_tests/seeds/monitoring/expected_table_samples.csv @@ -0,0 +1,5 @@ +table_name,sample_data_length +BUY_EVENTS,506 +F2DQ_SOURCE_TEST_TABLE,361 +SAMPLE_TABLE,830 +SAMPLE_WITH_ANOMALY,507 diff --git a/integration_tests/seeds/monitoring/expected_test_history.csv b/integration_tests/seeds/monitoring/expected_test_history.csv new file mode 100644 index 0000000..28ed1cb --- /dev/null +++ b/integration_tests/seeds/monitoring/expected_test_history.csv @@ -0,0 +1,35 @@ +table_name,column_name,test_name,status,message,failures_count,severity +TEST_F2DQ_METRICS,---,pected_metrics_,Pass,---,0,ERROR +TEST_F2DQ_TABLE_SAMPLES,---,_table_samples_,Pass,---,0,ERROR +TEST_F2DQ_Z_SCORE,---,pected_z_score_,Pass,---,0,ERROR +TEST_F2DQ_ANOMALIES,---,cted_anomalies_,Pass,---,0,ERROR +BUY_EVENTS,---,_table_rows__10,Pass,---,0,ERROR +BUY_EVENTS,---,ts_row_count__1,Pass,---,0,ERROR +BUY_EVENTS,---,null__freshness,Pass,---,0,ERROR +BUY_EVENTS,VALUE2,5_02___min__200,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,alue1__min__107,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,ulls_percent__0,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,5_02___min__100,Pass,---,0,ERROR +BUY_EVENTS,---,0__row_count__0,Pass,---,0,ERROR +F2DQ_METRICS,---,_buy_events___3,Pass,---,0,ERROR +F2DQ_METRICS,---,ref_buy_events_,Pass,---,0,ERROR +F2DQ_METRICS,---,ref_buy_events_,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,__nulls_percent,Pass,---,0,ERROR +F2DQ_METRICS,---,ref_buy_events_,Pass,---,0,ERROR +TEST_F2DQ_TABLE_SAMPLES,---,_table_samples_,Pass,---,0,ERROR +TEST_F2DQ_ANOMALIES,---,cted_anomalies_,Pass,---,0,ERROR +BUY_EVENTS,---,ts_row_count__1,Pass,---,0,ERROR +BUY_EVENTS,---,null__freshness,Pass,---,0,ERROR +BUY_EVENTS,VALUE2,5_02___min__200,Pass,---,0,ERROR +BUY_EVENTS,---,_table_rows__10,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,ulls_percent__0,Pass,---,0,ERROR +BUY_EVENTS,---,0__row_count__0,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,5_02___min__100,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,alue1__min__107,Pass,---,0,ERROR +BUY_EVENTS,VALUE1,__nulls_percent,Pass,---,0,ERROR +F2DQ_METRICS,---,_buy_events___3,Pass,---,0,ERROR +F2DQ_METRICS,---,ref_buy_events_,Pass,---,0,ERROR +F2DQ_METRICS,---,ref_buy_events_,Pass,---,0,ERROR +F2DQ_METRICS,---,ref_buy_events_,Pass,---,0,ERROR +TEST_F2DQ_METRICS,---,pected_metrics_,Pass,---,0,ERROR +TEST_F2DQ_Z_SCORE,---,pected_z_score_,Pass,---,0,ERROR diff --git a/integration_tests/seeds/monitoring/expected_z_score.csv b/integration_tests/seeds/monitoring/expected_z_score.csv new file mode 100644 index 0000000..49df41d --- /dev/null +++ b/integration_tests/seeds/monitoring/expected_z_score.csv @@ -0,0 +1,125 @@ +table_name,column_name,metric,time_window_end,z_score_value,modified_z_score_value,last_value,last_avg,last_stddev,last_median,last_iqr,last_median_absolute_deviation,last_mean_absolute_deviation,interval_length_sec +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__avg_length,2021-05-03 00:00:00,0,0,14105,14105,0,14105,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,distinct_values,2021-05-03 00:00:00,-707,-674,1000,1500,707,1500,500,500,500,86400 +BUY_EVENTS,EVENT_TYPE,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,max_length,2021-05-03 00:00:00,-707,-674,3000,3500,707,3500,500,500,500,86400 +SAMPLE_TABLE,EVENT_TYPE,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,min_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,missing_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,NULL_VALUE,nulls_percent,2021-05-03 00:00:00,0,0,100000,100000,0,100000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__avg_length,2021-05-03 00:00:00,0,0,3053,3053,0,3053,0,0,0,86400 +BUY_EVENTS,VALUE1,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,---,freshness,2021-05-03 00:00:00,0,0,40765000,40765000,0,40765000,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__stddev,2021-05-03 00:00:00,0,0,3028,3028,0,3028,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__missing_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,EVENT_TYPE,min_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_TABLE,VALUE1,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,VALUE2,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,VALUE1,variance,2021-05-03 00:00:00,707,674,2491667,2425000,94281,2425000,66667,66667,66667,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__row_count,2021-05-03 00:00:00,0,0,10000,10000,0,10000,0,0,0,86400 +BUY_EVENTS,---,my_distinct_table_rows,2021-05-03 00:00:00,0,0,10000,10000,0,10000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__row_count,2021-05-03 00:00:00,0,0,19000,19000,0,19000,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex,2021-05-03 00:00:00,-707,-674,0,500,707,500,500,500,500,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__diff,2021-05-03 00:00:00,0,0,4000,4000,0,4000,0,0,0,86400 +BUY_EVENTS,VALUE2,diff,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__avg,2021-05-03 00:00:00,0,0,5500,5500,0,5500,0,0,0,86400 +SAMPLE_TABLE,VALUE2,min,2021-05-03 00:00:00,0,0,109000,109000,0,109000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__max_length,2021-05-03 00:00:00,0,0,17000,17000,0,17000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,min,2021-05-03 00:00:00,-707,-674,98000,102500,6364,102500,4500,4500,4500,86400 +SAMPLE_WITH_ANOMALY,VALUE2,max,2021-05-03 00:00:00,707,674,205000,202500,3536,202500,2500,2500,2500,86400 +SAMPLE_WITH_ANOMALY,VALUE2,diff,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex_percent,2021-05-03 00:00:00,-707,-674,0,12500,17678,12500,12500,12500,12500,86400 +SAMPLE_TABLE,---,distinct_table_rows,2021-05-03 00:00:00,0,0,4000,4000,0,4000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__missing_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,VALUE2,stddev,2021-05-03 00:00:00,0,0,47975,47975,0,47975,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__min,2021-05-03 00:00:00,0,0,990,990,0,990,0,0,0,86400 +BUY_EVENTS,VALUE2,max,2021-05-03 00:00:00,707,674,205000,202500,3536,202500,2500,2500,2500,86400 +SAMPLE_WITHOUT_TIME_FILTER,---,global__my_distinct_table_rows,2021-05-03 00:00:00,0,0,19000,19000,0,19000,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__avg_length,2021-05-03 00:00:00,0,0,3900,3900,0,3900,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,avg,2021-05-03 00:00:00,-707,-674,98000,102500,6364,102500,4500,4500,4500,86400 +SAMPLE_TABLE,VALUE2,variance,2021-05-03 00:00:00,0,0,2301583,2301583,0,2301583,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__missing_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,missing_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,VALUE1,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__max,2021-05-03 00:00:00,0,0,4990,4990,0,4990,0,0,0,86400 +SAMPLE_WITH_ANOMALY,---,my_distinct_table_rows,2021-05-03 00:00:00,0,0,12000,12000,0,12000,0,0,0,86400 +BUY_EVENTS,VALUE1,diff,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__variance,2021-05-03 00:00:00,0,0,2784,2784,0,2784,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_values,2021-05-03 00:00:00,0,0,1000,1000,0,1000,0,0,0,86400 +SAMPLE_TABLE,NULL_VALUE,nulls_count,2021-05-03 00:00:00,0,0,4000,4000,0,4000,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__variance,2021-05-03 00:00:00,0,0,9167,9167,0,9167,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,avg_length,2021-05-03 00:00:00,-707,-674,3000,3125,177,3125,125,125,125,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__min,2021-05-03 00:00:00,0,0,1000,1000,0,1000,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__missing_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__avg,2021-05-03 00:00:00,0,0,3306,3306,0,3306,0,0,0,86400 +BUY_EVENTS,VALUE1,avg,2021-05-03 00:00:00,-707,-674,98000,102500,6364,102500,4500,4500,4500,86400 +SAMPLE_WITH_ANOMALY,VALUE2,min,2021-05-03 00:00:00,707,674,205000,202500,3536,202500,2500,2500,2500,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,not_match_regex,2021-05-03 00:00:00,-707,-674,0,500,707,500,500,500,500,86400 +BUY_EVENTS,VALUE1,max,2021-05-03 00:00:00,-707,-674,98000,102500,6364,102500,4500,4500,4500,86400 +SAMPLE_TABLE,VALUE1,stddev,2021-05-03 00:00:00,707,674,49917,49240,957,49240,677,677,677,86400 +BUY_EVENTS,VALUE1,min,2021-05-03 00:00:00,-707,-674,98000,102500,6364,102500,4500,4500,4500,86400 +SAMPLE_WITH_ANOMALY,VALUE2,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,---,my_custom_table_metric,2021-05-03 00:00:00,0,0,1000000,1000000,0,1000000,0,0,0,86400 +BUY_EVENTS,EVENT_TYPE,max_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,avg_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,max,2021-05-03 00:00:00,-707,-674,98000,102500,6364,102500,4500,4500,4500,86400 +SAMPLE_TABLE,---,my_distinct_table_rows,2021-05-03 00:00:00,0,0,9000,9000,0,9000,0,0,0,86400 +SAMPLE_TABLE,---,row_count,2021-05-03 00:00:00,0,0,4000,4000,0,4000,0,0,0,86400 +SAMPLE_TABLE,VALUE2,max,2021-05-03 00:00:00,0,0,209000,209000,0,209000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,diff,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,EVENT_TYPE,avg_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_TABLE,VALUE1,diff,2021-05-03 00:00:00,707,674,110000,105000,7071,105000,5000,5000,5000,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__missing_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__missing_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,VALUE1,min,2021-05-03 00:00:00,0,0,100000,100000,0,100000,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,regex_test,2021-05-03 00:00:00,0,0,4000,4000,0,4000,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__min_length,2021-05-03 00:00:00,0,0,1000,1000,0,1000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,TITLE,global__min_length,2021-05-03 00:00:00,0,0,10000,10000,0,10000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__max,2021-05-03 00:00:00,0,0,10000,10000,0,10000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,missing_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,VALUE2,nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE2,avg,2021-05-03 00:00:00,707,674,205000,202500,3536,202500,2500,2500,2500,86400 +BUY_EVENTS,---,freshness,2021-05-03 00:00:00,-707,-674,41065000,41186500,171827,41186500,121500,121500,121500,86400 +BUY_EVENTS,VALUE2,avg,2021-05-03 00:00:00,707,674,205000,202500,3536,202500,2500,2500,2500,86400 +BUY_EVENTS,---,row_count,2021-05-03 00:00:00,0,0,1000,1000,0,1000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,---,freshness,2021-05-03 00:00:00,-707,-674,41065000,41186500,171827,41186500,121500,121500,121500,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__max_length,2021-05-03 00:00:00,0,0,5000,5000,0,5000,0,0,0,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,min_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,match_regex_percent,2021-05-03 00:00:00,-707,-674,0,12500,17678,12500,12500,12500,12500,86400 +SAMPLE_WITH_ANOMALY,EVENT_TYPE,max_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__stddev,2021-05-03 00:00:00,0,0,1668,1668,0,1668,0,0,0,86400 +SAMPLE_TABLE,VALUE1,max,2021-05-03 00:00:00,707,674,210000,205000,7071,205000,5000,5000,5000,86400 +SAMPLE_TABLE,VALUE2,avg,2021-05-03 00:00:00,0,0,180750,180750,0,180750,0,0,0,86400 +SAMPLE_WITH_ANOMALY,VALUE1,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__min_length,2021-05-03 00:00:00,0,0,3000,3000,0,3000,0,0,0,86400 +SAMPLE_TABLE,VALUE1,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,EVENT_TYPE,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,EVENT_TYPE,missing_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,EVENT_TYPE,missing_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,VALUE2,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +F2DQ_SOURCE_TEST_TABLE,NUMBER,global__diff,2021-05-03 00:00:00,0,0,9000,9000,0,9000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RATING,global__nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +BUY_EVENTS,VALUE2,min,2021-05-03 00:00:00,707,674,205000,202500,3536,202500,2500,2500,2500,86400 +F2DQ_SOURCE_TEST_TABLE,DESCRIPTION,global__max_length,2021-05-03 00:00:00,0,0,5000,5000,0,5000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,VALUE2,nulls_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,duplicate_rows,2021-05-03 00:00:00,707,674,4000,3500,707,3500,500,500,500,86400 +F2DQ_SOURCE_TEST_TABLE,---,global__my_distinct_table_rows,2021-05-03 00:00:00,0,0,10000,10000,0,10000,0,0,0,86400 +SAMPLE_TABLE,VALUE2,diff,2021-05-03 00:00:00,0,0,100000,100000,0,100000,0,0,0,86400 +SAMPLE_WITHOUT_TIME_FILTER,RENTAL_RATE,global__nulls_percent,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 +SAMPLE_WITH_ANOMALY,---,row_count,2021-05-03 00:00:00,0,0,1000,1000,0,1000,0,0,0,86400 +SAMPLE_TABLE,EVENT_TYPE,unique_rows,2021-05-03 00:00:00,-707,-674,0,500,707,500,500,500,500,86400 +SAMPLE_TABLE,VALUE1,avg,2021-05-03 00:00:00,707,674,142500,135000,10607,135000,7500,7500,7500,86400 +SAMPLE_TABLE,EVENT_TYPE,missing_count,2021-05-03 00:00:00,0,0,0,0,0,0,0,0,0,86400 diff --git a/integration_tests/seeds/monitoring/sample_table.csv b/integration_tests/seeds/monitoring/sample_table.csv new file mode 100644 index 0000000..4d1021e --- /dev/null +++ b/integration_tests/seeds/monitoring/sample_table.csv @@ -0,0 +1,10 @@ +creation_time,update_time,event_type,value1,value2,null_value,not_used_colum +2021-04-30 12:40:35,,buy,100,109,,1 +2021-05-01 12:31:32,,buy,100,200,,1 +2021-05-01 12:35:35,,buy,110,205,,1 +2021-05-01 12:40:35,,sell,200,209,,1 +2021-05-01 12:40:35,2021-05-01 12:40:37,buy,100,109,,1 +2021-05-02 12:31:32,,buy,110,200,,1 +2021-05-02 12:35:35,,buy,150,205,,1 +2021-05-02 12:40:35,,buy,210,209,,1 +2021-05-02 12:40:35,,buy,100,109,,1 diff --git a/integration_tests/seeds/monitoring/sample_with_anomaly.csv b/integration_tests/seeds/monitoring/sample_with_anomaly.csv new file mode 100644 index 0000000..ff54aeb --- /dev/null +++ b/integration_tests/seeds/monitoring/sample_with_anomaly.csv @@ -0,0 +1,13 @@ +creation_time,event_type,value1,value2 +2021-04-30 12:40:35,buy,101,109 +2021-05-01 12:31:32,buy,107,200 +2021-05-02 12:35:35,buy,98,205 +2021-05-03 12:40:35,sell,108,209 +2021-05-04 12:40:35,buy,100,109 +2021-05-05 12:31:32,buy,110,200 +2021-05-06 12:35:35,buy,99,205 +2021-05-07 12:40:35,buy,94,209 +2021-05-08 12:40:35,buy,104,109 +2021-05-09 12:31:32,buy,10,200 +2021-05-10 12:35:35,buy,23,205 +2021-05-11 12:40:35,sell,10,209 diff --git a/integration_tests/seeds/monitoring/sample_without_time_filter.csv b/integration_tests/seeds/monitoring/sample_without_time_filter.csv new file mode 100644 index 0000000..e116410 --- /dev/null +++ b/integration_tests/seeds/monitoring/sample_without_time_filter.csv @@ -0,0 +1,20 @@ +title,rental_rate,rating +Academy Dinosaur,0.99,PG-13 +Alamo Videotape,0.99,G +Affair Prejudice,2.99,G +African Egg,2.99,G +Ace Goldfinger,4.99,G +Alice Fantasia,0.99,NC-17 +Adaptation Holes,2.99,NC-17 +Alien Center,2.99,NC-17 +Aladdin Calendar,4.99,NC-17 +Chamber Italian,4.99,NC-17 +Alaska Phantom,0.99,PG +Agent Truman,2.99,PG +Ali Forever,4.99,PG +Alabama Devil,2.99,PG-13 +Bright Encounters,4.99,PG-13 +Airplane Sierra,4.99,PG-13 +Date Speed,0.99,R +Grosse Wonderful,4.99,R +Airport Pollock,4.99,R \ No newline at end of file diff --git a/integration_tests/seeds/public_macros/cleaning/expected_sample_user_data.csv b/integration_tests/seeds/public_macros/cleaning/expected_sample_user_data.csv new file mode 100644 index 0000000..ca45723 --- /dev/null +++ b/integration_tests/seeds/public_macros/cleaning/expected_sample_user_data.csv @@ -0,0 +1,21 @@ +"full_name","email" +"Lizzie Effertz","*****@fakemail.com" +"Orlando Abbott","*****@fakemail.com" +"Kelley Harann","*****@fakemail.com" +"Ruth Langworth","*****@fakemail.com" +"Lane Swift","*****@fakemail.com" +"Bertha Corwin","*****@fakemail.com" +"Manuela Kling","*****@fakemail.com" +"Mose Balistreri","*****@fakemail.com" +"Robin Halvorson","*****@fakemail.com" +"Osbaldo Parker I","*****@fakemail.com" +"Javier Runolfsson","*****@fakemail.net" +"Amelia Batz","*****@fakemail.com" +"Abby Pouros","*****@fakemail.com" +"Markus Homenick","*****@fakemail.com" +"Braeden Turner","*****@fakemail.com" +"Horacio Parker","*****@fakemail.info" +"Ms. Stacy Padberg","*****@fakemail.com" +"Dr. Deshawn Stracke","*****@fakemail.com" +"Pascale Grady","*****@fakemail.com" +"Lacy Brekke","*****@fakemail.com" diff --git a/integration_tests/seeds/public_macros/cleaning/sample_user_data.csv b/integration_tests/seeds/public_macros/cleaning/sample_user_data.csv new file mode 100644 index 0000000..40aefd7 --- /dev/null +++ b/integration_tests/seeds/public_macros/cleaning/sample_user_data.csv @@ -0,0 +1,21 @@ +full_name,email + lizzie effertz,torp.trisha@fakemail.com + orlando abbott,dayton.hermiston@fakemail.com +kelley harann,borer.blake@fakemail.com +ruth langworth,garett66@fakemail.com +lane swift ,nienow.coralie@fakemail.com +bertha corwin ,tstroman@fakemail.com +manuela kling,shawn.langworth@fakemail.com +mose balistreri,dorris70@fakemail.com +robin halvorson,murazik.americo@fakemail.com + osbaldo parker i ,friesen.angeline@fakemail.com +javier runolfsson ,benjamin.bailey@fakemail.net +amelia batz,garrison60@fakemail.com +abby pouros,dominique.leannon@fakemail.com +markus homenick,piper73@fakemail.com +braeden turner,kozey.jace@fakemail.com +horacio parker,vtillman@fakemail.info +ms. stacy padberg,erdman.elaina@fakemail.com +dr. deshawn stracke,rosendo.beer@fakemail.com + pascale grady,princess60@fakemail.com +lacy brekke,romaguera.darrell@fakemail.com diff --git a/integration_tests/seeds/public_macros/filtering/duplicated.csv b/integration_tests/seeds/public_macros/filtering/duplicated.csv new file mode 100644 index 0000000..5e37a69 --- /dev/null +++ b/integration_tests/seeds/public_macros/filtering/duplicated.csv @@ -0,0 +1,9 @@ +transaction_id,creation_time,status,value +1,2021-05-01 12:31:32,pending,100 +2,2021-05-01 12:35:35,pending,200 +1,2021-05-01 12:40:35,completed,100 +3,2021-05-01 12:40:35,pending,300 +3,2021-05-02 12:31:32,completed,300 +4,2021-05-02 12:35:35,completed,10 +5,2021-05-02 12:40:35,pending,100 +4,2021-05-02 12:40:35,completed,40 diff --git a/integration_tests/seeds/public_macros/filtering/expected_deduplicated.csv b/integration_tests/seeds/public_macros/filtering/expected_deduplicated.csv new file mode 100644 index 0000000..884d28f --- /dev/null +++ b/integration_tests/seeds/public_macros/filtering/expected_deduplicated.csv @@ -0,0 +1,18 @@ +transaction_id,creation_time,status,value,use_case +1,2021-05-01 12:31:32,pending,100,take_first +2,2021-05-01 12:35:35,pending,200,take_first +3,2021-05-01 12:40:35,pending,300,take_first +4,2021-05-02 12:35:35,completed,10,take_first +5,2021-05-02 12:40:35,pending,100,take_first +1,2021-05-01 12:40:35,completed,100,take_last +2,2021-05-01 12:35:35,pending,200,take_last +3,2021-05-02 12:31:32,completed,300,take_last +4,2021-05-02 12:40:35,completed,40,take_last +5,2021-05-02 12:40:35,pending,100,take_last +1,2021-05-01 12:40:35,completed,100,take_all_statuses +1,2021-05-01 12:31:32,pending,100,take_all_statuses +2,2021-05-01 12:35:35,pending,200,take_all_statuses +3,2021-05-02 12:31:32,completed,300,take_all_statuses +3,2021-05-01 12:40:35,pending,300,take_all_statuses +4,2021-05-02 12:40:35,completed,40,take_all_statuses +5,2021-05-02 12:40:35,pending,100,take_all_statuses diff --git a/integration_tests/seeds/public_macros/filtering/expected_duplicates.csv b/integration_tests/seeds/public_macros/filtering/expected_duplicates.csv new file mode 100644 index 0000000..254f6b8 --- /dev/null +++ b/integration_tests/seeds/public_macros/filtering/expected_duplicates.csv @@ -0,0 +1,7 @@ +"transaction_id","creation_time","status","value","f2dq_duplicate_group_row_count","f2dq_duplicate_group_row_number" +1,"2021-05-01 12:31:32","pending",100,2,1 +1,"2021-05-01 12:40:35","completed",100,2,2 +3,"2021-05-01 12:40:35","pending",300,2,1 +3,"2021-05-02 12:31:32","completed",300,2,2 +4,"2021-05-02 12:35:35","completed",10,2,1 +4,"2021-05-02 12:40:35","completed",40,2,2 diff --git a/integration_tests/seeds/public_macros/normalizing/abbreviated_us_states.csv b/integration_tests/seeds/public_macros/normalizing/abbreviated_us_states.csv new file mode 100644 index 0000000..a2c5fea --- /dev/null +++ b/integration_tests/seeds/public_macros/normalizing/abbreviated_us_states.csv @@ -0,0 +1,52 @@ +state,code +Ala.,AL +Alaska,AK +Ariz.,AZ +Ark.,AR +Calif.,CA +Colo.,CO +Conn.,CT +Del.,DE +D.C.,DC +Fla.,FL +Ga.,GA +Hawaii,HI +Idaho,ID +Ill.,IL +Ind.,IN +Iowa,IA +Kans.,KS +Ky.,KY +La.,LA +Maine,ME +Md.,MD +Mass.,MA +Mich.,MI +Minn.,MN +Miss.,MS +Mo.,MO +Mont.,MT +Nebr.,NE +Nev.,NV +N.H.,NH +N.J.,NJ +N.M.,NM +N.Y.,NY +N.C.,NC +N.D.,ND +Ohio,OH +Okla.,OK +Ore.,OR +Pa.,PA +R.I.,RI +S.C.,SC +S.D.,SD +Tenn.,TN +Tex.,TX +Utah,UT +Vt.,VT +Va.,VA +Wash.,WA +W.Va.,WV +Wis.,WI +Wyo.,WY diff --git a/integration_tests/seeds/public_macros/normalizing/expected_us_states_normalized.csv b/integration_tests/seeds/public_macros/normalizing/expected_us_states_normalized.csv new file mode 100644 index 0000000..7068963 --- /dev/null +++ b/integration_tests/seeds/public_macros/normalizing/expected_us_states_normalized.csv @@ -0,0 +1,52 @@ +"state","code","state__normalized" +"Ariz.","AZ","Arizona" +"Ind.","IN","Indiana" +"La.","LA","Louisiana" +"W.Va.","WV","West Virginia" +"Nebr.","NE","Nebraska" +"Pa.","PA","Pennsylvania" +"Iowa","IA","Iowa" +"N.H.","NH","New Hampshire" +"S.C.","SC","South Carolina" +"Ore.","OR","Oregon" +"Conn.","CT","Connecticut" +"R.I.","RI","Rhode Island" +"Minn.","MN","Minnesota" +"D.C.","DC","District of Columbia" +"Wyo.","WY","Wyoming" +"Hawaii","HI","Hawaii" +"Wash.","WA","Washington" +"N.D.","ND","North Dakota" +"Mass.","MA","Massachusetts" +"N.Y.","NY","New York" +"N.M.","NM","New Mexico" +"Colo.","CO","Colorado" +"Ohio","OH","Ohio" +"Idaho","ID","Idaho" +"Ala.","AL","Alabama" +"Ark.","AR","Arkansas" +"S.D.","SD","South Dakota" +"Mo.","MO","Missouri" +"N.J.","NJ","New Jersey" +"Miss.","MS","Mississippi" +"Kans.","KS","Kansas" +"Vt.","VT","Vermont" +"Calif.","CA","California" +"Mich.","MI","Michigan" +"Alaska","AK","Alaska" +"Nev.","NV","Nevada" +"Okla.","OK","Oklahoma" +"Tenn.","TN","Tennessee" +"Ga.","GA","Georgia" +"Wis.","WI","Wisconsin" +"Ky.","KY","Kentucky" +"N.C.","NC","North Carolina" +"Mont.","MT","Montana" +"Fla.","FL","Florida" +"Va.","VA","Virginia" +"Tex.","TX","Texas" +"Md.","MD","Maryland" +"Utah","UT","Utah" +"Maine","ME","Maine" +"Del.","DE","Delaware" +"Ill.","IL","Illinois" diff --git a/integration_tests/seeds/public_macros/normalizing/us_states_normalization.csv b/integration_tests/seeds/public_macros/normalizing/us_states_normalization.csv new file mode 100644 index 0000000..cb74e40 --- /dev/null +++ b/integration_tests/seeds/public_macros/normalizing/us_states_normalization.csv @@ -0,0 +1,52 @@ +source,target +Ala.,Alabama +Alaska,Alaska +Ariz.,Arizona +Ark.,Arkansas +Calif.,California +Colo.,Colorado +Conn.,Connecticut +Del.,Delaware +D.C.,District of Columbia +Fla.,Florida +Ga.,Georgia +Hawaii,Hawaii +Idaho,Idaho +Ill.,Illinois +Ind.,Indiana +Iowa,Iowa +Kans.,Kansas +Ky.,Kentucky +La.,Louisiana +Maine,Maine +Md.,Maryland +Mass.,Massachusetts +Mich.,Michigan +Minn.,Minnesota +Miss.,Mississippi +Mo.,Missouri +Mont.,Montana +Nebr.,Nebraska +Nev.,Nevada +N.H.,New Hampshire +N.J.,New Jersey +N.M.,New Mexico +N.Y.,New York +N.C.,North Carolina +N.D.,North Dakota +Ohio,Ohio +Okla.,Oklahoma +Ore.,Oregon +Pa.,Pennsylvania +R.I.,Rhode Island +S.C.,South Carolina +S.D.,South Dakota +Tenn.,Tennessee +Tex.,Texas +Utah,Utah +Vt.,Vermont +Va.,Virginia +Wash.,Washington +W.Va.,West Virginia +Wis.,Wisconsin +Wyo.,Wyoming \ No newline at end of file diff --git a/integration_tests/seeds/public_macros/validating/expected_validated_credit_cards.csv b/integration_tests/seeds/public_macros/validating/expected_validated_credit_cards.csv new file mode 100644 index 0000000..14eca81 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/expected_validated_credit_cards.csv @@ -0,0 +1,11 @@ +credit_card_number,valid_credit_card +4941533405630082,1 +5476749195896614,1 +3568497486294461,1 +not_a_card_number,0 +3434-4351-4234-3234,0 +3434 4351 4234 3234,0 +34344 42344 43455 43456,0 +43423432,0 +234343443434,0 +2343423423423423423423423423,0 diff --git a/integration_tests/seeds/public_macros/validating/expected_validated_date_and_time.csv b/integration_tests/seeds/public_macros/validating/expected_validated_date_and_time.csv new file mode 100644 index 0000000..9b5d485 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/expected_validated_date_and_time.csv @@ -0,0 +1,13 @@ +date_time,valid_date_eu,valid_date_us,valid_date_inverse,valid_date_iso_8601,valid_time_24h,valid_time_12h,valid_time +31-01-2020,1,0,0,0,0,0,0 +01/31/2020,0,1,0,0,0,0,0 +05.05.2020,1,1,0,0,0,0,0 +2020-01-31,0,0,1,0,0,0,0 +23:59,0,0,0,0,1,0,1 +12:59,0,0,0,0,1,1,1 +13:59:01,0,0,0,0,0,0,1 +"12:59:01,55",0,0,0,0,0,0,1 +11:59:00,0,0,0,0,0,0,1 +midnight,0,0,0,0,0,0,0 +2020-01-31T12:59:00+02:00,0,0,0,1,0,0,0 +2020-01-31T12:59:00,0,0,0,1,0,0,0 diff --git a/integration_tests/seeds/public_macros/validating/expected_validated_emails.csv b/integration_tests/seeds/public_macros/validating/expected_validated_emails.csv new file mode 100644 index 0000000..640ac93 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/expected_validated_emails.csv @@ -0,0 +1,5 @@ +user_id,email,email_valid +1,test@fakemail.com,1 +2,novalidemail@,0 +3,novalidemail@com,0 +4,test+alovalidemail@fakemail.com,0 diff --git a/integration_tests/seeds/public_macros/validating/expected_validated_ips.csv b/integration_tests/seeds/public_macros/validating/expected_validated_ips.csv new file mode 100644 index 0000000..9982983 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/expected_validated_ips.csv @@ -0,0 +1,29 @@ +ip_address,valid_ip_v4,valid_ip_v6,valid_ip +1.2.3.4,1,0,1 +01.102.103.104,1,0,1 +124.171.228.4,1,0,1 +192.168.1.35,1,0,1 +192.168.1.198,1,0,1 +127.248.111.240,1,0,1 +01.1.1,0,0,0 +12325412,0,0,0 +notvalidatall,0,0,0 +232.232.33,0,0,0 +232.3232.232.232+2312,0,0,0 +::::erwerwe,0,0,0 +::3343:4343434343:34343:343434343:443,0,0,0 +,0,0,0 +2001:db8:3333:4444:5555:6666:7777:8888,0,1,1 +2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF,0,1,1 +2001:db8::,0,1,1 +::1234:5678,0,1,1 +2001:db8::1234:5678,0,1,1 +::11.22.33.44,0,1,1 +2001:db8::123.123.123.123,0,1,1 +2001:db8::1234:5678:5.6.7.8,0,1,1 +2001:db8:3333:4444:5555:6666:1.2.3.4,0,1,1 +::11.22.33.44,0,1,1 +2001:db8::123.123.123.123,0,1,1 +::1234:5678:91.123.4.56,0,1,1 +::1234:5678:1.2.3.4,0,1,1 +2001:db8::1234:5678:5.6.7.8,0,1,1 diff --git a/integration_tests/seeds/public_macros/validating/expected_validated_numbers.csv b/integration_tests/seeds/public_macros/validating/expected_validated_numbers.csv new file mode 100644 index 0000000..5ddcda2 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/expected_validated_numbers.csv @@ -0,0 +1,12 @@ +number,is_number,is_number_decimal_point,is_number_decimal_comma,is_percentage,is_percentage_decimal_point,is_percentage_decimal_comma +133,1,0,0,0,0,0 +1232.232,0,1,0,0,0,0 +"2332,123",0,0,1,0,0,0 +not a number,0,0,0,0,0,0 +"1,3%",0,0,0,1,0,1 +123%,0,0,0,1,1,1 +13 %,0,0,0,0,0,0 +76.234%,0,0,0,1,1,0 +not a number,0,0,0,0,0,0 +x,0,0,0,0,0,0 +123partly987,0,0,0,0,0,0 diff --git a/integration_tests/seeds/public_macros/validating/expected_validated_uuids.csv b/integration_tests/seeds/public_macros/validating/expected_validated_uuids.csv new file mode 100644 index 0000000..e23034f --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/expected_validated_uuids.csv @@ -0,0 +1,8 @@ +uuid,valid_uuid +ace1245c-3af5-11ec-8d3d-0242ac130003,1 +a568464e-a05d-412c-8b30-517a46c57d88,1 +notanuid,0 +d0d61836-3af5-11ec-8d3d-0242ac130003,1 +d0d61c6e-3af5-11ec-8d3d-0242ac130003,1 +343422-234324-234234-4234234-23432,0 +343422-234324-234234-4234234-234xxx32,0 diff --git a/integration_tests/seeds/public_macros/validating/validate_date_and_time.csv b/integration_tests/seeds/public_macros/validating/validate_date_and_time.csv new file mode 100644 index 0000000..1a9f767 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/validate_date_and_time.csv @@ -0,0 +1,13 @@ +date_time +31-01-2020 +01/31/2020 +05.05.2020 +2020-01-31 +23:59 +12:59 +13:59:01 +"12:59:01,55" +11:59:00 +midnight +2020-01-31T12:59:00+02:00 +2020-01-31T12:59:00 \ No newline at end of file diff --git a/integration_tests/seeds/public_macros/validating/validate_emails.csv b/integration_tests/seeds/public_macros/validating/validate_emails.csv new file mode 100644 index 0000000..d7a1982 --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/validate_emails.csv @@ -0,0 +1,5 @@ +user_id,email +1,test@fakemail.com +2,novalidemail@ +3,novalidemail@com +4,test+alovalidemail@fakemail.com diff --git a/integration_tests/seeds/public_macros/validating/validate_ip.csv b/integration_tests/seeds/public_macros/validating/validate_ip.csv new file mode 100644 index 0000000..cdc743b --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/validate_ip.csv @@ -0,0 +1,29 @@ +ip_address +1.2.3.4 +01.102.103.104 +124.171.228.4 +192.168.1.35 +192.168.1.198 +127.248.111.240 +01.1.1 +12325412 +notvalidatall +232.232.33 +232.3232.232.232+2312 +::::erwerwe +::3343:4343434343:34343:343434343:443 + +2001:db8:3333:4444:5555:6666:7777:8888 +2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF +2001:db8:: +::1234:5678 +2001:db8::1234:5678 +::11.22.33.44 +2001:db8::123.123.123.123 +2001:db8::1234:5678:5.6.7.8 +2001:db8:3333:4444:5555:6666:1.2.3.4 +::11.22.33.44 +2001:db8::123.123.123.123 +::1234:5678:91.123.4.56 +::1234:5678:1.2.3.4 +2001:db8::1234:5678:5.6.7.8 \ No newline at end of file diff --git a/integration_tests/seeds/public_macros/validating/validate_numbers.csv b/integration_tests/seeds/public_macros/validating/validate_numbers.csv new file mode 100644 index 0000000..fad0aab --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/validate_numbers.csv @@ -0,0 +1,12 @@ +number +"133" +"1232.232" +"2332,123" +"not a number" +"1,3%" +"123%" +"13 %" +"76.234%" +"not a number" +"x" +"123partly987" diff --git a/integration_tests/seeds/public_macros/validating/validate_uuid.csv b/integration_tests/seeds/public_macros/validating/validate_uuid.csv new file mode 100644 index 0000000..c4866ed --- /dev/null +++ b/integration_tests/seeds/public_macros/validating/validate_uuid.csv @@ -0,0 +1,8 @@ +uuid +ace1245c-3af5-11ec-8d3d-0242ac130003 +a568464e-a05d-412c-8b30-517a46c57d88 +notanuid +d0d61836-3af5-11ec-8d3d-0242ac130003 +d0d61c6e-3af5-11ec-8d3d-0242ac130003 +343422-234324-234234-4234234-23432 +343422-234324-234234-4234234-234xxx32 \ No newline at end of file diff --git a/integration_tests/snapshots/.gitkeep b/integration_tests/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration_tests/tests/.gitkeep b/integration_tests/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/macros/.gitkeep b/macros/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/macros/config/get_model_config.sql b/macros/config/get_model_config.sql new file mode 100644 index 0000000..2153636 --- /dev/null +++ b/macros/config/get_model_config.sql @@ -0,0 +1,41 @@ + +{% macro get_model_config(monitored) %} + {% set model = {} %} + {% do model.update({'name': f2dq.row_value(monitored, 'name')}) %} + {% do model.update({'schema': f2dq.row_value(monitored, 'schema')}) %} + {% do model.update({'database': f2dq.row_value(monitored, 'database')}) %} + {% do model.update({'time_filter': f2dq.row_value(monitored, 'time_filter')}) %} + {% do model.update({'metrics': fromjson(f2dq.row_value(monitored, 'metrics'))}) %} + {% do model.update({'model_name': model.get('database') + '.' + model.get('schema') + '.' + model.get('name')}) %} + {% do model.update({'table_name': full_table_name_values(model.get('name'), model.get('schema'), model.get('database'))}) %} + + {% set columns_db = f2dq.row_value(monitored, 'columns') %} + + {% set column_list = fromjson(columns_db) if columns_db is not none else none %} + {% set columns_dict = f2dq.dict_from_list(column_list) %} + + {% do model.update({'columns_dict': columns_dict}) %} + {% do model.update({'columns_compute_all': columns_dict is none}) %} + + {% set columns_query %} + select * from {{ ref('f2dq_columns') }} + where name = '{{ model.name }}' and schema = '{{ model.schema }}' and database = '{{ model.database }}' + {% endset %} + {% set columns = run_query(columns_query) %} + + {% set columns_info = {} %} + {% for col in columns %} + {% set column_name = f2dq.row_value(col, 'column_name') %} + {% set data_type = f2dq.get_column_type(col) %} + {% do columns_info.update({column_name: { 'data_type': data_type }}) %} + {% endfor %} + + {% do model.update({'columns_info': columns_info}) %} + {% do model.update({'columns': columns}) %} + + {{ return(model) }} +{% endmacro %} + +{% macro should_compute_metric(model, column_name) %} + {{ return(model.columns_compute_all or model.columns_dict.get(column_name)) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/db/bigquery/quote_column_name.sql b/macros/db/bigquery/quote_column_name.sql new file mode 100644 index 0000000..f8124a7 --- /dev/null +++ b/macros/db/bigquery/quote_column_name.sql @@ -0,0 +1,4 @@ +{% macro bigquery__quote_column_name(column_name) %} + {% set quoted_col_name = '`' + column_name + '`' %} + {{ return(quoted_col_name) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/db/bigquery/quote_string.sql b/macros/db/bigquery/quote_string.sql new file mode 100644 index 0000000..b472e12 --- /dev/null +++ b/macros/db/bigquery/quote_string.sql @@ -0,0 +1,4 @@ + +{%- macro bigquery__quote_string(str) %} + r"""{{ str }}""" +{% endmacro %} \ No newline at end of file diff --git a/macros/db/bigquery/split_and_return_nth_value.sql b/macros/db/bigquery/split_and_return_nth_value.sql new file mode 100644 index 0000000..28ff01e --- /dev/null +++ b/macros/db/bigquery/split_and_return_nth_value.sql @@ -0,0 +1,3 @@ +{% macro bigquery__split_and_return_nth_value(column_name, delimiter, ordinal) %} + split({{ f2dq.clean_blacklist(column_name, ['"', '`'], '') }}, '{{ delimiter }}')[ORDINAL( {{ ordinal }} )] +{% endmacro %} \ No newline at end of file diff --git a/macros/db/core/escape.sql b/macros/db/core/escape.sql new file mode 100644 index 0000000..7d23ee3 --- /dev/null +++ b/macros/db/core/escape.sql @@ -0,0 +1,4 @@ + +{% macro escape_seq_for_json(chr) %}{{adapter.dispatch('escape_seq_for_json', 'f2dq')(chr)}}{% endmacro %} + +{% macro default__escape_seq_for_json(chr) %}'\\\{{chr}}'{% endmacro %} \ No newline at end of file diff --git a/macros/db/core/identifier_mapping.sql b/macros/db/core/identifier_mapping.sql new file mode 100644 index 0000000..270bd89 --- /dev/null +++ b/macros/db/core/identifier_mapping.sql @@ -0,0 +1,10 @@ + +{% macro name_in_db(name) %} + {% set translated = adapter.dispatch('name_in_db', 'f2dq')(name) %} + {{ return(translated) }} + +{% endmacro %} + +{% macro default__name_in_db(name) %} + {{ return(name) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/db/core/quote_column_name.sql b/macros/db/core/quote_column_name.sql new file mode 100644 index 0000000..39c3361 --- /dev/null +++ b/macros/db/core/quote_column_name.sql @@ -0,0 +1,10 @@ +{% macro quote_column_name(column_name) %} + {% set col_name = adapter.dispatch('quote_column_name', 'f2dq')(column_name) %} + {{ return(col_name) }} +{% endmacro %} + + +{% macro default__quote_column_name(column_name) %} + {% set quoted_col_name = '"' + column_name + '"' %} + {{ return(quoted_col_name) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/db/core/quote_string.sql b/macros/db/core/quote_string.sql new file mode 100644 index 0000000..2183752 --- /dev/null +++ b/macros/db/core/quote_string.sql @@ -0,0 +1,13 @@ + +{%- macro quote_string(str) %} + {{ adapter.dispatch('quote_string', 'f2dq')(str) }} +{% endmacro %} + +{%- macro default__quote_string(str) %} + $${{ str }}$$ +{% endmacro %} + + +{%- macro quote_new_line() %}{{ adapter.dispatch('quote_new_line', 'f2dq')() }}{% endmacro %} + +{%- macro default__quote_new_line() %}'\134\134n'{% endmacro %} \ No newline at end of file diff --git a/macros/db/core/split_and_return_nth_value.sql b/macros/db/core/split_and_return_nth_value.sql new file mode 100644 index 0000000..e9c4d0b --- /dev/null +++ b/macros/db/core/split_and_return_nth_value.sql @@ -0,0 +1,7 @@ +{% macro split_and_return_nth_value(column_name, delimiter, ordinal) -%} + {{ adapter.dispatch('split_and_return_nth_value', 'f2dq')(column_name, delimiter, ordinal) }} +{%- endmacro %} + +{% macro default__split_and_return_nth_value(column_name, delimiter, ordinal) -%} + split_part({{ f2dq.clean_blacklist(column_name, ['"', '`'], '') }}, '{{ delimiter }}', {{ ordinal }}) +{%- endmacro %} \ No newline at end of file diff --git a/macros/db/postgres/escape.sql b/macros/db/postgres/escape.sql new file mode 100644 index 0000000..105c2ad --- /dev/null +++ b/macros/db/postgres/escape.sql @@ -0,0 +1 @@ +{% macro postgres__escape_seq_for_json(chr) %}'\{{chr}}'{% endmacro %} \ No newline at end of file diff --git a/macros/db/postgres/postgres_type_db.sql b/macros/db/postgres/postgres_type_db.sql new file mode 100644 index 0000000..725e728 --- /dev/null +++ b/macros/db/postgres/postgres_type_db.sql @@ -0,0 +1,3 @@ +{% macro postgres_type_db() %} + {{ ('postgres', 'greenplum') }} +{% endmacro %} \ No newline at end of file diff --git a/macros/db/postgres/quote_string.sql b/macros/db/postgres/quote_string.sql new file mode 100644 index 0000000..d8e6f87 --- /dev/null +++ b/macros/db/postgres/quote_string.sql @@ -0,0 +1,3 @@ + + +{%- macro postgres__quote_new_line() %}'\\n'{% endmacro %} \ No newline at end of file diff --git a/macros/db/redshift/escape.sql b/macros/db/redshift/escape.sql new file mode 100644 index 0000000..74f4447 --- /dev/null +++ b/macros/db/redshift/escape.sql @@ -0,0 +1,2 @@ + +{% macro redshift__escape_seq_for_json(chr) %}'\\\{{chr}}'{% endmacro %} \ No newline at end of file diff --git a/macros/db/redshift/quote_string.sql b/macros/db/redshift/quote_string.sql new file mode 100644 index 0000000..450f917 --- /dev/null +++ b/macros/db/redshift/quote_string.sql @@ -0,0 +1,2 @@ + +{%- macro redshift__quote_new_line() %}'\134\134n'{% endmacro %} \ No newline at end of file diff --git a/macros/db/snowflake/identifier_mapping.sql b/macros/db/snowflake/identifier_mapping.sql new file mode 100644 index 0000000..7a1c36f --- /dev/null +++ b/macros/db/snowflake/identifier_mapping.sql @@ -0,0 +1,8 @@ + +{% macro snowflake__name_in_db(name) %} + {% if name %} + {{ return (name.upper()) }} + {% else %} + {{ return (name) }} + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/macros/meta/get_monitored.sql b/macros/meta/get_monitored.sql new file mode 100644 index 0000000..a76cf1f --- /dev/null +++ b/macros/meta/get_monitored.sql @@ -0,0 +1,97 @@ +{% macro pub_monitored_from_graph() %} + {% set monitored = [] %} + {% set both = []%} + {% do both.extend(graph.nodes.values()) %} + {% do both.extend(graph.sources.values()) %} + {% set owners_config = f2dq.get_owners_config() %} + + {% set select_var = f2dq.f2dq_var('select', none) %} + {% set select_all = true %} + + {% set selected_nodes = none %} + {% set selected_tags = none %} + + {% if select_var is not none %} + {% set select_all = false %} + {% set selected_nodes = dict() %} + {% set selected_tags = dict() %} + + {% for el in select_var %} + {% if el.startswith('tag:') %} + {% do selected_tags.update({el[4:]: True}) %} + {% else %} + {% do selected_nodes.update({el: True}) %} + {% endif %} + {% endfor %} + {% endif %} + + {% for el in both %} + {% if el.resource_type in ['model', 'seed', 'source'] %} + {% if f2dq.f2dq_node_config(el, 'monitored') %} + {% set target_name = el.identifier or el.alias or el.name %} + + {% if select_all %} + {% set selected = true %} + {% else %} + {% set selected_name = selected_nodes.get(target_name, false) %} + {% set selected_tag = [] %} + + {% for tag in el.tags %} + {% if selected_tags.get(tag, false) %} + {% do selected_tag.append(true) %} + {% endif %} + {% endfor %} + + {% set selected = selected_name or (selected_tag | length > 0) %} + {% endif %} + + {% set metrics_groups = f2dq.f2dq_node_config(el, 'metrics_groups', f2dq.f2dq_var('default_metrics')) %} + {% set additional_metrics = f2dq.f2dq_node_config(el, 'metrics', {}) %} + + {% do monitored.append({ + 'name': f2dq.name_in_db(target_name), + 'schema': f2dq.name_in_db(el.schema), + 'database': f2dq.name_in_db(el.database), + 'time_filter': f2dq.f2dq_node_config(el, 'time_filter', none), + 'metrics_groups': metrics_groups, + 'additional_metrics': f2dq.metrics_in_db(additional_metrics), + 'metrics': f2dq.metrics_in_db(f2dq.final_metrics(metrics_groups, additional_metrics)), + 'columns': f2dq.columns_in_db(f2dq.f2dq_node_config(el, 'columns', none)), + 'anomaly_detector': f2dq.f2dq_node_config(el, 'anomaly_detector', f2dq.f2dq_var('anomaly_detector', {})), + 'owners': f2dq.prepare_model_owners(f2dq.f2dq_node_config(el, 'owners', []), owners_config), + 'selected': selected + }) + %} + {% endif %} + {% endif %} + {% endfor %} + + {{ return(monitored) }} +{% endmacro %} + +{% macro get_owners_config() %} + {% set owners_config = f2dq.f2dq_var('owners_config', {}) %} + {{ return (owners_config) }} +{% endmacro %} + +{% macro prepare_model_owners(f2dq_owners, owners_config) %} + {% set owners = {} %} + {% set seen_identifiers = {} %} + {% for owner in f2dq_owners if owners_config.get(owner) %} + {% set members = owners_config.get(owner) %} + {% for member in members %} + {% set identifier = member.get('identifier') %} + {% if identifier not in seen_identifiers %} + {% do seen_identifiers.update({identifier: true }) %} + {% do owners.update({ + identifier: { + 'notify_channel': member.get('type'), + 'owner': owner, + 'name': member.get('name') + } + }) %} + {% endif %} + {% endfor %} + {% endfor %} + {{ return (owners) }} +{% endmacro %} diff --git a/macros/meta/information_schema.sql b/macros/meta/information_schema.sql new file mode 100644 index 0000000..c9ed690 --- /dev/null +++ b/macros/meta/information_schema.sql @@ -0,0 +1,32 @@ +{% macro get_monitored_columns(schema, database) %} + {{ adapter.dispatch('get_monitored_columns', 'f2dq')(schema, database) }} +{% endmacro %} + +{% macro default__get_monitored_columns(table_schema, db_name) %} + {% set relation = api.Relation.create(database=db_name, schema=table_schema) %} + select + table_name, + table_schema, + table_catalog, + column_name, + data_type, + is_nullable + from + {{ relation.information_schema('COLUMNS') }} + where + table_schema = '{{ table_schema }}' +{% endmacro %} + +{% macro redshift__get_monitored_columns(table_schema, db_name) %} + select + table_name, + table_schema, + table_catalog, + column_name, + data_type, + is_nullable + from + svv_columns + where + table_schema = '{{ table_schema }}' +{% endmacro %} diff --git a/macros/meta/monitored_model_queries.sql b/macros/meta/monitored_model_queries.sql new file mode 100644 index 0000000..f701415 --- /dev/null +++ b/macros/meta/monitored_model_queries.sql @@ -0,0 +1,10 @@ +{% macro get_tables() %} + select * + from {{ ref('f2dq_selected') }} + order by name, schema, database, time_filter +{% endmacro %} + +{% macro get_schemas() %} + select distinct schema, database + from {{ ref('f2dq_selected') }} +{% endmacro %} \ No newline at end of file diff --git a/macros/meta/save_monitored.sql b/macros/meta/save_monitored.sql new file mode 100644 index 0000000..9760433 --- /dev/null +++ b/macros/meta/save_monitored.sql @@ -0,0 +1,17 @@ +{% macro save_monitored(monitored_path) %} + + {% set monitored_query %} + select + {{ full_table_name('name', 'schema', 'database') }} as {{ f2dq.quote_column('model') }}, + time_filter as {{ f2dq.quote_column('time_filter') }}, + metrics as {{ f2dq.quote_column('metrics') }}, + columns as {{ f2dq.quote_column('columns') }}, + anomaly_detector as {{ f2dq.quote_column('anomaly_detector') }}, + owners as {{ f2dq.quote_column('owners') }} + from {{ ref('f2dq_selected') }} + {% endset %} + {% set query_result = run_query(monitored_query) %} + {% set monitored_file_path = monitored_path or 'target/f2dq/monitored.json' %} + {% do query_result.to_json(monitored_file_path) %} + +{% endmacro %} \ No newline at end of file diff --git a/macros/meta/table_name.sql b/macros/meta/table_name.sql new file mode 100644 index 0000000..a468cec --- /dev/null +++ b/macros/meta/table_name.sql @@ -0,0 +1,33 @@ +{% macro full_table_name(table_name, table_schema, table_catalog) %} + {{ adapter.dispatch('full_table_name', 'f2dq')(table_name, table_schema, table_catalog) }} +{% endmacro %} + + +{% macro default__full_table_name(table_name, table_schema, table_catalog) %} + '"' || {{table_catalog}} || '"' || '.' || '"' || {{table_schema}} || '"' || '.' || '"' || {{table_name}} || '"' +{% endmacro %} + + +{% macro bigquery__full_table_name(table_name, table_schema, table_catalog) %} + '`' || {{table_catalog}} || '`' || '.' || '`' || {{table_schema}} || '`' || '.' || '`' || {{table_name}} || '`' +{% endmacro %} + + +{% macro full_table_name_values(table_name, table_schema, table_catalog) %} + {% set result = adapter.dispatch('full_table_name_values', 'f2dq')(table_name, table_schema, table_catalog) %} + {{ return (result.strip()) }} +{% endmacro %} + +{% macro default__full_table_name_values(table_name, table_schema, table_catalog) %} + "{{table_catalog}}"."{{table_schema}}"."{{table_name}}" +{% endmacro %} + + +{% macro bigquery__full_table_name_values(table_name, table_schema, table_catalog) %} + `{{table_catalog}}`.`{{table_schema}}`.`{{table_name}}` +{% endmacro %} + + +{% macro snowflake__full_table_name_values(table_name, table_schema, table_catalog) %} + "{{table_catalog|upper}}"."{{table_schema|upper}}"."{{table_name|upper}}" +{% endmacro %} \ No newline at end of file diff --git a/macros/metrics/base/build_in/column_default.sql b/macros/metrics/base/build_in/column_default.sql new file mode 100644 index 0000000..e4352b2 --- /dev/null +++ b/macros/metrics/base/build_in/column_default.sql @@ -0,0 +1,91 @@ + +{% macro f2dq_metric_max(context) %} + max({{context.column_name}}) +{% endmacro %} + +{% macro f2dq_metric_min(context) %} + min({{context.column_name}}) +{% endmacro %} + +{% macro f2dq_metric_avg(context) %} + avg(cast ({{context.column_name}} as {{ numeric_type() }})) +{% endmacro %} + +{% macro f2dq_metric_stddev(context) %} + stddev(cast ( {{context.column_name}} as {{ numeric_type() }})) +{% endmacro %} + +{% macro f2dq_metric_variance(context) %} + variance(cast ( {{context.column_name}} as {{ numeric_type() }})) +{% endmacro %} + +{% macro f2dq_metric_max_length(context) %} + max(length({{context.column_name}})) +{% endmacro %} + +{% macro f2dq_metric_min_length(context) %} + min(length({{context.column_name}})) +{% endmacro %} + +{% macro f2dq_metric_avg_length(context) %} + avg(cast (length( {{context.column_name}} ) as {{ numeric_type() }})) +{% endmacro %} + +{% macro f2dq_metric_nulls_count(context) %} + coalesce( + sum( + case when {{context.column_name}} is null + then 1 + else 0 + end + ), 0 + ) +{% endmacro %} + +{% macro f2dq_metric_missing_count(context) %} + coalesce( + sum( + case + when {{context.column_name}} is null + then 1 + when {{context.column_name}} = '' + then 1 + else 0 + end + ), 0 + ) +{% endmacro %} + +{% macro f2dq_metric_nulls_percent(context) %} + {{ percentage_formula(f2dq_metric_nulls_count(context), f2dq_metric_row_count()) }} +{% endmacro %} + +{% macro f2dq_metric_missing_percent(context) %} + {{ percentage_formula(f2dq_metric_missing_count(context), f2dq_metric_row_count()) }} +{% endmacro %} + +{% macro f2dq_metric_count_true(context) %} + COALESCE( + SUM( + CASE + WHEN {{ context.column_name }} IS TRUE THEN 1 + ELSE 0 + END + ), + 0 + ) +{% endmacro %} + +{% macro f2dq_metric_count_false(context) %} + COALESCE( + SUM( + CASE + WHEN {{ context.column_name }} IS FALSE THEN 1 + ELSE 0 + END + ), + 0 + ) +{% endmacro %} + + diff --git a/macros/metrics/base/build_in/optional_column_metrics.sql b/macros/metrics/base/build_in/optional_column_metrics.sql new file mode 100644 index 0000000..1d3f7e4 --- /dev/null +++ b/macros/metrics/base/build_in/optional_column_metrics.sql @@ -0,0 +1,104 @@ +{% macro f2dq_metric_regex_count(column_name, pattern) %} + coalesce( + sum( + case when {{ regex_match_expression(column_name, pattern) }} + then 1 + else 0 + end + ), 0 + ) +{% endmacro %} + +{% macro f2dq_metric_match_regex(context) %} + {{ f2dq_metric_regex_count(context.column_name, context.config.regex) }} +{% endmacro %} + +{% macro f2dq_metric_match_regex_percent(context) %} + {{ percentage_formula(f2dq_metric_match_regex(context), f2dq_metric_row_count()) }} +{% endmacro %} + +{% macro f2dq_metric_not_match_regex(context) %} + {{ f2dq_metric_row_count() }} - {{ f2dq_metric_regex_count(context.column_name, context.config.regex) }} +{% endmacro %} + +{% macro f2dq_metric_not_match_regex_percent(context) %} + {{ percentage_formula(f2dq_metric_not_match_regex(context), f2dq_metric_row_count()) }} +{% endmacro %} + +{% macro f2dq_metric_distinct_values(context) %} + {{ distinct_values(context) }} +{% endmacro %} + +{% macro distinct_values(context) %} + {{ adapter.dispatch('distinct_values', 'f2dq')(context) }} +{% endmacro %} + +{% macro default__distinct_values(context) %} + coalesce( + count(distinct {{ context.column_name }} ) + , 0) +{% endmacro %} + +{% macro postgres__distinct_values(context) %} + {# /* In postgres, its faster to count distinct values in a column by selecting then counting in separate steps */ #} + with temp_table as ( + select distinct {{ context.column_name }} from {{ context.table_name }} + where {{ in_time_window(context.time_filter) }} + ) + select coalesce(count(*), 0) from temp_table +{% endmacro %} + +{% macro f2dq_metric_approx_distinct_values(context) %} + {{ approx_distinct_values(context) }} +{% endmacro %} + +{% macro approx_distinct_values(context) %} + {{ adapter.dispatch('approx_distinct_values', 'f2dq')(context) }} +{% endmacro %} + +{% macro default__approx_distinct_values(context) %} + {# /* No approximate distinct count in postgres so we default to using a distinct count */ #} + {{ f2dq_metric_distinct_values(context) }} +{% endmacro %} + +{% macro redshift__approx_distinct_values(context) %} + approximate {{ f2dq_metric_distinct_values(context.column_name) }} +{% endmacro %} + +{% macro bigquery__approx_distinct_values(context) %} + approx_count_distinct({{ context.column_name }}) +{% endmacro %} + +{% macro snowflake__approx_distinct_values(context) %} + approx_count_distinct({{ context.column_name }}) +{% endmacro %} + +{% macro f2dq_metric_duplicate_values(context) %} + with temp_table as ( + select {{ context.column_name }} from {{ context.table_name }} + where {{ in_time_window(context.time_filter) }} + group by {{ context.column_name }} + having count(1) > 1 + ) + select coalesce(count(*), 0) from temp_table +{% endmacro %} + +{% macro f2dq_metric_duplicate_rows(context) %} + with temp_table as ( + select {{ context.column_name }}, count(1) as row_count from {{ context.table_name }} + where {{ in_time_window(context.time_filter) }} + group by {{ context.column_name }} + having count(1) > 1 + ) + select coalesce(sum(row_count), 0) from temp_table +{% endmacro %} + +{% macro f2dq_metric_unique_rows(context) %} + with temp_table as ( + select {{ context.column_name }}, count(1) as row_count from {{ context.table_name }} + where {{ in_time_window(context.time_filter) }} + group by {{ context.column_name }} + having count(1) = 1 + ) + select coalesce(sum(row_count), 0) from temp_table +{% endmacro %} \ No newline at end of file diff --git a/macros/metrics/base/build_in/optional_table_metrics.sql b/macros/metrics/base/build_in/optional_table_metrics.sql new file mode 100644 index 0000000..51df797 --- /dev/null +++ b/macros/metrics/base/build_in/optional_table_metrics.sql @@ -0,0 +1,7 @@ +{% macro f2dq_metric_distinct_table_rows(context) %} + with temp_table AS ( + select distinct * from {{ context.table_name }} + where {{ in_time_window(context.time_filter) }} + ) + select coalesce(count(*), 0) FROM temp_table +{% endmacro %} diff --git a/macros/metrics/base/build_in/table_default.sql b/macros/metrics/base/build_in/table_default.sql new file mode 100644 index 0000000..17bdef7 --- /dev/null +++ b/macros/metrics/base/build_in/table_default.sql @@ -0,0 +1,33 @@ + +{% macro f2dq_metric_row_count(context) %} + count(1) +{% endmacro %} + +{% macro f2dq_metric_freshness(context) %} + {{ freshness_expression(context.time_filter) }} +{% endmacro %} + +{% macro freshness_expression(time_filter) %} + {# /* If time_filter is none, we are computing the metric globally. we set the value as null since a table without use of a time filter doesn't really have a freshness metric */ #} + {% if time_filter is none %} + cast(null as {{ numeric_type() }}) + {% else %} + {{ adapter.dispatch('freshness_expression', 'f2dq')(time_filter) }} + {% endif %} +{% endmacro %} + +{% macro default__freshness_expression(time_filter) %} + EXTRACT(EPOCH FROM ({{time_window_end()}} - max({{time_filter}}))) +{% endmacro %} + +{% macro bigquery__freshness_expression(time_filter) %} + TIMESTAMP_DIFF ( timestamp({{ time_window_end()}}), timestamp(max({{time_filter}})), SECOND) +{% endmacro %} + +{% macro snowflake__freshness_expression(time_filter) %} + timediff(second, max({{time_filter}}), {{- time_window_end() -}}) +{% endmacro %} + +{% macro redshift__freshness_expression(time_filter) %} + DATEDIFF(second, max({{time_filter}}), {{- time_window_end() -}}) +{% endmacro %} diff --git a/macros/metrics/base/expression.sql b/macros/metrics/base/expression.sql new file mode 100644 index 0000000..bf876d5 --- /dev/null +++ b/macros/metrics/base/expression.sql @@ -0,0 +1,102 @@ +{% macro metrics_base_expressions(model, columns, table_level=False) %} + + {% set col_expr = [] %} + + {% for col in columns %} + {% set column_name = f2dq.row_value(col, 'column_name') %} + {% do col_expr.extend(f2dq.metrics_base_expression_column_all(model, col)) %} + {% endfor %} + + {% if table_level %} + {% do col_expr.extend(f2dq.metrics_base_expresion_table_all(model)) %} + {% endif %} + + {{ return (col_expr) }} + +{% endmacro %} + +{% macro metrics_base_expression_column_all(model, column) %} + + {%- set col_expr = [] %} + {%- set metrics_to_compute = [] %} + {% set column_name = f2dq.row_value(column, 'column_name') %} + {% set data_type = model.columns_info[column_name].data_type %} + {% do metrics_to_compute.extend(model.metrics.get('group').get('column', {}).get(data_type, [])) %} + {% do metrics_to_compute.extend(model.metrics.get('additional').get('column', {}).get(column_name, [])) %} + + {% for metric_value in metrics_to_compute %} + {% set metric_obj = f2dq.extract_metric_config(metric_value) %} + {% set expression = f2dq.metrics_base_expression_column(model, column_name, metric_obj['metric'], metric_obj['config']) %} + {% do col_expr.append({ 'expr': expression, 'col_name': column_name, 'metric': metric_obj['metric']}) %} + {% endfor %} + + {{ return (col_expr) }} + +{% endmacro %} + + +{% macro metrics_base_expresion_table_all(model) %} + {%- set table_expr = [] %} + {%- set metrics_to_compute = [] %} + {% do metrics_to_compute.extend(model.metrics.get('group').get('table', [])) %} + {% do metrics_to_compute.extend(model.metrics.get('additional').get('table', [])) %} + + {% for metric_value in metrics_to_compute %} + {% set metric_obj = f2dq.extract_metric_config(metric_value) %} + {% set expression = f2dq.metrics_base_expression_table(model, metric_obj['metric'], metric_obj['config']) %} + {% do table_expr.append({ 'expr': expression, 'col_name': '', 'metric': metric_obj['metric']}) %} + {% endfor %} + + {{ return (table_expr) }} + +{% endmacro %} + +{% macro metrics_base_expression_table(model, metric_name, config) %} + {% set metric_macro = f2dq.get_metric_macro(metric_name) %} + {% set context = {'time_filter': model.time_filter, 'metric_name': metric_name, 'config': config, 'table_name': model.table_name, 'column_name': none} %} + + {{ metric_macro(context) }} + +{% endmacro %} + + +{%- macro metrics_base_expression_column(model, column_name, metric_name, config) %} + {% set metric_macro = f2dq.get_metric_macro(metric_name) %} + {% set context = {'time_filter': model.time_filter, 'metric_name': metric_name, 'config': config, 'table_name': model.table_name, 'column_name': f2dq.quote_column_name(column_name)} %} + + {{ metric_macro(context) }} + +{% endmacro %} + +{% macro extract_metric_config(metric_value) %} + + {% set config = none %} + + {% if metric_value is mapping %} + {% set metric = metric_value.keys() | first %} + {% if metric_value[metric] is none %} + {{ exceptions.raise_compiler_error("Empty configuration passed for metric: " ~ metric ~ ". If the metric doesn't use a config, please use the column name as a string.") }} + {% endif %} + + {% set config = metric_value[metric] %} + {%- else %} + {% set metric = metric_value %} + {% endif %} + + {{ return ({'metric': metric, 'config': config}) }} + +{% endmacro %} + +{%- macro get_metric_macro(metric_name) %} + {% set macro_name = 'f2dq_metric' + '_' + metric_name %} + + {% if context['f2dq'].get(macro_name) %} + {% set metric_macro = context['f2dq'][macro_name] %} + {%- else %} + {% set metric_macro = context[project_name][macro_name] %} + {% endif %} + + {{ return (metric_macro) }} + +{% endmacro %} + diff --git a/macros/metrics/base/internal_model_template.sql b/macros/metrics/base/internal_model_template.sql new file mode 100644 index 0000000..c61f61f --- /dev/null +++ b/macros/metrics/base/internal_model_template.sql @@ -0,0 +1,31 @@ +{% macro f2dq_last_base_metrics_part() %} + +-- depends_on: {{ ref('f2dq_columns') }} + +{{ + config( + materialized='table', + ) +}} + +{{ f2dq.empty_last_base_metrics() }} + +{% endmacro %} + +{% macro f2dq_last_base_metrics_thread(num) %} + {% set part_name = 'f2dq_last_base_metrics_part' ~ num %} + {{ f2dq.generate_depends(['f2dq_selected', 'f2dq_monitored', 'f2dq_columns', 'f2dq_run_started_at', part_name]) }} + + {{ + config( + materialized='table', + ) + }} + + {% if not f2dq.in_compile() %} + {{ f2dq.metrics_base_compute_for_thread(num, part_name) }} + {% endif %} + + {{ f2dq.empty_last_base_metrics() }} + +{% endmacro %} \ No newline at end of file diff --git a/macros/metrics/base/queries.sql b/macros/metrics/base/queries.sql new file mode 100644 index 0000000..6bd1c8d --- /dev/null +++ b/macros/metrics/base/queries.sql @@ -0,0 +1,75 @@ +{% macro metrics_base_compute_for_thread(thread_value, ref_model) %} + {%- set tables = run_query(f2dq.get_tables()) %} + {%- for mtable in tables %} + -- we are splitting computing metrics to 4 different threads + {% set for_loop_mod = (loop.index % 4) %} + {% if for_loop_mod == thread_value %} + {% set model = get_model_config(mtable) %} + + {% set columns_to_query = [] %} + {% set size = 0 %} + + {% for column in model.columns %} + {% set column_name = f2dq.row_value(column, 'column_name') %} + + {% if should_compute_metric(model, column_name) %} + {% do columns_to_query.append(column) %} + {% endif %} + + {% set columns_size = columns_to_query| length %} + + {% if columns_size == var('f2dq:max_columns_in_query') %} + {%- set insert_stats_query = f2dq.metrics_base_insert(model, ref_model, columns_to_query) -%} + + {% if insert_stats_query %} + {% do run_query(insert_stats_query) %} + {% endif %} + {% do columns_to_query.clear() %} + {% endif %} + {% endfor %} + + {%- set insert_stats_query = f2dq.metrics_base_insert(model, ref_model, columns_to_query, table_level=True) -%} + {% do run_query(insert_stats_query) %} + + {{ dbt_utils.log_info('[f2dq_log] - finished computing metrics for:' ~ model.model_name) }} + {% endif %} + {% endfor %} +{% endmacro %} + +{% macro metrics_base_insert(model, ref_model, columns, table_level=False) %} + + {% set col_exprs = f2dq.metrics_base_expressions(model, columns, table_level) %} + {% if col_exprs == [] %} + {{ return ('') }} + {% endif %} + + insert into {{ ref(ref_model) }} + with temp_table_metrics as ( + select + {%- for col_expr in col_exprs %} + ( {{ col_expr.expr }} ) as {{ f2dq.quote_column_name(col_expr.col_name + '___' + col_expr.metric) }} + {%- if not loop.last %},{%- endif %} + {% endfor %} + from + {{ model.table_name }} + where + {{ in_time_window(model.time_filter) }} + ) + + {%- for col_expr in col_exprs %} + {% set final_metric_name = get_final_metric_name(col_expr.metric, model.time_filter) %} + + select '{{model.table_name}}' as table_name, '{{ col_expr.col_name }}' as column_name, '{{ final_metric_name }}' as metric, {{ f2dq.quote_column_name(col_expr.col_name + '___' + col_expr.metric) }} as value + from temp_table_metrics + {% if not loop.last %}union all{% endif %} + {% endfor %} + +{% endmacro %} + +{% macro get_final_metric_name(metric_name, time_filter) %} + {% if time_filter is none %} + {{ return ('global__' + metric_name) }} + {% else %} + {{ return (metric_name) }} + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/macros/post_hook/f2dq_monitored.sql b/macros/post_hook/f2dq_monitored.sql new file mode 100644 index 0000000..e3dbb3e --- /dev/null +++ b/macros/post_hook/f2dq_monitored.sql @@ -0,0 +1,11 @@ +{% macro pub_insert_into_f2dq_monitored() %} + {% set monitored = f2dq.pub_monitored_from_graph() %} + {% do f2dq.insert_list_to_table( + this, + monitored, + ['name', 'schema', 'database', 'time_filter', 'metrics_groups', 'additional_metrics', 'metrics', 'columns', 'anomaly_detector', 'owners', 'selected'] + ) %} + + {{ return('') }} + +{% endmacro %} \ No newline at end of file diff --git a/macros/public/cleaning/clean_additional_whitespace.sql b/macros/public/cleaning/clean_additional_whitespace.sql new file mode 100644 index 0000000..24f3ed8 --- /dev/null +++ b/macros/public/cleaning/clean_additional_whitespace.sql @@ -0,0 +1,23 @@ +{% macro clean_additional_whitespaces(column_name) %} + {{ adapter.dispatch('clean_additional_whitespaces', 'f2dq')(column_name) }} +{% endmacro %} + +{% macro default__clean_additional_whitespaces(column_name) %} + trim(regexp_replace( {{ column_name }}, '\s\s+', ' ')) +{% endmacro %} + +{% macro postgres__clean_additional_whitespaces(column_name) %} + trim(regexp_replace( {{ column_name }}, '\s\s+', ' ', 'g')) +{% endmacro %} + +{% macro redshift__clean_additional_whitespaces(column_name) %} + trim(regexp_replace( {{ column_name }}, '\\s\\s+', ' ')) +{% endmacro %} + +{% macro bigquery__clean_additional_whitespaces(column_name) %} + trim(regexp_replace( {{ column_name }}, r'\s\s+', ' ')) +{% endmacro %} + +{% macro snowflake__clean_additional_whitespaces(column_name) %} + trim(regexp_replace( {{ column_name }}, '\\s\\s+', ' ')) +{% endmacro %} \ No newline at end of file diff --git a/macros/public/cleaning/clean_blacklist.sql b/macros/public/cleaning/clean_blacklist.sql new file mode 100644 index 0000000..ce47fcf --- /dev/null +++ b/macros/public/cleaning/clean_blacklist.sql @@ -0,0 +1,31 @@ +{% macro generate_blacklist_pattern(chars_to_blacklist) %} + {% set pattern = [] %} + {% for char in chars_to_blacklist %} + {% set expr = '(' + char + ')' %} + {% do pattern.append(expr) %} + {% endfor %} + + {{ return(pattern | join('|')) }} +{% endmacro %} + +{%- macro clean_blacklist(column_name, chars_to_blacklist, replacement) -%} + {% set pattern_string = f2dq.generate_blacklist_pattern(chars_to_blacklist) %} + + {{ adapter.dispatch('clean_blacklist', 'f2dq')(column_name, pattern_string, replacement) }} +{%- endmacro -%} + +{%- macro default__clean_blacklist(column_name, pattern_string, replacement) -%} + regexp_replace( {{ column_name }}, '{{ pattern_string }}', '{{ replacement }}') +{%- endmacro -%} + +{%- macro postgres__clean_blacklist(column_name, pattern_string, replacement) -%} + regexp_replace( {{ column_name }}, '{{ pattern_string }}', '{{ replacement }}', 'g') +{%- endmacro -%} + +{%- macro redshift__clean_blacklist(column_name, pattern_string, replacement) -%} + regexp_replace( {{ column_name }}, '{{ pattern_string }}', '{{ replacement }}') +{%- endmacro -%} + +{%- macro bigquery__clean_blacklist(column_name, pattern_string, replacement) -%} + regexp_replace( {{ column_name }}, """{{ pattern_string }}""", '{{ replacement }}') +{%- endmacro -%} \ No newline at end of file diff --git a/macros/public/cleaning/clean_capitalize_words.sql b/macros/public/cleaning/clean_capitalize_words.sql new file mode 100644 index 0000000..93bda09 --- /dev/null +++ b/macros/public/cleaning/clean_capitalize_words.sql @@ -0,0 +1,3 @@ +{% macro clean_capitalize_words(column_name) %} + initcap( {{column_name}} ) +{% endmacro %} \ No newline at end of file diff --git a/macros/public/filtering/get_duplicates.sql b/macros/public/filtering/get_duplicates.sql new file mode 100644 index 0000000..55b6653 --- /dev/null +++ b/macros/public/filtering/get_duplicates.sql @@ -0,0 +1,23 @@ +{# https://github.com/fast2dev/f2dq/issues/143 #} + +{# +macro returns rows with she same key set (unique_cols) + +along with the fields of the base model duplicates information added: +f2dq_duplicate_count - total number of duplicates with the same current key set +f2dq_duplicate_row_number - number of a duplicate row inside the group of duplicates with the same current key set +#} + +{% macro filter_get_duplicates(relation, unique_cols, sort_columns) %} + ( + with duplication_context as ( + {{f2dq.add_duplication_context(relation, unique_cols, sort_columns)}} + ), + duplicate_rows as ( + select * from duplication_context where f2dq_duplicate_group_row_count > 1 + ) + {# return surrogate key as well? #} + select * + from duplicate_rows + ) +{% endmacro %} \ No newline at end of file diff --git a/macros/public/filtering/remove_duplicates.sql b/macros/public/filtering/remove_duplicates.sql new file mode 100644 index 0000000..22a71e3 --- /dev/null +++ b/macros/public/filtering/remove_duplicates.sql @@ -0,0 +1,15 @@ + + + +{% macro filter_remove_duplicates(relation, unique_cols, sort_columns) %} + ( + with with_row_num as ( + {{f2dq.add_duplication_context(relation, unique_cols, sort_columns)}} + ), + one_row_num as ( + select * from with_row_num where f2dq_duplicate_group_row_number = 1 + ) + select {{ dbt_utils.star(from=relation) }} + from one_row_num + ) +{% endmacro %} \ No newline at end of file diff --git a/macros/public/normalizing/normalize_values.sql b/macros/public/normalizing/normalize_values.sql new file mode 100644 index 0000000..9186742 --- /dev/null +++ b/macros/public/normalizing/normalize_values.sql @@ -0,0 +1,32 @@ +{% macro is_dbt_relation(obj) %} + {{ return (obj is mapping and obj.get('metadata', {}).get('type', '').endswith('Relation') )}} +{% endmacro %} + +{% macro normalize_expression_cte(reference_table) %} + with target_table as ( + {% if f2dq.is_dbt_relation(reference_table) or reference_table is string %} + select * from {{ reference_table }} + {% elif reference_table is mapping %} + {% for key, value in reference_table.items() %} + select '{{key}}' as source, '{{value}}' as target + {% if not loop.last %}union all{% endif %} + {% endfor %} + {% endif %} + ) +{% endmacro %} + +{%- macro normalize_values(source_relation, column_name, reference_table) -%} + ( + {{ f2dq.normalize_expression_cte(reference_table) }} + + select s.*, + case when t.source is null + then s.{{column_name}} + else t.target + end as {{ column_name + '__normalized'}} + from {{ source_relation }} s + left join target_table t + on t.source = s.{{column_name}} + ) +{%- endmacro -%} + diff --git a/macros/public/store/export_alerts.sql b/macros/public/store/export_alerts.sql new file mode 100644 index 0000000..0922231 --- /dev/null +++ b/macros/public/store/export_alerts.sql @@ -0,0 +1,22 @@ +{% macro export_alerts(start_date, end_date, alerts_path=None, monitored_path=None) %} + {% set alerts_query %} + select + type as {{ f2dq.quote_column('type') }}, + model as {{ f2dq.quote_column('model') }}, + message as {{ f2dq.quote_column('message') }}, + value as {{ f2dq.quote_column('value') }}, + {{ format_timestamp('time_window_end')}} as {{ f2dq.quote_column('time_window_end') }} + from {{ ref('f2dq_alerts') }} + where + case + when type = 'anomaly' then {{ in_date_window('time_window_end', start_date, end_date) }} + else {{ in_date_window('time_window_end', start_date, none) }} + end + order by time_window_end desc + {% endset %} + + {% set query_result = run_query(alerts_query) %} + {% set alerts_file_path = alerts_path or 'target/f2dq/alerts.json' %} + {% do query_result.to_json(alerts_file_path) %} + {{ save_monitored(monitored_path) }} +{% endmacro %} diff --git a/macros/public/store/export_table_samples.sql b/macros/public/store/export_table_samples.sql new file mode 100644 index 0000000..e093c38 --- /dev/null +++ b/macros/public/store/export_table_samples.sql @@ -0,0 +1,15 @@ +{% macro export_table_samples(start_date, end_date, table_samples_path=None) %} + {% set table_samples_query %} + select + lower(table_name) as table_name, + sample_data, + sampled_on + from + {{ ref('f2dq_table_samples') }} + {% endset %} + + {% set query_result = run_query(table_samples_query) %} + {% set table_samples_file_path = table_samples_path or 'target/f2dq/table_samples.json' %} + {% do query_result.to_json(table_samples_file_path) %} + +{% endmacro %} \ No newline at end of file diff --git a/macros/public/store/export_tests_history.sql b/macros/public/store/export_tests_history.sql new file mode 100644 index 0000000..e21a15a --- /dev/null +++ b/macros/public/store/export_tests_history.sql @@ -0,0 +1,25 @@ +{% macro export_tests_history(start_date, end_date, tests_history_path=None) %} + {% set tests_history_query %} + select + table_name, + column_name, + test_name, + run_at, + status, + execution_time, + message, + failures_count, + failures_json, + failures_table, + severity, + compiled_sql + from + {{ ref('f2dq_test_history') }} + where {{ in_date_window('run_at', start_date, end_date) }} + {% endset %} + + {% set query_result = run_query(tests_history_query) %} + {% set tests_history_file_path = tests_history_path or 'target/f2dq/tests_history.json' %} + {% do query_result.to_json(tests_history_file_path) %} + +{% endmacro %} diff --git a/macros/public/store/generate_overview.sql b/macros/public/store/generate_overview.sql new file mode 100644 index 0000000..10eb6c9 --- /dev/null +++ b/macros/public/store/generate_overview.sql @@ -0,0 +1,93 @@ + +{% macro overview_select_base(type, timestamp_col) %} + '{{ type }}' as {{ f2dq.quote_column('type') }}, + table_name as {{ f2dq.quote_column('table_name') }}, + column_name as {{ f2dq.quote_column('column_name') }}, + {{ timestamp_col }} as {{ f2dq.quote_column('computed_on') }}, +{% endmacro %} + +{% macro generate_overview(start_date, end_date, interval, overview_path=None, monitored_path=None) %} +-- depends_on: {{ ref('f2dq_anomalies') }} +-- depends_on: {{ ref('f2dq_base_metrics') }} +-- depends_on: {{ ref('f2dq_schema_changes') }} +-- depends_on: {{ ref('f2dq_columns') }} + + {# time grain is either days or hour #} + {% set time_grain, num_str = interval.split(':') %} + {% set num = num_str | int %} + {% if time_grain == 'hours' %} + {% set interval_length_sec = num * 3600 %} + {% elif time_grain == 'days'%} + {% set interval_length_sec = num * 3600 * 24 %} + {% else %} + {{ exceptions.raise_compiler_error("Invalid interval. Got: " ~ interval) }} + {% endif %} + {{ dbt_utils.log_info('[f2dq] interval length in seconds is ' ~ interval_length_sec) }} + {% set overview_query %} + with schema_changes_casted as ( + select id, table_name, operation, column_name, data_type, {{ bool_to_string('is_nullable') }}, prev_column_name, prev_data_type, {{ bool_to_string('prev_is_nullable') }}, detected_time + from {{ ref('f2dq_schema_changes') }} + ), + columns_casted as ( + select {{ full_table_name('name', 'schema', 'database') }} as table_name, column_name, data_type, {{ bool_to_string('is_nullable') }}, computed_on + from {{ ref('f2dq_columns') }} + ) + + ( + select + {{ overview_select_base('metric', 'computed_on')}} + {{ to_single_json(['metric', 'value', 'time_window_end', 'interval_length_sec']) }} as {{ f2dq.quote_column('data') }} + from + {{ ref('f2dq_base_metrics') }} + where {{ in_date_window('time_window_end', start_date, end_date) }} + and interval_length_sec = {{interval_length_sec}} + ) union all + ( + select + {{ overview_select_base('anomaly', 'computed_on')}} + {{ to_single_json(['id', 'metric', 'z_score_value', 'last_value', 'last_avg', 'last_stddev', 'time_window_end', 'interval_length_sec']) }} as {{ f2dq.quote_column('data') }} + from + {{ ref('f2dq_anomalies') }} + where {{ in_date_window('time_window_end', start_date, end_date) }} + and interval_length_sec = {{interval_length_sec}} + ) union all + ( + select + {{ overview_select_base('schema_change', 'detected_time')}} + {{ to_single_json(['id', 'operation', 'data_type', 'is_nullable', 'prev_column_name', 'prev_data_type', 'prev_is_nullable', 'detected_time']) }} as {{ f2dq.quote_column('data') }} + from + schema_changes_casted + where {{ in_date_window('detected_time', start_date, none) }} + ) union all + ( + select + {{ overview_select_base('schema', 'computed_on')}} + {{ to_single_json(['data_type', 'is_nullable']) }} as {{ f2dq.quote_column('data') }} + from + columns_casted + ) + union all + ( + select + 'alert' as {{ f2dq.quote_column('type') }}, + model as {{ f2dq.quote_column('table_name') }}, + null as {{ f2dq.quote_column('column_name') }}, + time_window_end as {{ f2dq.quote_column('computed_on') }}, + {{ to_single_json(['type', 'model', 'message', 'value', 'time_window_end']) }} as {{ f2dq.quote_column('data') }} + from + {{ ref('f2dq_alerts') }} + where + case + when type = 'anomaly' then {{ in_date_window('time_window_end', start_date, end_date) }} + else {{ in_date_window('time_window_end', start_date, none) }} + end + ) + order by {{ f2dq.quote_column('computed_on')}} desc + {% endset %} + + {% set overview_result = run_query(overview_query) %} + {% set overview_file_path = overview_path or '../target/f2dq/overview.json' %} + {% do overview_result.to_json(overview_file_path) %} + {{ save_monitored(monitored_path) }} + +{% endmacro %} diff --git a/macros/public/validating/regex_dict.sql b/macros/public/validating/regex_dict.sql new file mode 100644 index 0000000..29fc42a --- /dev/null +++ b/macros/public/validating/regex_dict.sql @@ -0,0 +1,65 @@ +{# +# This file contains significant part of code licensed under: +# Copyright 2020 Soda +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +#} + +{% macro get_regex_for(to_validate) %} + + {% set regexp_dict = { + 'number_whole': '^\-?[0-9]+$', + 'number_decimal_point': '^\-?[0-9]+\.[0-9]+$', + 'number_decimal_comma': '^\-?[0-9]+,[0-9]+$', + 'number_percentage': '^\-?[0-9]+([\.,][0-9]+)? ?%$', + 'number_percentage_point': '^\-?[0-9]+([\.][0-9]+)? ?%$', + 'number_percentage_comma': '^\-?[0-9]+([,][0-9]+)? ?%$', + 'date_eu': '^([1-9]|0[1-9]|[12][0-9]|3[01])[-\./]([1-9]|0[1-9]|1[012])[-\./](19|20)?[0-9][0-9]$', + 'date_us': '^([1-9]|0[1-9]|1[012])[-\./]([1-9]|0[1-9]|[12][0-9]|3[01])[-\./](19|20)?[0-9][0-9]$', + 'date_inverse': '^(19|20)[0-9][0-9][-\./]?([1-9]|0[1-9]|1[012])[-\./]?([1-9]|0[1-9]|[12][0-9]|3[01])$', + 'time_24h': '^([01][0-9]|2[0-3]):([0-5][0-9])$', + 'time_12h': '^(1[0-2]|0?[1-9]):[0-5][0-9]$', + 'time': '^([0-9]|1[0-9]|2[0-4])[:-]([0-9]|[0-5][0-9])([:-]([0-9]|[0-5][0-9])(,[0-9]+)?)?$', + 'date_iso_8601': + '^' + '([1-9][0-9]{3}-((0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-8])|(0[13-9]|1[0-2])-(29|30)|(0[13578]|1[02])-31)|' + '([1-9][0-9](0[48]|[2468][048]|[13579][26])|([2468][048]|[13579][26])00)-02-29)' + + 'T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?' + + '(Z|[+-][01][0-9]:[0-5][0-9])?' + '$', + 'uuid': '^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$', + 'ipv4_address': '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$', + 'ipv6_address': '^((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}))|:)))(%.+)?$', + 'email': '^[A-Za-z0-9.-_%]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$', + } %} + + {% set base_regex = regexp_dict[to_validate] %} + {% set qualifed_regex = adapter.dispatch('get_regex_for', 'f2dq')(base_regex) %} + {{ return(qualifed_regex) }} + +{% endmacro %} + +{% macro default__get_regex_for(pattern) %} + {{ return (pattern) }} +{% endmacro %} + +{% macro redshift__get_regex_for(pattern) %} + {% set changed = modules.re.sub('\.', '\\.', pattern) %} + {% set changed = modules.re.sub('\-', '\\-', changed) %} + {{ return (changed) }} +{% endmacro %} + +{% macro snowflake__get_regex_for(pattern) %} + {% set changed = modules.re.sub('\.', '\\.', pattern) %} + {% set changed = modules.re.sub('\-', '\\-', changed) %} + {{ return (changed) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/public/validating/valid_with_regex.sql b/macros/public/validating/valid_with_regex.sql new file mode 100644 index 0000000..acd3148 --- /dev/null +++ b/macros/public/validating/valid_with_regex.sql @@ -0,0 +1,93 @@ + + +{% macro valid_regex(column_name, to_validate) %} +{% set pattern = f2dq.get_regex_for(to_validate) %} + case when + {{ column_name }} is null then false + else {{ f2dq.regex_match_expression(column_name, pattern) }} + end +{% endmacro %} + +{% macro valid_email(column_name) %} + {{ f2dq.valid_regex(column_name, 'email')}} +{% endmacro %} + +{% macro valid_date_eu(column_name) %} + {{ f2dq.valid_regex(column_name, 'date_eu')}} +{% endmacro %} + +{% macro valid_date_us(column_name) %} + {{ f2dq.valid_regex(column_name, 'date_us')}} +{% endmacro %} + +{% macro valid_date_inverse(column_name) %} + {{ f2dq.valid_regex(column_name, 'date_inverse')}} +{% endmacro %} + +{% macro valid_date_iso_8601(column_name) %} + {{ f2dq.valid_regex(column_name, 'date_iso_8601')}} +{% endmacro %} + +{% macro valid_time_24h(column_name) %} + {{ f2dq.valid_regex(column_name, 'time_24h')}} +{% endmacro %} + +{% macro valid_time_12h(column_name) %} + {{ f2dq.valid_regex(column_name, 'time_12h')}} +{% endmacro %} + +{% macro valid_time(column_name) %} + {{ f2dq.valid_regex(column_name, 'time')}} +{% endmacro %} + +{% macro valid_ip_v4(column_name) %} + {{ f2dq.valid_regex(column_name, 'ipv4_address')}} +{% endmacro %} + +{% macro valid_ip_v6(column_name) %} + {{ f2dq.valid_regex(column_name, 'ipv6_address')}} +{% endmacro %} + +{% macro valid_ip(column_name) %} + ( + {{ f2dq.valid_regex(column_name, 'ipv4_address')}} + or + {{ f2dq.valid_regex(column_name, 'ipv6_address')}} + ) +{% endmacro %} + +{% macro valid_number(column_name) %} + {{ f2dq.valid_regex(column_name, 'number_whole')}} +{% endmacro %} + +{% macro valid_number_decimal_point(column_name) %} + {{ f2dq.valid_regex(column_name, 'number_decimal_point')}} +{% endmacro %} + +{% macro valid_number_decimal_comma(column_name) %} + {{ f2dq.valid_regex(column_name, 'number_decimal_comma')}} +{% endmacro %} + +{% macro valid_number_percentage(column_name) %} + {{ f2dq.valid_regex(column_name, 'number_percentage')}} +{% endmacro %} + +{% macro valid_number_percentage_point(column_name) %} + {{ f2dq.valid_regex(column_name, 'number_percentage_point')}} +{% endmacro %} + +{% macro valid_number_percentage_comma(column_name) %} + {{ f2dq.valid_regex(column_name, 'number_percentage_comma')}} +{% endmacro %} + +{% macro valid_phone(column_name) %} + {{ f2dq.valid_regex(column_name, 'phone')}} +{% endmacro %} + +{% macro valid_uuid(column_name) %} + {{ f2dq.valid_regex(column_name, 'uuid')}} +{% endmacro %} + +{% macro valid_credit_card(column_name) %} + {{ f2dq.valid_regex(column_name, 'credit_card_number')}} +{% endmacro %} \ No newline at end of file diff --git a/macros/run_end/save_results_history.sql b/macros/run_end/save_results_history.sql new file mode 100644 index 0000000..d0de079 --- /dev/null +++ b/macros/run_end/save_results_history.sql @@ -0,0 +1,112 @@ + +{% macro save_test_history(results) -%} + +{{ adapter.dispatch('save_test_history_impl', 'f2dq') (results) }} + +{%- endmacro %} + +{% macro default__save_test_history_impl(results) %} + -- depends_on: {{ ref('f2dq_test_history') }} + {% set command = flags.WHICH %} + {% if execute and results and command in ('test', 'build') %} + {% set tests = [] %} + {% for el in results %} + {% if el.node.resource_type.value == 'test' %} + {% do tests.append(f2dq.test_data_dict(el)) %} + {% endif %} + {% endfor %} + + {% if tests %} + {% do f2dq.insert_list_to_table( + ref('f2dq_test_history'), + tests, + ['table_name', 'column_name', 'test_name', 'status', 'execution_time', 'message', 'failures_count', 'failures_json', 'failures_table', 'severity', 'compiled_sql', 'run_at'], + { 'run_at': timestamp_type() } + ) %} + {% endif %} + + {% endif %} + {{ return ('') }} + +{% endmacro %} + +{% macro test_data_dict(el) %} + + {% set run_started_at_str = run_started_at.strftime('%Y-%m-%d %H:%M:%S') %} + + {% if el.node.to_dict().get('test_metadata') %} + {% set any_refs = modules.re.findall("ref\(\'(?P.*)\'\)", el.node.test_metadata.kwargs['model']) %} + {% set any_source = modules.re.findall("source\(\'(?P.*)\'\,\s+\'(?P.*)\'\)", el.node.test_metadata.kwargs['model']) %} + + {% if any_refs %} + {% set name = any_refs[0] %} + {% set node_name = f2dq.priv_full_name_from_depends(el.node, name) %} + {% set schema = graph.nodes.get(node_name)['schema'] %} + {% set database = graph.nodes.get(node_name)['database'] %} + {% set table_name = (database + '.' + schema + '.' + name) | lower %} + + {% elif any_source %} + {% set package_name = any_source[0][0] %} + {% set name = any_source[0][1] %} + {% set node_name = f2dq.priv_full_name_from_depends(el.node, name) %} + {% set schema = graph.sources.get(node_name)['schema'] %} + {% set database = graph.sources.get(node_name)['database'] %} + {% set table_name = (database + '.' + schema + '.' + name) | lower %} + {% else %} + {% set table_name = none %} + {% endif %} + {% else %} + {% set table_name = none %} + {% endif %} + + {% if var.has_var('f2dq:query_test_failures') %} + {% set query_failures = var('f2dq:query_test_failures') %} + {% else %} + {% set query_failures = true %} + {% endif %} + + {% if el.failures and el.failures > 0 and el.node.relation_name and query_failures %} + {% if var.has_var('f2dq:test_history_failures_limit') %} + {% set limit_count = var('f2dq:test_history_failures_limit')%} + {% else %} + {% set limit_count = 10 %} + {% endif %} + + {% set failures_query %} + select * from {{ el.node.relation_name}} limit {{ limit_count }} + {% endset %} + {% set failures_list = f2dq.agate_to_list(run_query(failures_query)) %} + {% endif %} + + {% set failures_json = none %} + + {{ return ({ + 'table_name': table_name, + 'column_name': el.node.column_name or none, + 'test_name': el.node.name, + 'status': el.status.name, + 'execution_time': el.execution_time, + 'message': el.message, + 'failures_count': el.failures, + 'failures_json': '' ~ failures_list, + 'failures_table': el.node.relation_name or none, + 'severity': el.node.config.severity, + 'compiled_sql': el.node.compiled_sql or el.node.compiled_code or none, + 'run_at': run_started_at_str, + }) + }} + +{% endmacro %} + +{% macro priv_full_name_from_depends(node, name) %} + + {% for full_name in node.depends_on.nodes %} + {% set node_name = full_name.split('.')[-1] %} + {% if node_name == name %} + {{ return(full_name) }} + {% endif %} + {% endfor %} + + {{ return(none) }} + +{% endmacro %} diff --git a/macros/samples/internal_model_template.sql b/macros/samples/internal_model_template.sql new file mode 100644 index 0000000..bfa32e6 --- /dev/null +++ b/macros/samples/internal_model_template.sql @@ -0,0 +1,54 @@ +{%- macro order_by_if_time_filter(time_filter) -%} + {%- if time_filter is not none -%} + order by {{ time_filter }} desc + {%- endif -%} +{%- endmacro -%} + + +{% macro f2dq_last_table_samples() %} + {{ f2dq.generate_depends(['f2dq_selected', 'f2dq_monitored', 'f2dq_columns', 'f2dq_run_started_at', 'f2dq_last_table_samples_part']) }} + + {{ + config( + materialized='table', + ) + }} + + {% set store_samples = f2dq.f2dq_var('store_table_samples', false) %} + {% if not f2dq.in_compile() and store_samples is sameas true %} + {%- set tables = run_query(f2dq.get_tables()) %} + + {% set samples_list = [] %} + {%- for sample_table in tables %} + + {% set model = get_model_config(sample_table) %} + {% set columns_to_sample = [] %} + {% for key, value in model.columns_info.items() | sort %} + {% if value.data_type in ['numeric', 'text'] %} + {% do columns_to_sample.append(key) %} + {% endif %} + {% endfor %} + + {% set samples_query %} + select {{ print_list(columns_to_sample)}} from {{ model.table_name }} + {{ order_by_if_time_filter(model.time_filter) }} + limit 10 + {% endset %} + + {% set samples = f2dq.agate_to_list(run_query(samples_query)) %} + {% do samples_list.append({ + 'table_name': model.model_name, + 'sample_data': samples, + }) %} + + {% endfor %} + {% do f2dq.insert_list_to_table( + ref('f2dq_last_table_samples_part'), + samples_list, + ['table_name', 'sample_data'] + ) %} + {% endif %} + + {{ f2dq.empty_last_table_samples() }} + +{% endmacro %} \ No newline at end of file diff --git a/macros/store/insert_list_to_table.sql b/macros/store/insert_list_to_table.sql new file mode 100644 index 0000000..59ceb6b --- /dev/null +++ b/macros/store/insert_list_to_table.sql @@ -0,0 +1,42 @@ +{% macro insert_list_to_table(table, list, params, dtype=None,insert_size=100) %} + + {% set single_insert_list = [] %} + {% for el in list %} + {% do single_insert_list.append(el) %} + {% set single_insert_list_size = single_insert_list | length %} + {% if single_insert_list_size == insert_size or loop.last %} + + {% set insert_query %} + insert into {{ table }} ({%- for p in params %}{{p}}{% if not loop.last %}, {% endif %}{% endfor %}) values + {%- for row in single_insert_list -%} + ( + {%- for p in params -%} + {%- if row[p] is none -%} + NULL + {%- else -%} + {%- if row[p] is string -%} + {%- if dtype and p in dtype -%} + {% set cast_type = dtype[p] %} + cast ({{ f2dq.quote_string(row[p]) }} as {{ cast_type }}) + {%- else %} + {{- f2dq.quote_string(row[p]) -}} + {%- endif -%} + {%- elif row[p] is number -%} + {{-row[p]-}} + {%- else -%} + {{- f2dq.quote_string(tojson(row[p])) -}} + {%- endif -%} + {%- endif -%} + {%- if not loop.last -%},{%- endif -%} + {%- endfor -%} + ) + {%- if not loop.last -%},{%- endif %} + {% endfor -%} + {% endset %} + + {% do run_query(insert_query) %} + {% do single_insert_list.clear() %} + {% endif %} + {% endfor %} + +{% endmacro %} diff --git a/macros/tests/test_metrics.sql b/macros/tests/test_metrics.sql new file mode 100644 index 0000000..45cf3d2 --- /dev/null +++ b/macros/tests/test_metrics.sql @@ -0,0 +1,106 @@ + +{% macro metric_expression(table, metric, expression, column_name=None, condition=None) %} + select * from {{ref('f2dq_base_metrics')}} + where + table_name = '{{ f2dq.full_table_name_values(table.identifier, table.schema, table.database)}}' and + metric = '{{ metric }}' and + {% if condition is not none %} + {{ condition }} and + {% endif %} + {% if column_name is none %} + not ( {{ expression }} ) + {% else %} + column_name = '{{ column_name }}' and + not ( {{ expression }} ) + {% endif %} + +{% endmacro %} + +{# old test macros, will be removed after some time #} +{% test metric_expression_is_true(model, table, metric, expression, column_name=None, condition=None) %} + {{ f2dq.metric_expression(table, metric, expression, column_name=None, condition=None) }} +{% endtest %} + + +{% test metric_equal_to(model, table, metric, value, column_name=None, condition=None) %} + {{ f2dq.metric_expression(table, metric, 'value = ' ~ value, column_name, condition) }} +{% endtest %} + + +{% test metric_in_range(model, table, metric, min_value, max_value, column_name=None, condition=None) %} + {{ f2dq.metric_expression(table, metric, 'value >= ' ~ min_value ~ ' and value <= ' ~ max_value, column_name, condition) }} +{% endtest %} + +{# new test macros #} + +{% test assert_true(model, column_name=None, metric=None, expression=expression, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, expression, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_false(model, column_name=None, metric=None, expression=expression, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'not (' ~ expression ~ ')', column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_in_range(model, column_name=None, metric=None, min_value=None, max_value=None, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'value >= ' ~ min_value ~ ' and value <= ' ~ max_value, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_equal(model, column_name=None, metric=None, value=value, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'value = ' ~ value, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_greater(model, column_name=None, metric=None, value=None, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'value > ' ~ value, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_greater_equal(model, column_name=None, metric=None, value=None, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'value >= ' ~ value, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_less(model, column_name=None, metric=None, value=None, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'value < ' ~ value, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} + +{% test assert_less_equal(model, column_name=None, metric=None, value=None, condition=None) %} + -- depends_on: {{ ref('f2dq_base_metrics') }} + {% if execute %} + {{ f2dq.metric_expression(model, metric, 'value <= ' ~ value, column_name, condition) }} + {% else %} + {{ f2dq.empty_table() }} + {% endif %} +{% endtest %} \ No newline at end of file diff --git a/macros/utils/agate/row_value.sql b/macros/utils/agate/row_value.sql new file mode 100644 index 0000000..b8a7797 --- /dev/null +++ b/macros/utils/agate/row_value.sql @@ -0,0 +1,20 @@ + +{% macro row_value(agate_row, column) %} + {{ return (agate_row[f2dq.name_in_db(column)]) }} +{% endmacro %} + +{% macro agate_to_list(table) %} + {% set col_names = table.column_names %} + {% set query_result = [] %} + {% for row in table.rows %} + {% set pairs = [] %} + {% for col_name in col_names %} + {% set value = row.get(col_name) | string %} + {% do pairs.append('"' ~ (col_name | lower) ~ '":' ~ '"' ~ (value | replace('"', '\\\"') | replace('\n', '\\n') ) ~ '"') %} + {% endfor %} + {% set joined_pairs = '{' ~ (pairs | join(',')) ~ '}' %} + {% do query_result.append(joined_pairs) %} + {% endfor %} + {% set query_result = '[' ~ (query_result | join(',')) ~ ']' %} + {{ return (query_result) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/bool_to_string.sql b/macros/utils/bool_to_string.sql new file mode 100644 index 0000000..897bf4a --- /dev/null +++ b/macros/utils/bool_to_string.sql @@ -0,0 +1,8 @@ + +{% macro bool_to_string(column) %} + ( + case when {{ column }} = true then 'true' + when {{ column }} = false then 'false' + end + ) as {{ column }} +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/column_types.sql b/macros/utils/column_types.sql new file mode 100644 index 0000000..d7cba96 --- /dev/null +++ b/macros/utils/column_types.sql @@ -0,0 +1,102 @@ + +{% macro get_column_type(column) %} + {% set result = adapter.dispatch('get_column_type', 'f2dq')(column) %} + {{ return(result) }} +{% endmacro %} + + +{% macro default__get_column_type(column) %} + + {% if column.data_type in [ + 'character varying', + 'varchar', + 'character', + 'char', + 'text' + ] %} + {{ return('text') }} + + {% elif column.data_type in [ + 'smallint', + 'integer', + 'bigint', + 'decimal', + 'numeric', + 'real', + 'double precision', + 'enum', + ] %} + {{ return('numeric') }} + + {% elif column.data_type in [ 'boolean', 'bool' ] %} + + {{ return('boolean') }} + + {% else %} + {{ return('unknown') }} + + {% endif %} + +{% endmacro %} + + +{% macro snowflake__get_column_type(column) %} + + {% if column.DATA_TYPE in [ + 'VARCHAR', + 'CHAR', + 'CHARACTER', + 'STRING', + 'TEXT' + ] %} + + {{ return('text') }} + + {% elif column.DATA_TYPE in [ + 'NUMBER', + 'DECIMAL', + 'NUMERIC', + 'INT', + 'INTEGER', + 'BIGINT', + 'SMALLINT', + 'TINYINT', + 'BYTEINT', + 'FLOAT', + 'FLOAT4', + 'FLOAT8', + 'DOUBLE', + 'DOUBLE PRECISION', + 'REAL', + ] %} + + {{ return('numeric') }} + + {% elif column.data_type in [ 'BOOLEAN' ] %} + + {{ return('boolean') }} + + {% else %} + + {{ return('unknown') }} + + {% endif %} + +{% endmacro %} + + +{% macro bigquery__get_column_type(column) %} + + {% if column.data_type in [ 'STRING' ] %} + {{ return('text') }} + + {% elif column.data_type in [ "INT64", "NUMERIC", "BIGNUMERIC", "FLOAT64", "INTEGER"] %} + {{ return('numeric') }} + + {% elif column.data_type in [ "BOOLEAN", "BOOL"] %} + {{ return('boolean') }} + {% else %} + {{ return('unknown') }} + + {% endif %} +{% endmacro %} diff --git a/macros/utils/comma_delimited_list.sql b/macros/utils/comma_delimited_list.sql new file mode 100644 index 0000000..fcabb3a --- /dev/null +++ b/macros/utils/comma_delimited_list.sql @@ -0,0 +1,5 @@ +{% macro comma_delimited_list(args) %} + {%- for arg in args %} + {{- arg -}} {{- ", " if not loop.last else "" -}} + {% endfor %} +{% endmacro %} diff --git a/macros/utils/comparison_text.sql b/macros/utils/comparison_text.sql new file mode 100644 index 0000000..eeb767e --- /dev/null +++ b/macros/utils/comparison_text.sql @@ -0,0 +1,5 @@ +{% macro comparison_text(a, b) %} + case when {{a}} > {{b}} then 'greater than' + when {{a}} = {{b}} then 'equal to' + else 'less than' end +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/compat_dual_read.sql b/macros/utils/compat_dual_read.sql new file mode 100644 index 0000000..ecf1f9b --- /dev/null +++ b/macros/utils/compat_dual_read.sql @@ -0,0 +1,58 @@ +{# Dual-read helpers for migrating from re_data → f2dq without breaking + projects that still use re_data_* config keys or re_data:* vars. #} + +{% macro f2dq_var(name, default=none) %} + {# Prefer f2dq:; fall back to re_data:; then default. + For booleans, either namespace set to true wins (so a project that + still sets re_data:store_table_samples: true keeps working even + though the package default for f2dq:store_table_samples is false). #} + {% set fkey = 'f2dq:' ~ name %} + {% set rkey = 're_data:' ~ name %} + {% set fval = var(fkey) if var.has_var(fkey) else none %} + {% set rval = var(rkey) if var.has_var(rkey) else none %} + {% if fval is sameas true or rval is sameas true %} + {{ return(true) }} + {% elif fval is sameas false and rval is sameas false %} + {{ return(false) }} + {% elif fval is not none and fval is not sameas false %} + {{ return(fval) }} + {% elif rval is not none and rval is not sameas false %} + {{ return(rval) }} + {% elif fval is not none %} + {{ return(fval) }} + {% elif rval is not none %} + {{ return(rval) }} + {% else %} + {{ return(default) }} + {% endif %} +{% endmacro %} + +{% macro f2dq_node_config(el, key, default=none) %} + {# Read f2dq_ / re_data_ from node config, then config.meta / + node.meta (dbt 1.12 CustomKeyInConfigDeprecation migration path). + `key` is the suffix without package prefix, e.g. 'monitored', + 'time_filter', 'metrics_groups'. #} + {% set keys = ['f2dq_' ~ key, 're_data_' ~ key] %} + {% for k in keys %} + {% if el.config is mapping and el.config.get(k) is not none %} + {{ return(el.config.get(k)) }} + {% endif %} + {% endfor %} + {% set conf_meta = el.config.get('meta', {}) if el.config is mapping else {} %} + {% if conf_meta is mapping %} + {% for k in keys %} + {% if conf_meta.get(k) is not none %} + {{ return(conf_meta.get(k)) }} + {% endif %} + {% endfor %} + {% endif %} + {% set node_meta = el.get('meta', {}) if el is mapping else {} %} + {% if node_meta is mapping %} + {% for k in keys %} + {% if node_meta.get(k) is not none %} + {{ return(node_meta.get(k)) }} + {% endif %} + {% endfor %} + {% endif %} + {{ return(default) }} +{% endmacro %} diff --git a/macros/utils/deduplication/add_duplication_context.sql b/macros/utils/deduplication/add_duplication_context.sql new file mode 100644 index 0000000..3f1482d --- /dev/null +++ b/macros/utils/deduplication/add_duplication_context.sql @@ -0,0 +1,13 @@ +{% macro add_duplication_context(relation, unique_cols, sort_columns) %} + + select {{ dbt_utils.star(from=relation) }} + , count(*) over ( + partition by {{ f2dq.comma_delimited_list(unique_cols) }} + ) as f2dq_duplicate_group_row_count + , row_number() over ( + partition by {{ f2dq.comma_delimited_list(unique_cols) }} {% if sort_columns %} order by {{ f2dq.comma_delimited_list(sort_columns) }} {% endif %} + ) as f2dq_duplicate_group_row_number + + from {{ relation }} + +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/depends_macro.sql b/macros/utils/depends_macro.sql new file mode 100644 index 0000000..95cd988 --- /dev/null +++ b/macros/utils/depends_macro.sql @@ -0,0 +1,6 @@ +{% macro generate_depends(used_tables) %} + {% for t in used_tables %} + -- depends_on: {{ ref(t) }} + {% endfor %} + +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/dict_from_list.sql b/macros/utils/dict_from_list.sql new file mode 100644 index 0000000..a52e7b9 --- /dev/null +++ b/macros/utils/dict_from_list.sql @@ -0,0 +1,14 @@ + +{% macro dict_from_list(el_list) %} + + {% if el_list is none %} + {{ return (none) }} + {% endif %} + + {% set for_cols_dict = {} %} + {% for col in el_list %} + {% do for_cols_dict.update({col: True})%} + {% endfor %} + {% do return(for_cols_dict) %} + +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/fivetran_utils/json_extract.sql b/macros/utils/fivetran_utils/json_extract.sql new file mode 100644 index 0000000..8fc6002 --- /dev/null +++ b/macros/utils/fivetran_utils/json_extract.sql @@ -0,0 +1,40 @@ +{# +# This file contains significant part of code derived from +# https://github.com/fivetran/dbt_fivetran_utils/tree/v0.4.0 which is licensed under Apache License 2.0. +#} + +{% macro json_extract(string, string_path) -%} + +{{ adapter.dispatch('json_extract','f2dq') (string, string_path) }} + +{%- endmacro %} + +{% macro default__json_extract(string, string_path) %} + + json_extract_path_text({{string}}, {{ "'" ~ string_path ~ "'" }} ) + +{% endmacro %} + +{% macro snowflake__json_extract(string, string_path) %} + + json_extract_path_text(try_parse_json( {{string}} ), {{ "'" ~ string_path ~ "'" }} ) + +{% endmacro %} + +{% macro redshift__json_extract(string, string_path) %} + + case when is_valid_json( {{string}} ) then json_extract_path_text({{string}}, {{ "'" ~ string_path ~ "'" }} ) else null end + +{% endmacro %} + +{% macro bigquery__json_extract(string, string_path) %} + + json_extract_scalar({{string}}, {{ "'$." ~ string_path ~ "'" }} ) + +{% endmacro %} + +{% macro postgres__json_extract(string, string_path) %} + + {{string}}::json->>{{"'" ~ string_path ~ "'" }} + +{% endmacro %} diff --git a/macros/utils/fivetran_utils/percentile.sql b/macros/utils/fivetran_utils/percentile.sql new file mode 100644 index 0000000..7fca13f --- /dev/null +++ b/macros/utils/fivetran_utils/percentile.sql @@ -0,0 +1,60 @@ +{# +# This file contains significant part of code derived from +# https://github.com/fivetran/dbt_fivetran_utils/tree/v0.4.0 which is licensed under Apache License 2.0. +#} + +{% macro percentile(percentile_field, partition_field, percent) -%} + +{{ adapter.dispatch('percentile','f2dq') (percentile_field, partition_field, percent) }} + +{%- endmacro %} + +--percentile calculation specific to Redshift +{% macro default__percentile(percentile_field, partition_field, percent) %} + + percentile_cont( + {{ percent }} ) + within group ( order by {{ percentile_field }} ) + over ( partition by {{ partition_field }} ) + +{% endmacro %} + +--percentile calculation specific to Redshift +{% macro redshift__percentile(percentile_field, partition_field, percent) %} + + percentile_cont( + {{ percent }} ) + within group ( order by {{ percentile_field }} ) + over ( partition by {{ partition_field }} ) + +{% endmacro %} + +--percentile calculation specific to BigQuery +{% macro bigquery__percentile(percentile_field, partition_field, percent) %} + + percentile_cont( + {{ percentile_field }}, + {{ percent }}) + over (partition by {{ partition_field }} + ) + +{% endmacro %} + +{% macro postgres__percentile(percentile_field, partition_field, percent) %} + + percentile_cont( + {{ percent }} ) + within group ( order by {{ percentile_field }} ) + /* have to group by partition field */ + +{% endmacro %} + +{% macro spark__percentile(percentile_field, partition_field, percent) %} + + percentile( + {{ percentile_field }}, + {{ percent }}) + over (partition by {{ partition_field }} + ) + +{% endmacro %} diff --git a/macros/utils/for_loops.sql b/macros/utils/for_loops.sql new file mode 100644 index 0000000..719271c --- /dev/null +++ b/macros/utils/for_loops.sql @@ -0,0 +1,4 @@ + +{% macro print_list(l) %} + {% for el in l %}{{el}}{% if not loop.last %},{% endif %}{% endfor %} +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/formulas.sql b/macros/utils/formulas.sql new file mode 100644 index 0000000..20a14bd --- /dev/null +++ b/macros/utils/formulas.sql @@ -0,0 +1,10 @@ +{% macro percentage_formula(summation, total) %} + abs( + ( + cast({{ summation }} as {{ numeric_type() }}) + ) / + nullif( + cast( {{ total }} as {{ numeric_type() }} ) + , 0) * 100.0 + ) +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/generate_alert_message.sql b/macros/utils/generate_alert_message.sql new file mode 100644 index 0000000..5f1cce4 --- /dev/null +++ b/macros/utils/generate_alert_message.sql @@ -0,0 +1,65 @@ +{% macro generate_anomaly_message(column_name, metric, last_value, last_avg) -%} + {{ return(adapter.dispatch('generate_anomaly_message', 'f2dq')(column_name, metric, last_value, last_avg)) }} +{%- endmacro %} + +{% macro default__generate_anomaly_message(column_name, metric, last_value, last_avg) %} + + case when {{ column_name }} != '' then metric || '(' || column_name || ')' + else metric + end + || ' is ' || + {{ to_2dp( percentage_formula('last_value - last_avg', last_avg) ) }} + || '% ' || + {{ comparison_text(last_value, last_avg) }} + || ' average.' +{% endmacro %} + +{% macro to_2dp(val) %} + {{ adapter.dispatch('to_2dp', 'f2dq')(val) }} +{% endmacro %} + +{% macro default__to_2dp(val) %} + trim(to_char({{ val }}, '9999999999999999990D00')) +{% endmacro %} + +{% macro bigquery__to_2dp(val) %} + format('%.2f', {{ val }}) +{% endmacro %} + +{% macro seconds_to_hours(val) %} + cast({{ val }} as {{ numeric_type() }}) / 3600 +{% endmacro %} + +{% macro generate_metric_value_text(metric, value) %} + case + when {{ metric }} = 'freshness' + then cast({{ to_2dp(seconds_to_hours(value)) }} as {{ string_type() }}) || ' hours' + when {{ regex_match_expression(metric, 'percent') }} + then cast({{ to_2dp(value) }} as {{ string_type() }}) || '%' + when {{ regex_match_expression(metric, 'count') }} + then cast({{ value }} as {{ string_type() }}) + else cast({{ to_2dp(value) }} as {{ string_type() }}) + end + +{% endmacro %} + +{% macro generate_schema_change_message(operation, column_name, prev_column_name, prev_data_type, data_type, detected_time) %} + case + when {{ operation }} = 'column_added' + then 'column ' || {{ column_name }} || ' of type ' || {{ data_type }} || ' was added.' + when {{ operation }} = 'column_removed' + then 'column ' || {{ prev_column_name }} || ' of type ' || {{ prev_data_type }} || ' was removed.' + when {{ operation }} = 'type_change' + then {{ column_name }} || ' column data type was changed from ' || {{ prev_data_type }} || ' to ' || {{ data_type }} || '.' + else '' + end +{% endmacro %} + +{% macro generate_failed_test_message(test_name, column_name) %} + case + when {{ column_name }} is null + then 'Test ' || {{ test_name }} || ' failed.' + else + 'Test ' || {{ test_name }} || ' failed for column ' || {{ column_name }} || '.' + end +{% endmacro %} diff --git a/macros/utils/get_database.sql b/macros/utils/get_database.sql new file mode 100644 index 0000000..e4a89a6 --- /dev/null +++ b/macros/utils/get_database.sql @@ -0,0 +1,16 @@ + +{% macro get_target_database() %} + {{- adapter.dispatch('get_target_database', 'f2dq')() -}} +{% endmacro %} + +{% macro default__get_target_database() %} + {{- return (target.dbname) -}} +{% endmacro %} + +{% macro bigquery__get_target_database() %} + {{- return (target.project) -}} +{% endmacro %} + +{% macro snowflake__get_target_database() %} + {{- return (target.database) -}} +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/in_compile.sql b/macros/utils/in_compile.sql new file mode 100644 index 0000000..7caf6b6 --- /dev/null +++ b/macros/utils/in_compile.sql @@ -0,0 +1,16 @@ +{% macro in_compile() %} + + {%- call statement('in_compile', fetch_result=True) -%} + select * from {{ ref('f2dq_run_started_at') }} + {%- endcall -%} + + {% if execute %} + {%- set result = load_result('in_compile')['data'][0][0] -%} + {% if result == run_started_at.timestamp() * 1000000 %} + {{ return(False) }} + {% else %} + {{ return(True) }} + {% endif %} + {% endif %} + +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/is_list.sql b/macros/utils/is_list.sql new file mode 100644 index 0000000..c1d0732 --- /dev/null +++ b/macros/utils/is_list.sql @@ -0,0 +1,7 @@ +{% macro is_list(obj) %} + {% if not obj %} + {{ return (False) }} + {% endif %} + {% set check = obj is iterable and (obj is not string and obj is not mapping) %} + {{ return (check) }} +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/json/to_single_json.sql b/macros/utils/json/to_single_json.sql new file mode 100644 index 0000000..3a0a079 --- /dev/null +++ b/macros/utils/json/to_single_json.sql @@ -0,0 +1,22 @@ +{% macro to_json_string_value_or_null(column) %} + ( + case + when {{ column }} is null then 'null' + else '"' || + regexp_replace( + replace(cast({{ column }} as {{ string_type() }}), '"', {{ escape_seq_for_json('"') }}), + '\n', {{ quote_new_line() }} {% if target.type in postgres_type_db() %}, 'g' {% endif %} + ) || '"' + end + ) +{% endmacro %} + +{% macro to_single_json(columns) %} + '{' || + {%- for column in columns %} + '"{{ column }}": ' || + {{ to_json_string_value_or_null(column) }} + {%- if not loop.last %} || ',' || {%- endif %} + {%- endfor %} + || '}' +{% endmacro %} diff --git a/macros/utils/mock/empty_tables.sql b/macros/utils/mock/empty_tables.sql new file mode 100644 index 0000000..4c35a50 --- /dev/null +++ b/macros/utils/mock/empty_tables.sql @@ -0,0 +1,67 @@ + +{% macro all_types_select() %} + with types_table as ( + select + cast (null as {{ string_type() }}) as string_type, + cast (null as {{ long_string_type() }}) as long_string_type, + cast (1 as {{ numeric_type() }}) as numeric_type, + cast ('2000-01-10' as {{ timestamp_type() }}) as timestamp_type, + cast (true as {{ boolean_type() }}) as boolean_type + ) +{% endmacro %} + +{% macro empty_table_generic(list) %} + {{ f2dq.all_types_select() }} + select + {% for name, type in list %} + {{ type }}_type as {{ name }} + {%- if not loop.last %}, {%- endif %} + {% endfor %} + from types_table + where string_type is not null +{% endmacro %} + +{% macro empty_last_base_metrics() %} + {{ + f2dq.empty_table_generic([ + ('table_name', 'string'), + ('column_name', 'string'), + ('metric', 'string'), + ('value', 'numeric') + ]) + }} +{% endmacro %} + +{% macro empty_last_table_samples() %} + {{ + f2dq.empty_table_generic([ + ('table_name', 'string'), + ('sample_data', 'string') + ]) + }} +{% endmacro %} + +{% macro empty_columns_table() %} + {{ + f2dq.empty_table_generic([ + ('name', 'string'), + ('schema', 'string'), + ('database', 'string'), + ('column_name', 'string'), + ('data_type', 'string'), + ('is_nullable', 'boolean'), + ('time_filter', 'string'), + ('computed_on', 'timestamp') + ]) + }} +{% endmacro %} + + +{% macro empty_table() %} + {{ + f2dq.empty_table_generic([ + ('name', 'string') + ]) + }} +{% endmacro %} + diff --git a/macros/utils/monitored_config.sql b/macros/utils/monitored_config.sql new file mode 100644 index 0000000..645b1d9 --- /dev/null +++ b/macros/utils/monitored_config.sql @@ -0,0 +1,38 @@ + + +{% macro columns_in_db(columns) %} + {% set translated = [] %} + {% if columns is none %} + {{ return (none) }} + {% endif %} + {% for col in columns %} + {% do translated.append(f2dq.name_in_db(col))%} + {% endfor %} + {{ return (translated) }} +{% endmacro %} + +{% macro metrics_in_db(metrics) %} + {% set translated = metrics %} + {% set column_metrics = {} %} + {% for col in metrics.column %} + {% do column_metrics.update({f2dq.name_in_db(col): metrics.column[col]}) %} + {% endfor %} + {% if column_metrics %} + {% do metrics.update({'column': column_metrics}) %} + {% endif %} + {{ return (metrics) }} +{% endmacro %} + +{% macro final_metrics(metrics_groups, additional_metrics) %} + {% set final_metrics_dict = dict([('group', {}), ('additional', {})]) %} + {% set all_metrics_groups = f2dq.f2dq_var('metrics_groups') %} + + {% for group in metrics_groups %} + {% set value = all_metrics_groups.get(group) %} + {% do final_metrics_dict['group'].update(value) %} + {% endfor %} + + {% do final_metrics_dict['additional'].update(additional_metrics) %} + {{ return (final_metrics_dict) }} + +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/quote.sql b/macros/utils/quote.sql new file mode 100644 index 0000000..2ad8f37 --- /dev/null +++ b/macros/utils/quote.sql @@ -0,0 +1,13 @@ + + +{% macro quote_column(col_name) %} + {{ adapter.dispatch('quote_column', 'f2dq')(col_name) }} +{% endmacro %} + +{% macro default__quote_column(col_name) %} + "{{ col_name }}" +{% endmacro %} + +{% macro bigquery__quote_column(col_name) %} + `{{ col_name }}` +{% endmacro %} \ No newline at end of file diff --git a/macros/utils/regular_expression.sql b/macros/utils/regular_expression.sql new file mode 100644 index 0000000..24be854 --- /dev/null +++ b/macros/utils/regular_expression.sql @@ -0,0 +1,15 @@ +{% macro regex_match_expression(column_name, pattern) %} + {{ adapter.dispatch('regex_match_expression', 'f2dq')(column_name, pattern) }} +{% endmacro %} + +{% macro default__regex_match_expression(column_name, pattern) %} + ({{column_name}} ~ '{{pattern}}') +{% endmacro %} + +{% macro bigquery__regex_match_expression(column_name, pattern) %} + regexp_contains({{column_name}}, r'{{pattern}}') +{% endmacro %} + +{% macro snowflake__regex_match_expression(column_name, pattern) %} + regexp_like({{column_name | upper}}, '{{pattern}}') +{% endmacro %} diff --git a/macros/utils/time_macros.sql b/macros/utils/time_macros.sql new file mode 100644 index 0000000..359fe8f --- /dev/null +++ b/macros/utils/time_macros.sql @@ -0,0 +1,100 @@ + +{% macro time_window_start() %} + cast('{{- f2dq.f2dq_var('time_window_start') -}}' as timestamp) +{% endmacro %} + + +{% macro time_window_end() %} + cast('{{- f2dq.f2dq_var('time_window_end') -}}' as timestamp) +{% endmacro %} + + +{% macro anamaly_detection_time_window_start() %} + {{ adapter.dispatch('anamaly_detection_time_window_start', 'f2dq')() }} +{% endmacro %} + +{% macro default__anamaly_detection_time_window_start() %} + {{ time_window_start() }} - interval '{{ f2dq.f2dq_var('anomaly_detection_look_back_days') }} days' +{% endmacro %} + +{% macro bigquery__anamaly_detection_time_window_start() %} + DATE_ADD({{ time_window_start() }}, INTERVAL -{{ f2dq.f2dq_var('anomaly_detection_look_back_days') }} DAY) +{% endmacro %} + +{% macro snowflake__anamaly_detection_time_window_start() %} + DATEADD('DAY', -{{- f2dq.f2dq_var('anomaly_detection_look_back_days') -}}, {{ time_window_start() }}) +{% endmacro %} + + +{% macro interval_length_sec(start_timestamp, end_timestamp) %} + {{ adapter.dispatch('interval_length_sec', 'f2dq')(start_timestamp, end_timestamp) }} +{% endmacro %} + +{% macro default__interval_length_sec(start_timestamp, end_timestamp) %} + EXTRACT(EPOCH FROM ({{ end_timestamp }} - {{ start_timestamp }} )) +{% endmacro %} + +{% macro bigquery__interval_length_sec(start_timestamp, end_timestamp) %} + TIMESTAMP_DIFF ({{ end_timestamp }}, {{ start_timestamp }}, SECOND) +{% endmacro %} + +{% macro snowflake__interval_length_sec(start_timestamp, end_timestamp) %} + timediff(second, {{ start_timestamp }}, {{ end_timestamp }}) +{% endmacro %} + +{% macro redshift__interval_length_sec(start_timestamp, end_timestamp) %} + DATEDIFF(second, {{ start_timestamp }}, {{ end_timestamp }}) +{% endmacro %} + +{%- macro in_time_window(time_column) %} + {# /* If not time_filter is specified, we compute the metric over the entire table else we filter for the time frame */ #} + {% if time_column is none %} + true + {% else %} + {{ adapter.dispatch('in_time_window', 'f2dq')(time_column) }} + {% endif %} +{% endmacro -%} + +{% macro default__in_time_window(time_column) %} + {{time_column}} >= {{ time_window_start() }} and + {{time_column}} < {{ time_window_end() }} +{% endmacro %} + +{% macro bigquery__in_time_window(time_column) %} + cast({{time_column}} as timestamp) >= {{ time_window_start() }} and + cast({{time_column}} as timestamp) < {{ time_window_end() }} +{% endmacro %} + + +{% macro format_timestamp(column_name) %} + {{ adapter.dispatch('format_timestamp', 'f2dq')(column_name) }} +{% endmacro %} + +{% macro default__format_timestamp(column_name) %} + to_char({{column_name}}, 'YYYY-MM-DD HH24:MI:SS') +{% endmacro %} + +{% macro bigquery__format_timestamp(column_name) %} + FORMAT_TIMESTAMP('%Y-%m-%d %H:%I:%S', {{column_name}}) +{% endmacro %} + +/* +provide a common way to compare time vs a range: start_date <= target <= end_date +if start_date is none: target <= end_date +if end_date is none: target >= start_date +think none as infinity +*/ +{%- macro in_date_window(target, start_date, end_date) %} + {{ adapter.dispatch('in_date_window','f2dq')(target, start_date, end_date) }} +{% endmacro -%} + +{% macro default__in_date_window(target, start_date, end_date) %} + {% if start_date is not none and end_date is not none %} + date({{target}}) between '{{start_date}}' and '{{end_date}}' + {% elif start_date is none %} + date({{target}}) <= '{{end_date}}' + {% elif end_date is none %} + date({{target}}) >= '{{start_date}}' + {% endif %} +{% endmacro %} + diff --git a/macros/utils/used_types.sql b/macros/utils/used_types.sql new file mode 100644 index 0000000..a5a6fa3 --- /dev/null +++ b/macros/utils/used_types.sql @@ -0,0 +1,100 @@ +{% macro timestamp_type() %} + {{ adapter.dispatch('timestamp_type', 'f2dq')() }} +{% endmacro %} + +{% macro default__timestamp_type() %} + timestamp without time zone +{% endmacro %} + +{% macro redshift__timestamp_type() %} + TIMESTAMP +{% endmacro %} + +{% macro bigquery__timestamp_type() %} + TIMESTAMP +{% endmacro %} + +{% macro snowflake__timestamp_type() %} + TIMESTAMP_NTZ +{% endmacro %} + +{% macro string_type() %} + {{ adapter.dispatch('string_type', 'f2dq')() }} +{% endmacro %} + +{% macro default__string_type() %} + text +{% endmacro %} + +{% macro redshift__string_type() %} + varchar(2047) +{% endmacro %} + +{% macro bigquery__string_type() %} + STRING +{% endmacro %} + +{% macro snowflake__string_type() %} + STRING +{% endmacro %} + +{% macro long_string_type() %} + {{ adapter.dispatch('long_string_type', 'f2dq')() }} +{% endmacro %} + +{% macro default__long_string_type() %} + {{ f2dq.string_type() }} +{% endmacro %} + +{% macro redshift__long_string_type() %} + varchar(65535) +{% endmacro %} + +{% macro integer_type() %} + INTEGER +{% endmacro %} + + +{% macro boolean_type() %} + {{ adapter.dispatch('boolean_type', 'f2dq')() }} +{% endmacro %} + +{% macro default__boolean_type() %} + BOOLEAN +{% endmacro %} + +{% macro redshift__boolean_type() %} + boolean +{% endmacro %} + +{% macro bigquery__boolean_type() %} + BOOLEAN +{% endmacro %} + +{% macro snowflake__boolean_type() %} + BOOLEAN +{% endmacro %} + + +{% macro numeric_type() %} + {{ adapter.dispatch('numeric_type', 'f2dq')() }} +{% endmacro %} + +{% macro default__numeric_type() %} + double precision +{% endmacro %} + +{% macro redshift__numeric_type() %} + DOUBLE PRECISION +{% endmacro %} + +{% macro bigquery__numeric_type() %} + FLOAT64 +{% endmacro %} + +{% macro snowflake__numeric_type() %} + FLOAT +{% endmacro %} + + + diff --git a/models/alerts/f2dq_alerts.sql b/models/alerts/f2dq_alerts.sql new file mode 100644 index 0000000..62e99a5 --- /dev/null +++ b/models/alerts/f2dq_alerts.sql @@ -0,0 +1,34 @@ +select + 'anomaly' as type, + {{ f2dq.clean_blacklist('table_name', ['"', '`'], '') }} as model, + message, + last_value_text as value, + time_window_end +from + {{ ref(var('f2dq:anomalies_filtered')) }} +union all + +select + 'schema_change' as type, + {{ f2dq.clean_blacklist('table_name', ['"', '`'], '') }} as model, + {{ generate_schema_change_message('operation', 'column_name', 'prev_column_name', 'prev_data_type', 'data_type', 'detected_time') }} as message, + '' as value, + detected_time as time_window_end +from {{ ref('f2dq_schema_changes') }} + +union all + +select + 'test' as type, + table_name as model, + {{ generate_failed_test_message('test_name', 'column_name') }}, + status as value, + run_at as time_window_end + +from {{ ref('f2dq_test_history') }} +where + status = 'Fail' + or status = 'Error' + {% if f2dq.f2dq_var('show_warns_as_alerts', false) %} + or status = 'Warn' + {% endif %} diff --git a/models/alerts/f2dq_anomalies.sql b/models/alerts/f2dq_anomalies.sql new file mode 100644 index 0000000..1286fa7 --- /dev/null +++ b/models/alerts/f2dq_anomalies.sql @@ -0,0 +1,55 @@ +{{ + config( + materialized='view' + ) +}} +select + z.id, + z.table_name, + z.column_name, + z.metric, + z.z_score_value, + z.modified_z_score_value, + m.anomaly_detector, + z.last_value, + z.last_avg, + z.last_median, + z.last_stddev, + z.last_median_absolute_deviation, + z.last_mean_absolute_deviation, + z.last_iqr, + z.last_first_quartile - (cast( {{ json_extract('m.anomaly_detector', 'whisker_boundary_multiplier') }} as {{numeric_type()}} ) * z.last_iqr) lower_bound, + z.last_third_quartile + (cast( {{ json_extract('m.anomaly_detector', 'whisker_boundary_multiplier') }} as {{numeric_type()}} ) * z.last_iqr) upper_bound, + z.last_first_quartile, + z.last_third_quartile, + z.time_window_end, + z.interval_length_sec, + z.computed_on, + {{ f2dq.generate_anomaly_message('z.column_name', 'z.metric', 'z.last_value', 'z.last_avg') }} as message, + {{ f2dq.generate_metric_value_text('z.metric', 'z.last_value') }} as last_value_text +from + {{ ref('f2dq_z_score')}} z +left join {{ ref('f2dq_selected') }} m +on {{ split_and_return_nth_value('table_name', '.', 1) }} = m.database +and {{ split_and_return_nth_value('table_name', '.', 2) }} = m.schema +and {{ split_and_return_nth_value('table_name', '.', 3) }} = m.name +where + case when (lower(coalesce({{ json_extract('m.anomaly_detector', 'direction') }}, 'both')) = 'up' and z.last_value > z.last_avg) + or (lower(coalesce({{ json_extract('m.anomaly_detector', 'direction') }}, 'both')) = 'down' and z.last_value < z.last_avg) + or (lower(coalesce({{ json_extract('m.anomaly_detector', 'direction') }}, 'both')) != 'up' and lower(coalesce({{ json_extract('m.anomaly_detector', 'direction') }}, 'both')) != 'down') + then + case + when {{ json_extract('m.anomaly_detector', 'name') }} = 'z_score' + then abs(z_score_value) > cast({{ json_extract('m.anomaly_detector', 'threshold') }} as {{ numeric_type() }}) + when {{ json_extract('m.anomaly_detector', 'name') }} = 'modified_z_score' + then abs(modified_z_score_value) > cast( {{ json_extract('m.anomaly_detector', 'threshold') }} as {{numeric_type()}} ) + when {{ json_extract('m.anomaly_detector', 'name') }} = 'boxplot' + then ( + z.last_value < z.last_first_quartile - (cast( {{ json_extract('m.anomaly_detector', 'whisker_boundary_multiplier') }} as {{numeric_type()}} ) * z.last_iqr) + or + z.last_value > z.last_third_quartile + (cast( {{ json_extract('m.anomaly_detector', 'whisker_boundary_multiplier') }} as {{numeric_type()}} ) * z.last_iqr) + ) + else false + end + else false + end diff --git a/models/alerts/f2dq_schema_changes.sql b/models/alerts/f2dq_schema_changes.sql new file mode 100644 index 0000000..d1af2a3 --- /dev/null +++ b/models/alerts/f2dq_schema_changes.sql @@ -0,0 +1,167 @@ +{{ + config( + materialized='incremental', + unique_key = 'id', + on_schema_change='sync_all_columns', + ) +}} + +-- depends_on: {{ ref('f2dq_run_started_at') }} +-- depends_on: {{ ref('f2dq_columns_over_time') }} +-- depends_on: {{ ref('f2dq_monitored') }} +-- depends_on: {{ ref('f2dq_selected') }} + +{% if execute and not f2dq.in_compile() %} + {% set last_data_points %} + select + distinct detected_time + from {{ ref('f2dq_columns_over_time') }} + order by + detected_time desc limit 2; + {% endset %} + + {% set detected_times = run_query(last_data_points) %} + + {% set times_list = detected_times.columns[0].values() %} + {% set most_recent_time = times_list[0] %} + + {% if times_list | length > 1 %} + {% set prev_most_recent = times_list[1] %} + {% else %} + {% set prev_most_recent = times_list[0] %} + {% endif %} +{% else %} + {% set times_list = () %} +{% endif %} + +{% if times_list == () %} + {{ + f2dq.empty_table_generic([ + ('id', 'string'), + ('table_name', 'string'), + ('operation', 'string'), + ('column_name', 'string'), + ('data_type', 'string'), + ('is_nullable', 'boolean'), + ('prev_column_name', 'string'), + ('prev_data_type', 'string'), + ('prev_is_nullable', 'boolean'), + ('detected_time', 'timestamp') + ]) + }} +{% else %} + + with curr_monitored_schema as ( + select * from {{ ref('f2dq_columns_over_time')}} + where detected_time = cast('{{ most_recent_time }}' as {{ timestamp_type() }}) + and table_name in ( + select {{ full_table_name('name', 'schema', 'database') }} from {{ ref('f2dq_selected')}} + ) + ), + + + prev_monitored_schema as ( + select * from {{ ref('f2dq_columns_over_time')}} + where detected_time = cast('{{ prev_most_recent}}' as {{ timestamp_type() }}) + and table_name in ( + select {{ full_table_name('name', 'schema', 'database') }} from {{ ref('f2dq_selected')}} + ) + ), + + all_changes as ( + ( + select + curr.table_name as table_name, + 'type_change' as operation, + curr.column_name as column_name, + curr.data_type as data_type, + curr.is_nullable as is_nullable, + + prev.column_name as prev_column_name, + prev.data_type as prev_data_type, + prev.is_nullable as prev_is_nullable + + from curr_monitored_schema curr inner join prev_monitored_schema prev on (curr.table_name = prev.table_name and curr.column_name = prev.column_name) + where + curr.data_type != prev.data_type or + curr.is_nullable != prev.is_nullable + ) + + union all + + ( + + select + curr.table_name as table_name, + 'column_added' as operation, + curr.column_name as column_name, + curr.data_type as data_type, + curr.is_nullable as is_nullable, + + null as prev_column_name, + null as prev_data_type, + null as prev_is_nullable + + from curr_monitored_schema curr left join prev_monitored_schema prev on (curr.table_name = prev.table_name and curr.column_name = prev.column_name) + where prev.table_name is null and prev.column_name is null + {# note: when a column is added, make sure we only detect for models that were previously monitored, + this avoids a situation where a newly monitored model has all its columns detected with 'column_added' operation#} + and curr.table_name in ( + select table_name from prev_monitored_schema + ) + + ) + + union all + + ( + + select + prev.table_name as table_name, + 'column_removed' as operation, + null as column_name, + null as data_type, + null as is_nullable, + + prev.column_name as prev_column_name, + prev.data_type as prev_data_type, + prev.is_nullable as prev_is_nullable + + from prev_monitored_schema prev left join curr_monitored_schema curr on (curr.table_name = prev.table_name and curr.column_name = prev.column_name) + where curr.table_name is null and curr.column_name is null + + ) + ), + + all_with_time as ( + select + all_changes.table_name, + all_changes.operation, + all_changes.column_name, + all_changes.data_type, + all_changes.is_nullable, + all_changes.prev_column_name, + all_changes.prev_data_type, + all_changes.prev_is_nullable, + cast({{dbt.current_timestamp_backcompat()}} as {{ timestamp_type() }}) as detected_time + from all_changes + ) + + select + cast ({{ dbt_utils.generate_surrogate_key([ + 'table_name', + 'column_name', + 'detected_time' + ]) }} as {{ string_type() }} ) as id, + table_name, + cast (operation as {{ string_type() }}) as operation, + column_name, + data_type, + is_nullable, + prev_column_name, + prev_data_type, + prev_is_nullable, + detected_time + from all_with_time + +{% endif %} diff --git a/models/alerts/f2dq_test_runs.sql b/models/alerts/f2dq_test_runs.sql new file mode 100644 index 0000000..f716d9c --- /dev/null +++ b/models/alerts/f2dq_test_runs.sql @@ -0,0 +1,13 @@ +{{ + config( + materialized='view' + ) +}} + +select + sum(case when status = 'Fail' then 1 else 0 end) as failed, + sum(case when status = 'Pass' then 1 else 0 end) as passed, + run_at +from {{ ref ('f2dq_test_history') }} +group by run_at +order by run_at desc \ No newline at end of file diff --git a/models/alerts/f2dq_z_score.sql b/models/alerts/f2dq_z_score.sql new file mode 100644 index 0000000..138e5aa --- /dev/null +++ b/models/alerts/f2dq_z_score.sql @@ -0,0 +1,76 @@ +{{ + config( + materialized='incremental', + unique_key = 'id', + on_schema_change='sync_all_columns', + ) +}} + +with z_score_without_id as ( + + select + stats.table_name as table_name, + stats.column_name as column_name, + stats.metric as metric, + stats.interval_length_sec, + (last_metric.last_value - stats.last_avg) / (stats.last_stddev + 0.0000000001) as z_score_value, + case + when stats.last_median_absolute_deviation = 0 then + (last_metric.last_value - stats.last_median) / (1.253314 * (stats.last_mean_absolute_deviation + 0.0000000001)) + else + (0.6745 * (last_metric.last_value - stats.last_median)) / (stats.last_median_absolute_deviation + 0.0000000001) + end as modified_z_score_value, + last_metric.last_value as last_value, + stats.last_avg as last_avg, + stats.last_median as last_median, + stats.last_stddev as last_stddev, + stats.last_median_absolute_deviation, + stats.last_mean_absolute_deviation, + stats.last_third_quartile - stats.last_first_quartile as last_iqr, + stats.last_first_quartile, + stats.last_third_quartile, + {{ time_window_end() }} as time_window_end, + cast( {{dbt.current_timestamp_backcompat()}} as {{ timestamp_type() }} ) as computed_on + from + {{ ref('f2dq_last_stats') }} as stats, + {{ ref('f2dq_last_metrics') }} as last_metric + where + stats.table_name = last_metric.table_name and + stats.column_name = last_metric.column_name and + stats.metric = last_metric.metric and + ( + stats.interval_length_sec = last_metric.interval_length_sec or + (stats.interval_length_sec is null and last_metric.interval_length_sec is null) + ) and + last_metric.last_value is not null and + stats.last_avg is not null and + stats.last_stddev is not null + ) + +select + cast ({{ dbt_utils.generate_surrogate_key([ + 'table_name', + 'column_name', + 'metric', + 'interval_length_sec', + 'time_window_end' + ]) }} as {{ string_type() }} ) as id, + table_name, + column_name, + metric, + z_score_value, + modified_z_score_value, + last_value, + last_avg, + last_median, + last_stddev, + last_median_absolute_deviation, + last_mean_absolute_deviation, + last_iqr, + last_first_quartile, + last_third_quartile, + time_window_end, + interval_length_sec, + computed_on + +from z_score_without_id diff --git a/models/internal/f2dq_run_started_at.sql b/models/internal/f2dq_run_started_at.sql new file mode 100644 index 0000000..8ce8035 --- /dev/null +++ b/models/internal/f2dq_run_started_at.sql @@ -0,0 +1,7 @@ +{{ + config( + materialized='table', + ) +}} + +select {{ run_started_at.timestamp() * 1000000 }} as run_started_at \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_part0.sql b/models/internal/metrics/base/f2dq_last_base_metrics_part0.sql new file mode 100644 index 0000000..f6d3ec1 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_part0.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_part() }} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_part1.sql b/models/internal/metrics/base/f2dq_last_base_metrics_part1.sql new file mode 100644 index 0000000..f6d3ec1 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_part1.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_part() }} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_part2.sql b/models/internal/metrics/base/f2dq_last_base_metrics_part2.sql new file mode 100644 index 0000000..f6d3ec1 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_part2.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_part() }} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_part3.sql b/models/internal/metrics/base/f2dq_last_base_metrics_part3.sql new file mode 100644 index 0000000..f6d3ec1 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_part3.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_part() }} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_thread0.sql b/models/internal/metrics/base/f2dq_last_base_metrics_thread0.sql new file mode 100644 index 0000000..c559bc6 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_thread0.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_thread(0)}} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_thread1.sql b/models/internal/metrics/base/f2dq_last_base_metrics_thread1.sql new file mode 100644 index 0000000..f5f4227 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_thread1.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_thread(1)}} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_thread2.sql b/models/internal/metrics/base/f2dq_last_base_metrics_thread2.sql new file mode 100644 index 0000000..91c16d3 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_thread2.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_thread(2)}} \ No newline at end of file diff --git a/models/internal/metrics/base/f2dq_last_base_metrics_thread3.sql b/models/internal/metrics/base/f2dq_last_base_metrics_thread3.sql new file mode 100644 index 0000000..bde4984 --- /dev/null +++ b/models/internal/metrics/base/f2dq_last_base_metrics_thread3.sql @@ -0,0 +1 @@ +{{ f2dq_last_base_metrics_thread(3)}} \ No newline at end of file diff --git a/models/internal/samples/f2dq_last_table_samples.sql b/models/internal/samples/f2dq_last_table_samples.sql new file mode 100644 index 0000000..0a63945 --- /dev/null +++ b/models/internal/samples/f2dq_last_table_samples.sql @@ -0,0 +1 @@ +{{ f2dq_last_table_samples() }} \ No newline at end of file diff --git a/models/internal/samples/f2dq_last_table_samples_part.sql b/models/internal/samples/f2dq_last_table_samples_part.sql new file mode 100644 index 0000000..1522c36 --- /dev/null +++ b/models/internal/samples/f2dq_last_table_samples_part.sql @@ -0,0 +1,7 @@ +{{ + config( + materialized='table', + ) +}} + +{{ f2dq.empty_last_table_samples() }} \ No newline at end of file diff --git a/models/meta/f2dq_columns.sql b/models/meta/f2dq_columns.sql new file mode 100644 index 0000000..df8c63f --- /dev/null +++ b/models/meta/f2dq_columns.sql @@ -0,0 +1,39 @@ +{{ + config( + materialized='table', + ) +}} + +-- depends_on: {{ ref('f2dq_run_started_at') }} +-- depends_on: {{ ref('f2dq_monitored') }} +-- depends_on: {{ ref('f2dq_selected') }} + +{% if execute %} + {% set schemas = run_query(f2dq.get_schemas()) %} + {% if schemas %} + + with columns_from_select as ( + {% for row in schemas %} + {% set schema_name = f2dq.name_in_db(f2dq.row_value(row, 'schema')) %} + {{ get_monitored_columns(schema_name, f2dq.row_value(row, 'database')) }} + {%- if not loop.last %} union all {%- endif %} + {% endfor %} + ) + + select + cast (table_name as {{ string_type() }} ) as name, + cast (table_schema as {{ string_type() }} ) as schema, + cast (table_catalog as {{ string_type() }} ) as database, + cast (column_name as {{ string_type() }} ) as column_name, + cast (data_type as {{ string_type() }} ) as data_type, + cast (case is_nullable when 'YES' then 1 else 0 end as {{ boolean_type() }} ) as is_nullable, + cast ({{dbt.current_timestamp_backcompat()}} as {{ timestamp_type() }}) as computed_on + from columns_from_select + + {% else %} + {{ empty_columns_table() }} + {% endif %} + +{% else %} + {{ empty_columns_table() }} +{% endif %} diff --git a/models/meta/f2dq_monitored.sql b/models/meta/f2dq_monitored.sql new file mode 100644 index 0000000..fee012d --- /dev/null +++ b/models/meta/f2dq_monitored.sql @@ -0,0 +1,23 @@ +{{ + config( + materialized='table', + unique_key = 'table_name', + post_hook="{% if execute %}{{ pub_insert_into_f2dq_monitored() }}{% endif %}" + ) +}} + +{{ + f2dq.empty_table_generic([ + ('name', 'string'), + ('schema', 'string'), + ('database', 'string'), + ('time_filter', 'string'), + ('metrics_groups', 'string'), + ('additional_metrics', 'string'), + ('metrics', 'string'), + ('columns', 'string'), + ('anomaly_detector', 'string'), + ('owners', 'string'), + ('selected', 'boolean') + ]) +}} \ No newline at end of file diff --git a/models/meta/f2dq_selected.sql b/models/meta/f2dq_selected.sql new file mode 100644 index 0000000..e293550 --- /dev/null +++ b/models/meta/f2dq_selected.sql @@ -0,0 +1,6 @@ + +select + name, schema, database, time_filter, metrics, columns, anomaly_detector, owners +from {{ ref('f2dq_monitored')}} +where + selected = true \ No newline at end of file diff --git a/models/metrics/final/f2dq_metrics.sql b/models/metrics/final/f2dq_metrics.sql new file mode 100644 index 0000000..9686a50 --- /dev/null +++ b/models/metrics/final/f2dq_metrics.sql @@ -0,0 +1 @@ +select * from {{ ref('f2dq_base_metrics') }} \ No newline at end of file diff --git a/models/metrics/for_anomalies/f2dq_last_metrics.sql b/models/metrics/for_anomalies/f2dq_last_metrics.sql new file mode 100644 index 0000000..fffac13 --- /dev/null +++ b/models/metrics/for_anomalies/f2dq_last_metrics.sql @@ -0,0 +1,12 @@ +select + table_name, + column_name, + metric, + value as last_value, + interval_length_sec, + computed_on +from + {{ ref('f2dq_base_metrics') }} +where + time_window_end = {{- time_window_end() -}} + diff --git a/models/metrics/for_anomalies/f2dq_last_stats.sql b/models/metrics/for_anomalies/f2dq_last_stats.sql new file mode 100644 index 0000000..bb7fd7a --- /dev/null +++ b/models/metrics/for_anomalies/f2dq_last_stats.sql @@ -0,0 +1,104 @@ +{% set columns_to_group_by = 'table_name, column_name, metric, interval_length_sec' %} + +with median_value as ( + select distinct + table_name, + column_name, + metric, + interval_length_sec, + avg(value) {% if target.type not in postgres_type_db() %} over(partition by {{ columns_to_group_by }}) {% endif %} as last_avg, + {{ percentile(percentile_field='value', partition_field=columns_to_group_by, percent='0.25') }} as last_first_quartile, + {{ percentile(percentile_field='value', partition_field=columns_to_group_by, percent='0.5') }} as last_median, + {{ percentile(percentile_field='value', partition_field=columns_to_group_by, percent='0.75') }} as last_third_quartile + from + {{ ref('f2dq_base_metrics') }} + where + time_window_end > {{- anamaly_detection_time_window_start() -}} and + time_window_end <= {{- time_window_end() -}} + {% if target.type in postgres_type_db() %} + group by + {{ columns_to_group_by }} + {% endif %} + +), abs_deviation as ( + select + s.table_name, + s.column_name, + s.metric, + s.interval_length_sec, + abs( s.value - mv.last_avg ) as absolute_deviation_from_mean, + abs( s.value - mv.last_median ) as absolute_deviation_from_median + from + {{ ref('f2dq_base_metrics') }} s + left join + median_value mv + on + s.table_name = mv.table_name and + s.column_name = mv.column_name and + s.metric = mv.metric and + s.interval_length_sec = mv.interval_length_sec + where + s.time_window_end > {{- anamaly_detection_time_window_start() -}} and + s.time_window_end <= {{- time_window_end() -}} +), median_abs_deviation as ( + select distinct + table_name, + column_name, + metric, + interval_length_sec, + avg(absolute_deviation_from_mean) {% if target.type not in postgres_type_db() %} over(partition by {{ columns_to_group_by }}) {% endif %} as mean_absolute_deviation, + {{ percentile(percentile_field='absolute_deviation_from_median', partition_field=columns_to_group_by, percent='0.5') }} as median_absolute_deviation + from + abs_deviation + {% if target.type in postgres_type_db() %} + group by + {{ columns_to_group_by }} + {% endif %} +), stats as ( + select + table_name, + column_name, + metric, + stddev(value) as last_stddev, + max(time_window_end) as last_metric_time, + interval_length_sec, + max(computed_on) as computed_on + from + {{ ref('f2dq_base_metrics') }} + where + time_window_end > {{- anamaly_detection_time_window_start() -}} and + time_window_end <= {{- time_window_end() -}} + group by + {{ columns_to_group_by }} +) + +select + s.table_name, + s.column_name, + s.metric, + mv.last_avg, + s.last_stddev, + s.last_metric_time, + s.interval_length_sec, + s.computed_on, + mv.last_median, + mv.last_first_quartile, + mv.last_third_quartile, + md.median_absolute_deviation last_median_absolute_deviation, + md.mean_absolute_deviation last_mean_absolute_deviation +from + stats s +left join + median_value mv + on + s.table_name = mv.table_name and + s.column_name = mv.column_name and + s.metric = mv.metric and + s.interval_length_sec = mv.interval_length_sec +left join + median_abs_deviation md + on + s.table_name = md.table_name and + s.column_name = md.column_name and + s.metric = md.metric and + s.interval_length_sec = md.interval_length_sec diff --git a/models/metrics/types/base/f2dq_base_metrics.sql b/models/metrics/types/base/f2dq_base_metrics.sql new file mode 100644 index 0000000..8b78030 --- /dev/null +++ b/models/metrics/types/base/f2dq_base_metrics.sql @@ -0,0 +1,54 @@ +{{ + config( + materialized='incremental', + unique_key = 'id', + on_schema_change='sync_all_columns', + ) +}} + +-- depends_on: {{ ref('f2dq_columns') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_thread0') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_thread1') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_thread2') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_thread3') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_part0') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_part1') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_part2') }} +-- depends_on: {{ ref('f2dq_last_base_metrics_part3') }} +-- depends_on: {{ ref('f2dq_run_started_at') }} +-- depends_on: {{ ref('f2dq_monitored') }} +-- depends_on: {{ ref('f2dq_selected') }} + +with + +with_time_window as ( + {% set parts = ['0','1','2','3'] %} + {% for part in parts %} + {% set ref_name = 'f2dq_last_base_metrics_part' + part %} + select + *, + {{ time_window_start() }} as time_window_start, + {{ time_window_end() }} as time_window_end + from {{ ref(ref_name) }} + {%- if not loop.last %} union all {%- endif %} + {% endfor %} +) +select + cast ({{ dbt_utils.generate_surrogate_key([ + 'table_name', + 'column_name', + 'metric', + 'time_window_start', + 'time_window_end' + ]) }} as {{ string_type() }} ) as id, + cast (table_name as {{ string_type() }} ) as table_name, + cast (column_name as {{ string_type() }} ) as column_name, + cast (metric as {{ string_type() }} ) as metric, + cast (value as {{ numeric_type() }} ) as value, + cast (time_window_start as {{ timestamp_type() }} ) as time_window_start, + cast (time_window_end as {{ timestamp_type() }} ) as time_window_end, + cast ( + {{ interval_length_sec('time_window_start', 'time_window_end') }} as {{ integer_type() }} + ) as interval_length_sec, + cast ({{dbt.current_timestamp_backcompat()}} as {{ timestamp_type() }}) as computed_on +from with_time_window diff --git a/models/metrics/types/samples/f2dq_table_samples.sql b/models/metrics/types/samples/f2dq_table_samples.sql new file mode 100644 index 0000000..3dd442d --- /dev/null +++ b/models/metrics/types/samples/f2dq_table_samples.sql @@ -0,0 +1,17 @@ +{{ + config( + materialized='table', + unique_key = 'table_name', + on_schema_change='sync_all_columns', + ) +}} + +-- depends_on: {{ ref('f2dq_last_table_samples') }} +-- depends_on: {{ ref('f2dq_last_table_samples_part') }} + +select + table_name, + sample_data, + cast ({{dbt.current_timestamp_backcompat()}} as {{ timestamp_type() }}) as sampled_on + +from {{ ref('f2dq_last_table_samples_part') }} diff --git a/models/metrics/types/schema/f2dq_columns_over_time.sql b/models/metrics/types/schema/f2dq_columns_over_time.sql new file mode 100644 index 0000000..2f093bd --- /dev/null +++ b/models/metrics/types/schema/f2dq_columns_over_time.sql @@ -0,0 +1,35 @@ +{{ + config( + materialized='incremental', + unique_key = 'id', + on_schema_change='sync_all_columns', + ) +}} + + +with columns as ( + +select + {{ full_table_name('cols.name', 'cols.schema', 'cols.database') }} as table_name, + cols.column_name, + cols.data_type, + cols.is_nullable, + cast ({{dbt.current_timestamp_backcompat()}} as {{ timestamp_type() }} ) as detected_time +from + {{ ref('f2dq_columns')}} cols, {{ ref('f2dq_selected')}} tables +where + cols.name = tables.name and cols.schema = tables.schema and cols.database = tables.database +) + +select + cast ({{ dbt_utils.generate_surrogate_key([ + 'table_name', + 'column_name', + 'detected_time' + ]) }} as {{ string_type() }} ) as id, + table_name, + column_name, + data_type, + is_nullable, + detected_time +from columns \ No newline at end of file diff --git a/packages.yml b/packages.yml new file mode 100644 index 0000000..60c4d13 --- /dev/null +++ b/packages.yml @@ -0,0 +1,3 @@ +packages: + - package: dbt-labs/dbt_utils + version: 1.4.1 diff --git a/profiles.yml b/profiles.yml new file mode 100644 index 0000000..8555b9e --- /dev/null +++ b/profiles.yml @@ -0,0 +1,60 @@ +f2dq_postgres: + target: dev + outputs: + dev: + type: postgres + host: localhost + user: postgres + password: postgres + port: 5432 + dbname: postgres + schema: dq + threads: 4 +f2dq_snowflake: + target: dev + outputs: + dev: + type: snowflake + account: "{{ env_var('SNOWFLAKE_F2DQ_TESTING_ACCOUNT') }}" + user: "{{ env_var('F2DQ_TESTING_USER') }}" + password: "{{ env_var('F2DQ_TESTING_PASSWORD') }}" + database: F2DQ_TESTING + warehouse: F2DQ_TESTING_DWH + schema: "{{ env_var('DQ_SCHEMA') }}" + threads: 4 +f2dq_redshift: + target: dev + outputs: + dev: + type: redshift + host: "{{ env_var('REDSHIFT_F2DQ_TESTING_HOST') }}" + user: "{{ env_var('F2DQ_TESTING_USER') }}" + password: "{{ env_var('F2DQ_TESTING_PASSWORD') }}" + port: 5439 + dbname: f2dq_testing + schema: "{{ env_var('DQ_SCHEMA') }}" + threads: 4 +f2dq_bigquery: + target: dev + outputs: + dev: + type: bigquery + method: service-account-json + project: "{{ env_var('BIGQUERY_TESTING_PROJECT_ID') }}" + dataset: "{{ env_var('DQ_SCHEMA') }}" + threads: 4 + keyfile_json: + type: "{{ env_var('BIGQUERY_TESTING_TYPE') }}" + project_id: "{{ env_var('BIGQUERY_TESTING_PROJECT_ID') }}" + private_key_id: "{{ env_var('BIGQUERY_TESTING_PRIVATE_KEY_ID') }}" + private_key: "{{ env_var('BIGQUERY_TESTING_PRIVATE_KEY') }}" + client_email: "{{ env_var('BIGQUERY_TESTING_CLIENT_EMAIL') }}" + client_id: "{{ env_var('BIGQUERY_TESTING_CLIENT_ID') }}" + auth_uri: "{{ env_var('BIGQUERY_TESTING_AUTH_URI') }}" + token_uri: "{{ env_var('BIGQUERY_TESTING_TOKEN_URI') }}" + auth_provider_x509_cert_url: "{{ env_var('BIGQUERY_TESTING_AUTH_PROVIDER_X509_CERT_URL') }}" + client_x509_cert_url: "{{ env_var('BIGQUERY_TESTING_CLIENT_X509_CERT_URL') }}" + location: US + timeout_seconds: 300 + priority: interactive + retries: 1 \ No newline at end of file diff --git a/snapshots/.gitkeep b/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/lineage_graph.png b/static/lineage_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..ee2b79460ec35e90a0ad717bf1c0ee2b11251584 GIT binary patch literal 122924 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