model2data 1.14.0__tar.gz → 1.16.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {model2data-1.14.0/model2data.egg-info → model2data-1.16.0}/PKG-INFO +8 -6
- {model2data-1.14.0 → model2data-1.16.0}/README.md +7 -5
- {model2data-1.14.0 → model2data-1.16.0}/README_PYPI.md +7 -5
- {model2data-1.14.0 → model2data-1.16.0}/model2data/cli.py +24 -11
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/hint_tests.py +60 -7
- model2data-1.16.0/model2data/dbt/templates/hint_macros/model2data_parent_tests.sql +37 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/tests.py +15 -7
- {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/apply.py +42 -1
- {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/checks.py +37 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/core.py +147 -4
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/days.py +29 -1
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/faker.py +42 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/hints.py +31 -10
- model2data-1.16.0/model2data/generate/parents.py +437 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/timeline.py +109 -18
- {model2data-1.14.0 → model2data-1.16.0}/model2data/guide/setup.md +3 -2
- {model2data-1.14.0 → model2data-1.16.0}/model2data/guide/triage.md +11 -1
- {model2data-1.14.0 → model2data-1.16.0}/model2data/guide/tune.md +22 -4
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/__init__.py +3 -2
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/dbml.py +10 -5
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/document.py +19 -2
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/errors.py +22 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/types.py +1 -1
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/validate.py +227 -40
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/PUBLISHING.md +14 -12
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/README.md +48 -5
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/examples/coffee_webshop.model2data.yml +3 -3
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/model.schema.json +24 -9
- {model2data-1.14.0 → model2data-1.16.0/model2data.egg-info}/PKG-INFO +8 -6
- {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/SOURCES.txt +3 -0
- {model2data-1.14.0 → model2data-1.16.0}/pyproject.toml +1 -1
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_cli.py +37 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_cli_model.py +2 -2
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_column_time_hints.py +2 -1
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects.py +8 -2
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_dbt.py +31 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_review.py +4 -2
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_distributions.py +8 -6
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_cli.py +1 -1
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_dbml.py +3 -1
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_messages.py +4 -4
- model2data-1.16.0/tests/test_parent_dates.py +882 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_shaping.py +28 -26
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_spec_alignment.py +133 -1
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_table_seeds.py +11 -5
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_timeline.py +31 -28
- {model2data-1.14.0 → model2data-1.16.0}/LICENSE +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/LICENSE +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/constants.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/database.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/tools.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/naming.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/project.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/hint_macros/model2data_when_tests.sql +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/presets.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/report.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/history.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/kinds.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/options.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/relationships.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/when.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/check.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/columns.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/dbt.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/expression.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/graph.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/infer.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/lightdash.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/ossie.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/reader.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/semantic.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/sql.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/types.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/values.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/_yaml.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/dump.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/engine.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/model/reader.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/output.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/parse/__init__.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/parse/dbml.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/examples/coffee_webshop.metrics.yml +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/metrics/README.md +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/metrics/metrics.schema.json +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data/utils.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/requires.txt +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/top_level.txt +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/setup.cfg +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_as_of_anchor.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_coverage_gaps.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_days.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbml_parser.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbml_parser_fuzz.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_integration.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_naming.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_project.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_tests.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_replay.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_report.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_determinism.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_examples_model.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_faker_name_inference.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_fast_paths.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_generation.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_guide.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_hint_tests.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_history.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_issue_lines.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_lone_country.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_dbt.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_expression.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_lightdash.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_ossie.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_reader.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_semantic.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_spec.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_values.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_model.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_edges.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_vocabulary.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_options.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_release_stress.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_reproducibility.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_row_identity.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_spec_schema.py +0 -0
- {model2data-1.14.0 → model2data-1.16.0}/tests/test_when.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.16.0
|
|
4
4
|
Summary: Turn a data model into realistic synthetic data, a runnable dbt project and metrics with known values
|
|
5
5
|
Author: JB Analytica
|
|
6
6
|
License-Expression: MIT
|
|
@@ -89,9 +89,12 @@ emails, not `Lorem ipsum`. Type a column with any Faker provider (`billing_count
|
|
|
89
89
|
come from one country. → [The model file](https://github.com/JB-Analytica/model2data/blob/main/docs/model-file.md)
|
|
90
90
|
|
|
91
91
|
**Foreign keys that resolve.** Tables are generated in dependency order and every foreign key
|
|
92
|
-
points at a parent row that exists — onto a primary key or a unique one
|
|
93
|
-
|
|
94
|
-
(`
|
|
92
|
+
points at a parent row that exists — onto a primary key or a unique one — and
|
|
93
|
+
`after: customers.created_at` keeps an order on or after the sign-up of the customer it points
|
|
94
|
+
at (`validate` suggests it where a date could fall before its parent's). Integer primary keys
|
|
95
|
+
number the rows 1..N, in order. Composite primary and unique keys hold, and a column that repeats a
|
|
96
|
+
parent's value through a foreign key (`orders.customer_email`) holds the email of the order's
|
|
97
|
+
own customer.
|
|
95
98
|
→ [The model file](https://github.com/JB-Analytica/model2data/blob/main/docs/model-file.md)
|
|
96
99
|
|
|
97
100
|
**A dbt project that runs.** Seeds, staging models that `ref()` them, `not_null`, `unique`,
|
|
@@ -115,8 +118,7 @@ byte-identical. → [The determinism promise](https://github.com/JB-Analytica/mo
|
|
|
115
118
|
|
|
116
119
|
**When things happen.** `--business-hours`, `--growth` and `--seasonality` shape every timestamp
|
|
117
120
|
(weekdays and working hours, a trend, a Q4 peak), or one column at a time. `updated_at` never
|
|
118
|
-
lands before `created_at`, `after` orders any two columns, and `when` gives `shipped_at` a value
|
|
119
|
-
on shipped and delivered orders only. → [When things happen](https://github.com/JB-Analytica/model2data/blob/main/docs/time-shapes.md)
|
|
121
|
+
lands before `created_at`, `after` orders any two columns, or a column after a parent's, and `when` gives `shipped_at` a value on shipped and delivered orders only. → [When things happen](https://github.com/JB-Analytica/model2data/blob/main/docs/time-shapes.md)
|
|
120
122
|
|
|
121
123
|
**How the data is spread.** `--skew` lets a few customers place most of the orders; `weights`,
|
|
122
124
|
`true_rate`, `null_rate` and `distinct` shape a column; `distribution` draws a number from a
|
|
@@ -42,9 +42,12 @@ emails, not `Lorem ipsum`. Type a column with any Faker provider (`billing_count
|
|
|
42
42
|
come from one country. → [The model file](docs/model-file.md)
|
|
43
43
|
|
|
44
44
|
**Foreign keys that resolve.** Tables are generated in dependency order and every foreign key
|
|
45
|
-
points at a parent row that exists — onto a primary key or a unique one
|
|
46
|
-
|
|
47
|
-
(`
|
|
45
|
+
points at a parent row that exists — onto a primary key or a unique one — and
|
|
46
|
+
`after: customers.created_at` keeps an order on or after the sign-up of the customer it points
|
|
47
|
+
at (`validate` suggests it where a date could fall before its parent's). Integer primary keys
|
|
48
|
+
number the rows 1..N, in order. Composite primary and unique keys hold, and a column that repeats a
|
|
49
|
+
parent's value through a foreign key (`orders.customer_email`) holds the email of the order's
|
|
50
|
+
own customer.
|
|
48
51
|
→ [The model file](docs/model-file.md)
|
|
49
52
|
|
|
50
53
|
**A dbt project that runs.** Seeds, staging models that `ref()` them, `not_null`, `unique`,
|
|
@@ -68,8 +71,7 @@ byte-identical. → [The determinism promise](docs/generating.md#the-determinism
|
|
|
68
71
|
|
|
69
72
|
**When things happen.** `--business-hours`, `--growth` and `--seasonality` shape every timestamp
|
|
70
73
|
(weekdays and working hours, a trend, a Q4 peak), or one column at a time. `updated_at` never
|
|
71
|
-
lands before `created_at`, `after` orders any two columns, and `when` gives `shipped_at` a value
|
|
72
|
-
on shipped and delivered orders only. → [When things happen](docs/time-shapes.md)
|
|
74
|
+
lands before `created_at`, `after` orders any two columns, or a column after a parent's, and `when` gives `shipped_at` a value on shipped and delivered orders only. → [When things happen](docs/time-shapes.md)
|
|
73
75
|
|
|
74
76
|
**How the data is spread.** `--skew` lets a few customers place most of the orders; `weights`,
|
|
75
77
|
`true_rate`, `null_rate` and `distinct` shape a column; `distribution` draws a number from a
|
|
@@ -42,9 +42,12 @@ emails, not `Lorem ipsum`. Type a column with any Faker provider (`billing_count
|
|
|
42
42
|
come from one country. → [The model file](https://github.com/JB-Analytica/model2data/blob/main/docs/model-file.md)
|
|
43
43
|
|
|
44
44
|
**Foreign keys that resolve.** Tables are generated in dependency order and every foreign key
|
|
45
|
-
points at a parent row that exists — onto a primary key or a unique one
|
|
46
|
-
|
|
47
|
-
(`
|
|
45
|
+
points at a parent row that exists — onto a primary key or a unique one — and
|
|
46
|
+
`after: customers.created_at` keeps an order on or after the sign-up of the customer it points
|
|
47
|
+
at (`validate` suggests it where a date could fall before its parent's). Integer primary keys
|
|
48
|
+
number the rows 1..N, in order. Composite primary and unique keys hold, and a column that repeats a
|
|
49
|
+
parent's value through a foreign key (`orders.customer_email`) holds the email of the order's
|
|
50
|
+
own customer.
|
|
48
51
|
→ [The model file](https://github.com/JB-Analytica/model2data/blob/main/docs/model-file.md)
|
|
49
52
|
|
|
50
53
|
**A dbt project that runs.** Seeds, staging models that `ref()` them, `not_null`, `unique`,
|
|
@@ -68,8 +71,7 @@ byte-identical. → [The determinism promise](https://github.com/JB-Analytica/mo
|
|
|
68
71
|
|
|
69
72
|
**When things happen.** `--business-hours`, `--growth` and `--seasonality` shape every timestamp
|
|
70
73
|
(weekdays and working hours, a trend, a Q4 peak), or one column at a time. `updated_at` never
|
|
71
|
-
lands before `created_at`, `after` orders any two columns, and `when` gives `shipped_at` a value
|
|
72
|
-
on shipped and delivered orders only. → [When things happen](https://github.com/JB-Analytica/model2data/blob/main/docs/time-shapes.md)
|
|
74
|
+
lands before `created_at`, `after` orders any two columns, or a column after a parent's, and `when` gives `shipped_at` a value on shipped and delivered orders only. → [When things happen](https://github.com/JB-Analytica/model2data/blob/main/docs/time-shapes.md)
|
|
73
75
|
|
|
74
76
|
**How the data is spread.** `--skew` lets a few customers place most of the orders; `weights`,
|
|
75
77
|
`true_rate`, `null_rate` and `distinct` shape a column; `distribution` draws a number from a
|
|
@@ -29,6 +29,7 @@ from model2data.defects import (
|
|
|
29
29
|
)
|
|
30
30
|
from model2data.generate.core import (
|
|
31
31
|
generate_data_from_dbml,
|
|
32
|
+
get_capped_tables,
|
|
32
33
|
get_cyclic_tables,
|
|
33
34
|
get_unresolved_composite_keys,
|
|
34
35
|
)
|
|
@@ -184,7 +185,7 @@ app = typer.Typer(
|
|
|
184
185
|
cls=_GenerateByDefault,
|
|
185
186
|
help=(
|
|
186
187
|
"model2data: Generate analytics-ready datasets from a data model.\n\n"
|
|
187
|
-
"Given a model -- a .model2data.yml document (spec 0.
|
|
188
|
+
"Given a model -- a .model2data.yml document (spec 0.5.0), the same as JSON,\n"
|
|
188
189
|
"or a DBML file -- this tool produces:\n"
|
|
189
190
|
"• Synthetic but realistic data\n"
|
|
190
191
|
"• A runnable dbt project scaffold\n"
|
|
@@ -401,8 +402,9 @@ def main(
|
|
|
401
402
|
"warn",
|
|
402
403
|
"--hint-tests",
|
|
403
404
|
help=(
|
|
404
|
-
"Write the model's generation hints as dbt tests (min/max range, after
|
|
405
|
-
"null_rate, distinct, when, grain) at this severity:
|
|
405
|
+
"Write the model's generation hints as dbt tests (min/max range, after "
|
|
406
|
+
"(also a parent's column), null_rate, distinct, when, grain) at this severity: "
|
|
407
|
+
"error, warn or off. They "
|
|
406
408
|
"describe intent, so they warn by default rather than break a first dbt "
|
|
407
409
|
"build on real data."
|
|
408
410
|
),
|
|
@@ -772,6 +774,7 @@ def main(
|
|
|
772
774
|
cyclic_tables = get_cyclic_tables()
|
|
773
775
|
unresolved_keys = get_unresolved_composite_keys()
|
|
774
776
|
duplicate_unique = get_duplicate_unique_columns()
|
|
777
|
+
capped_tables = get_capped_tables()
|
|
775
778
|
|
|
776
779
|
typer.echo("\n📊 Summary")
|
|
777
780
|
typer.echo(f" Tables generated: {len(generated_tables)}")
|
|
@@ -810,6 +813,13 @@ def main(
|
|
|
810
813
|
)
|
|
811
814
|
for label in duplicate_unique:
|
|
812
815
|
typer.echo(f" - {label}")
|
|
816
|
+
if capped_tables:
|
|
817
|
+
typer.echo(
|
|
818
|
+
" ⚠️ Tables generated with fewer rows than asked, so a unique reference "
|
|
819
|
+
"keeps one row per parent value:"
|
|
820
|
+
)
|
|
821
|
+
for label in capped_tables:
|
|
822
|
+
typer.echo(f" - {label}")
|
|
813
823
|
|
|
814
824
|
if metrics_count is not None:
|
|
815
825
|
typer.echo(
|
|
@@ -837,12 +847,13 @@ def main(
|
|
|
837
847
|
|
|
838
848
|
|
|
839
849
|
def _spec_of(file: Path) -> str:
|
|
840
|
-
"""The spec a conforming model is written against: 0.4.0, 0.3.0, or 0.2.0 (DBML
|
|
841
|
-
`when` included)."""
|
|
850
|
+
"""The spec a conforming model is written against: 0.5.0, 0.4.0, 0.3.0, or 0.2.0 (DBML
|
|
851
|
+
without `when` or a 0.5.0 `after` included)."""
|
|
842
852
|
version = str(load(file).version)
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
853
|
+
for minor in ("0.5", "0.4", "0.3"):
|
|
854
|
+
if version.startswith(minor):
|
|
855
|
+
return f"{minor}.0"
|
|
856
|
+
return "0.2.0"
|
|
846
857
|
|
|
847
858
|
|
|
848
859
|
def _print_defects(report: DefectsReport) -> None:
|
|
@@ -934,11 +945,12 @@ def validate_command(
|
|
|
934
945
|
),
|
|
935
946
|
),
|
|
936
947
|
):
|
|
937
|
-
"""Check that models conform to spec 0.
|
|
948
|
+
"""Check that models conform to spec 0.5.0 (or 0.2.x-0.4.x), printing every issue with its path.
|
|
938
949
|
|
|
939
950
|
Takes one or more files and/or --glob patterns. A metrics file (*.metrics.yml) is checked
|
|
940
951
|
against metrics spec 0.1.0 and against its model. Exits 1 when any file has an error;
|
|
941
|
-
warnings are printed, and the file conforms.
|
|
952
|
+
warnings are printed, and the file conforms. A warning that a child's date `can fall
|
|
953
|
+
before` its parent's names the `after: <table>.<column>` to add.
|
|
942
954
|
"""
|
|
943
955
|
if output_format not in ("text", "github"):
|
|
944
956
|
raise typer.BadParameter("must be 'text' or 'github'", param_hint="--format")
|
|
@@ -1029,7 +1041,8 @@ def convert_command(
|
|
|
1029
1041
|
force: bool = typer.Option(False, "--force", help="Overwrite the output file if it exists."),
|
|
1030
1042
|
):
|
|
1031
1043
|
"""Convert a model -- DBML, typically -- to a .model2data.yml document (spec 0.2.0, or
|
|
1032
|
-
0.3.0 when it has defects, 0.4.0 when it has `when`
|
|
1044
|
+
0.3.0 when it has defects, 0.4.0 when it has `when`, 0.5.0 when an `after` is a list or
|
|
1045
|
+
names a parent's column)."""
|
|
1033
1046
|
try:
|
|
1034
1047
|
model = load(file)
|
|
1035
1048
|
except ModelError as error:
|
|
@@ -12,11 +12,17 @@ What becomes a test, and what does not:
|
|
|
12
12
|
|---------------------------|----------------------------------------|-----------|
|
|
13
13
|
| `generate.min` / `max` | `model2data_between` | none |
|
|
14
14
|
| `generate.after: other` | `model2data_not_before` | none |
|
|
15
|
+
| `generate.after: table.column` | `model2data_not_before_parent` | none |
|
|
15
16
|
| `generate.null_rate` | `model2data_max_null_share` | yes |
|
|
16
17
|
| `generate.distinct: n` | `model2data_max_distinct` | none |
|
|
17
18
|
| `generate.when` | `model2data_when` | none |
|
|
18
19
|
| table `grain` | `model2data_unique_combination` | none |
|
|
19
20
|
|
|
21
|
+
An `after` listing several columns writes one test for each. A parent's column
|
|
22
|
+
(`after: customers.created_at`, see `model2data.generate.parents`) is tested
|
|
23
|
+
through the foreign key that reaches it, so that test needs the model's
|
|
24
|
+
references: a caller passing bare tables passes `refs` too.
|
|
25
|
+
|
|
20
26
|
A column with `when` and `null_rate` has its null share tested among the rows
|
|
21
27
|
`when` matches (`model2data_when_max_null_share`), since only those rows can
|
|
22
28
|
hold a value, and its `model2data_when` test lets those rows be null.
|
|
@@ -28,8 +34,9 @@ constraint a row can break, so they produce no test.
|
|
|
28
34
|
|
|
29
35
|
The generic tests live in `macros/model2data_hint_tests.sql` of the generated
|
|
30
36
|
project (see `write_hint_macros`), and the `when` ones in
|
|
31
|
-
`macros/model2data_when_tests.sql
|
|
32
|
-
|
|
37
|
+
`macros/model2data_when_tests.sql` and the parent one in
|
|
38
|
+
`macros/model2data_parent_tests.sql`, each written only for a model that has
|
|
39
|
+
one, so a project without either is what it was: plain SQL, no dbt package.
|
|
33
40
|
"""
|
|
34
41
|
|
|
35
42
|
from __future__ import annotations
|
|
@@ -41,6 +48,9 @@ from pathlib import Path
|
|
|
41
48
|
from typing import Any, Literal, Optional, Union
|
|
42
49
|
|
|
43
50
|
from model2data.generate import kinds
|
|
51
|
+
from model2data.generate.core import parent_rules_for
|
|
52
|
+
from model2data.generate.parents import ParentRule
|
|
53
|
+
from model2data.generate.timeline import same_row_after
|
|
44
54
|
from model2data.model.engine import EngineInputs, to_engine
|
|
45
55
|
from model2data.model.types import Model
|
|
46
56
|
from model2data.parse.dbml import ColumnDef, TableDef
|
|
@@ -53,6 +63,8 @@ MACROS_FILE = "model2data_hint_tests.sql"
|
|
|
53
63
|
_MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / MACROS_FILE
|
|
54
64
|
WHEN_MACROS_FILE = "model2data_when_tests.sql"
|
|
55
65
|
_WHEN_MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / WHEN_MACROS_FILE
|
|
66
|
+
PARENT_MACROS_FILE = "model2data_parent_tests.sql"
|
|
67
|
+
_PARENT_MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / PARENT_MACROS_FILE
|
|
56
68
|
|
|
57
69
|
|
|
58
70
|
@dataclass(frozen=True)
|
|
@@ -64,6 +76,8 @@ class HintTest:
|
|
|
64
76
|
test's name, `arguments` its parameters as dbt's `arguments:` block, and
|
|
65
77
|
`hint` the hint it comes from (`min`, `max`, `after`, `null_rate`,
|
|
66
78
|
`distinct`, `when`, `grain`). `min` and `max` of one column share one test.
|
|
79
|
+
`parent` is the `(table, column)` a test looks rows up in: the parent's key
|
|
80
|
+
for `model2data_not_before_parent`, None for every other test.
|
|
67
81
|
"""
|
|
68
82
|
|
|
69
83
|
table: str
|
|
@@ -71,6 +85,7 @@ class HintTest:
|
|
|
71
85
|
test: str
|
|
72
86
|
arguments: dict[str, Any] = field(default_factory=dict)
|
|
73
87
|
hint: str = ""
|
|
88
|
+
parent: Optional[tuple[str, str]] = None
|
|
74
89
|
|
|
75
90
|
def to_dbt(self, severity: str = "warn") -> dict[str, dict[str, Any]]:
|
|
76
91
|
"""The entry for a `tests:` list: `{name: {arguments: ..., config: {severity: ...}}}`."""
|
|
@@ -83,12 +98,16 @@ def hint_tests_for(
|
|
|
83
98
|
model_or_tables: Union[Model, EngineInputs, Mapping[str, TableDef]],
|
|
84
99
|
*,
|
|
85
100
|
tolerance: float = DEFAULT_TOLERANCE,
|
|
101
|
+
refs: Optional[list[dict]] = None,
|
|
86
102
|
) -> list[HintTest]:
|
|
87
103
|
"""The tests implied by the hints of a model, in table then column order.
|
|
88
104
|
|
|
89
105
|
Takes a `Model`, the `EngineInputs` of one, or the `tables` dict (as
|
|
90
106
|
`to_engine(model).tables`, or the renamed tables the CLI hands
|
|
91
107
|
`generate_dbt_yml`). Table-level tests follow the table's column tests.
|
|
108
|
+
With `tables`, `refs` are the references as `to_engine` gives them; without
|
|
109
|
+
them no test of a parent's column is written (a `Model` or `EngineInputs`
|
|
110
|
+
brings its own).
|
|
92
111
|
|
|
93
112
|
`tolerance` is the absolute slack of the statistical test (the null share
|
|
94
113
|
may be `null_rate + tolerance`); hard constraints take none. Severity is
|
|
@@ -97,18 +116,25 @@ def hint_tests_for(
|
|
|
97
116
|
if tolerance < 0:
|
|
98
117
|
raise ValueError("tolerance must not be negative")
|
|
99
118
|
if isinstance(model_or_tables, Model):
|
|
100
|
-
|
|
119
|
+
inputs = to_engine(model_or_tables)
|
|
120
|
+
tables: Mapping[str, TableDef] = inputs.tables
|
|
121
|
+
refs = inputs.refs
|
|
101
122
|
elif isinstance(model_or_tables, EngineInputs):
|
|
102
123
|
tables = model_or_tables.tables
|
|
124
|
+
refs = model_or_tables.refs
|
|
103
125
|
else:
|
|
104
126
|
tables = model_or_tables
|
|
127
|
+
rules = parent_rules_for(dict(tables), refs) if refs else {}
|
|
105
128
|
|
|
106
129
|
found: list[HintTest] = []
|
|
107
130
|
for key, table in tables.items():
|
|
108
131
|
by_name = {column.name: column for column in table.columns}
|
|
109
132
|
names = set(by_name)
|
|
133
|
+
parents = {rule.column: rule for rule in rules.get(key, [])}
|
|
110
134
|
for column in table.columns:
|
|
111
135
|
found.extend(_column_tests(key, column, by_name, tolerance))
|
|
136
|
+
if column.name in parents:
|
|
137
|
+
found.extend(_parent_tests(key, parents[column.name]))
|
|
112
138
|
grain = (table.note or {}).get("grain")
|
|
113
139
|
if grain and all(name in names for name in grain):
|
|
114
140
|
found.append(
|
|
@@ -146,8 +172,7 @@ def _column_tests(
|
|
|
146
172
|
)
|
|
147
173
|
)
|
|
148
174
|
|
|
149
|
-
after
|
|
150
|
-
if isinstance(after, str) and after in kinds_of and after != column.name:
|
|
175
|
+
for after in [a for a in same_row_after(note) if a in kinds_of and a != column.name]:
|
|
151
176
|
arguments: dict[str, Any] = {"other": after}
|
|
152
177
|
# The generator compares a date with a timestamp at day granularity.
|
|
153
178
|
if (
|
|
@@ -202,6 +227,31 @@ def _column_tests(
|
|
|
202
227
|
return tests
|
|
203
228
|
|
|
204
229
|
|
|
230
|
+
def _parent_tests(table: str, rule: ParentRule) -> list[HintTest]:
|
|
231
|
+
"""One `model2data_not_before_parent` test per parent column the `after` names."""
|
|
232
|
+
tests = []
|
|
233
|
+
for link in rule.links:
|
|
234
|
+
arguments: dict[str, Any] = {
|
|
235
|
+
"foreign_key": link.fk,
|
|
236
|
+
"to": f"ref('stg_{link.parent_table}')",
|
|
237
|
+
"field": link.parent_key,
|
|
238
|
+
"parent_column": link.parent_column,
|
|
239
|
+
}
|
|
240
|
+
if link.by_day:
|
|
241
|
+
arguments["granularity"] = "day"
|
|
242
|
+
tests.append(
|
|
243
|
+
HintTest(
|
|
244
|
+
table,
|
|
245
|
+
rule.column,
|
|
246
|
+
"model2data_not_before_parent",
|
|
247
|
+
arguments,
|
|
248
|
+
"after",
|
|
249
|
+
parent=(link.parent_table, link.parent_key),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return tests
|
|
253
|
+
|
|
254
|
+
|
|
205
255
|
def _when_conditions(when: Any, kinds_of: dict[str, ColumnDef]) -> Optional[dict[str, list]]:
|
|
206
256
|
"""A `when` hint as the test's `conditions`: an enum's values as the member text it holds."""
|
|
207
257
|
if not isinstance(when, dict) or not when or not all(name in kinds_of for name in when):
|
|
@@ -212,13 +262,14 @@ def _when_conditions(when: Any, kinds_of: dict[str, ColumnDef]) -> Optional[dict
|
|
|
212
262
|
}
|
|
213
263
|
|
|
214
264
|
|
|
215
|
-
def write_hint_macros(dest: Path, *, when: bool = False) -> Path:
|
|
265
|
+
def write_hint_macros(dest: Path, *, when: bool = False, parents: bool = False) -> Path:
|
|
216
266
|
"""Write the generic tests into `dest/macros/`, and return the file's path.
|
|
217
267
|
|
|
218
268
|
A project whose YAML holds `hint_tests_for` tests needs this file next to
|
|
219
269
|
it; `generate_dbt_yml` writes it itself when it emits any. With `when`, the
|
|
220
270
|
file of the `when` tests is written beside it (`WHEN_MACROS_FILE`), which a
|
|
221
|
-
project holding a `model2data_when` test needs too
|
|
271
|
+
project holding a `model2data_when` test needs too; with `parents`, the
|
|
272
|
+
file of `model2data_not_before_parent` (`PARENT_MACROS_FILE`).
|
|
222
273
|
"""
|
|
223
274
|
macros = dest / "macros"
|
|
224
275
|
macros.mkdir(parents=True, exist_ok=True)
|
|
@@ -226,4 +277,6 @@ def write_hint_macros(dest: Path, *, when: bool = False) -> Path:
|
|
|
226
277
|
target.write_text(_MACROS_SOURCE.read_text())
|
|
227
278
|
if when:
|
|
228
279
|
(macros / WHEN_MACROS_FILE).write_text(_WHEN_MACROS_SOURCE.read_text())
|
|
280
|
+
if parents:
|
|
281
|
+
(macros / PARENT_MACROS_FILE).write_text(_PARENT_MACROS_SOURCE.read_text())
|
|
229
282
|
return target
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{#-
|
|
2
|
+
The generic test model2data writes for a cross-table `after` (`after:
|
|
3
|
+
customers.created_at` on orders.order_date): a row is not dated before the
|
|
4
|
+
parent row its foreign key points at. Written beside
|
|
5
|
+
model2data_hint_tests.sql, and only by a model that has such a hint. Plain
|
|
6
|
+
ANSI SQL, runs on DuckDB and Postgres. Returns the rows that break it.
|
|
7
|
+
`column_name` arrives already quoted by the schema YAML; `foreign_key`,
|
|
8
|
+
`field` (the parent's key) and `parent_column` (the column `after` names)
|
|
9
|
+
are raw names and are quoted here. A row whose foreign key is null, or points
|
|
10
|
+
at no parent row, is left to the not_null and relationships tests, and a key
|
|
11
|
+
several parent rows share (left to the unique test) counts its earliest one.
|
|
12
|
+
`granularity='day'` compares a date with a timestamp by day.
|
|
13
|
+
-#}
|
|
14
|
+
|
|
15
|
+
{% test model2data_not_before_parent(model, column_name, foreign_key, to, field, parent_column, granularity=none) %}
|
|
16
|
+
{%- set mine = 'child.' ~ column_name -%}
|
|
17
|
+
{%- set theirs = 'parent.parent_value' -%}
|
|
18
|
+
{%- if granularity == 'day' -%}
|
|
19
|
+
{%- set mine = 'cast(' ~ mine ~ ' as date)' -%}
|
|
20
|
+
{%- set theirs = 'cast(' ~ theirs ~ ' as date)' -%}
|
|
21
|
+
{%- endif %}
|
|
22
|
+
select
|
|
23
|
+
child.{{ column_name }} as value,
|
|
24
|
+
parent.parent_value
|
|
25
|
+
from {{ model }} as child
|
|
26
|
+
join (
|
|
27
|
+
select
|
|
28
|
+
{{ adapter.quote(field) }} as parent_key,
|
|
29
|
+
min({{ adapter.quote(parent_column) }}) as parent_value
|
|
30
|
+
from {{ to }}
|
|
31
|
+
where {{ adapter.quote(parent_column) }} is not null
|
|
32
|
+
group by {{ adapter.quote(field) }}
|
|
33
|
+
) as parent
|
|
34
|
+
on child.{{ adapter.quote(foreign_key) }} = parent.parent_key
|
|
35
|
+
where child.{{ column_name }} is not null
|
|
36
|
+
and {{ mine }} < {{ theirs }}
|
|
37
|
+
{% endtest %}
|
|
@@ -66,10 +66,12 @@ def generate_dbt_yml(
|
|
|
66
66
|
staging_path = dest / "models" / "staging"
|
|
67
67
|
staging_path.mkdir(parents=True, exist_ok=True)
|
|
68
68
|
|
|
69
|
-
hinted = _hinted(tables, hint_tests, test_tolerance)
|
|
69
|
+
hinted = _hinted(tables, hint_tests, test_tolerance, refs)
|
|
70
70
|
if hinted:
|
|
71
|
-
|
|
72
|
-
write_hint_macros(
|
|
71
|
+
names = {test.test for tests in hinted.values() for test in tests}
|
|
72
|
+
write_hint_macros(
|
|
73
|
+
dest, when="model2data_when" in names, parents="model2data_not_before_parent" in names
|
|
74
|
+
)
|
|
73
75
|
if any(_history_entries(table) for table in tables.values()):
|
|
74
76
|
write_history_macros(dest)
|
|
75
77
|
fk_map = _fk_map(tables, refs)
|
|
@@ -114,14 +116,14 @@ def generate_dbt_yml(
|
|
|
114
116
|
|
|
115
117
|
|
|
116
118
|
def _hinted(
|
|
117
|
-
tables: dict, hint_tests: str, test_tolerance: float
|
|
119
|
+
tables: dict, hint_tests: str, test_tolerance: float, refs: list[dict]
|
|
118
120
|
) -> dict[tuple[str, Optional[str]], list[HintTest]]:
|
|
119
121
|
"""The hint tests by (table, column), column None for a table-level one; none for `off`."""
|
|
120
122
|
if hint_tests not in ("error", "warn", "off"):
|
|
121
123
|
raise ValueError(f"hint_tests must be error, warn or off, not {hint_tests!r}")
|
|
122
124
|
hinted: dict[tuple[str, Optional[str]], list[HintTest]] = defaultdict(list)
|
|
123
125
|
if hint_tests != "off":
|
|
124
|
-
for hint_test in hint_tests_for(tables, tolerance=test_tolerance):
|
|
126
|
+
for hint_test in hint_tests_for(tables, tolerance=test_tolerance, refs=refs):
|
|
125
127
|
hinted[(hint_test.table, hint_test.column)].append(hint_test)
|
|
126
128
|
return hinted
|
|
127
129
|
|
|
@@ -193,7 +195,8 @@ class DbtTest:
|
|
|
193
195
|
`relationships`, a `model2data_*` hint test) or `unique_combination` for the
|
|
194
196
|
singular test of a composite key. `arguments` are the test's parameters,
|
|
195
197
|
`severity` is `error`, or the hint tests' severity, and `parent` is the
|
|
196
|
-
`(table, column)` a `relationships` test looks values up in
|
|
198
|
+
`(table, column)` a `relationships` test looks values up in, or the parent
|
|
199
|
+
key a `model2data_not_before_parent` test joins on.
|
|
197
200
|
"""
|
|
198
201
|
|
|
199
202
|
name: str
|
|
@@ -218,7 +221,7 @@ def dbt_tests(
|
|
|
218
221
|
name dbt gives the test in the generated project. Unit tests are not data
|
|
219
222
|
tests and are not listed.
|
|
220
223
|
"""
|
|
221
|
-
hinted = _hinted(tables, hint_tests, test_tolerance)
|
|
224
|
+
hinted = _hinted(tables, hint_tests, test_tolerance, refs)
|
|
222
225
|
fk_map = _fk_map(tables, refs)
|
|
223
226
|
found: list[DbtTest] = []
|
|
224
227
|
for table in tables.values():
|
|
@@ -226,6 +229,9 @@ def dbt_tests(
|
|
|
226
229
|
for col in table.columns:
|
|
227
230
|
column_ref = _dbt_column_ref(col.name)
|
|
228
231
|
parents = iter(fk_map.get((table.name, col.name), []))
|
|
232
|
+
hint_parents = iter(
|
|
233
|
+
t.parent for t in hinted.get((table.name, col.name), []) if t.parent is not None
|
|
234
|
+
)
|
|
229
235
|
for entry in _column_entries(table, col, fk_map, hinted, hint_tests):
|
|
230
236
|
test_type, body = _entry_parts(entry)
|
|
231
237
|
arguments = dict(body.get("arguments") or {})
|
|
@@ -233,6 +239,8 @@ def dbt_tests(
|
|
|
233
239
|
if test_type == "relationships":
|
|
234
240
|
ref = next(parents)
|
|
235
241
|
parent = (ref["target_table"], ref["target_column"])
|
|
242
|
+
elif test_type == "model2data_not_before_parent":
|
|
243
|
+
parent = next(hint_parents)
|
|
236
244
|
found.append(
|
|
237
245
|
DbtTest(
|
|
238
246
|
name=generic_test_name(
|
|
@@ -67,8 +67,10 @@ from model2data.dbt.tests import DbtTest, dbt_tests
|
|
|
67
67
|
from model2data.defects.checks import Columns, as_seeded, failing, fails
|
|
68
68
|
from model2data.defects.report import AppliedDefect, DefectsReport, ExpectedFailure
|
|
69
69
|
from model2data.generate import kinds
|
|
70
|
+
from model2data.generate.core import parent_rules_for
|
|
70
71
|
from model2data.generate.days import DayResult, TableDay
|
|
71
72
|
from model2data.generate.history import IS_CURRENT, VALID_FROM, VALID_TO, history_key, primary_key
|
|
73
|
+
from model2data.generate.parents import ParentRule, floor_on_day, moments
|
|
72
74
|
from model2data.model.engine import EngineInputs, to_engine
|
|
73
75
|
from model2data.model.types import Defect, Model
|
|
74
76
|
from model2data.parse.dbml import ColumnDef, TableDef
|
|
@@ -111,8 +113,11 @@ class _Table:
|
|
|
111
113
|
refs: list[dict],
|
|
112
114
|
results: Optional[list[DayResult]],
|
|
113
115
|
incremental: Any,
|
|
116
|
+
rules: Sequence[ParentRule] = (),
|
|
114
117
|
):
|
|
115
118
|
self.key = key
|
|
119
|
+
# The columns whose `after` names a parent's column (see generate.parents).
|
|
120
|
+
self.rules = list(rules)
|
|
116
121
|
self.table = table
|
|
117
122
|
self.frames = frames
|
|
118
123
|
self.frame = frames[key]
|
|
@@ -533,7 +538,7 @@ def _late_arriving(table: _Table, defect: Defect, rng: random.Random, count: int
|
|
|
533
538
|
]
|
|
534
539
|
temporal = [c for c in table.columns if table.is_temporal(c)]
|
|
535
540
|
candidates = table.free(candidates, temporal)
|
|
536
|
-
chosen = _choose(rng, candidates, count,
|
|
541
|
+
chosen = _choose(rng, candidates, count, _before_parents_once_late(table, candidates, name))
|
|
537
542
|
patches: list[_Patch] = []
|
|
538
543
|
# Earliest day first: a row moved back lowers what the loader has seen on later days.
|
|
539
544
|
for position in sorted(chosen, key=lambda p: (table.inserted_day[p], p)):
|
|
@@ -634,6 +639,40 @@ def _event_column(table: _Table) -> Optional[str]:
|
|
|
634
639
|
return next((c for c in table.columns if table.is_temporal(c)), None)
|
|
635
640
|
|
|
636
641
|
|
|
642
|
+
def _before_parents_once_late(table: _Table, candidates: list[int], name: str) -> set[int]:
|
|
643
|
+
"""The candidates moving back would date before a parent row (see generate.parents).
|
|
644
|
+
|
|
645
|
+
A late-arriving row is moved back past the previous load's cutoff, every date of
|
|
646
|
+
it; a date with a cross-table `after` can then fall before its parent, which the
|
|
647
|
+
dbt test `model2data_not_before_parent` reports. Rows that stay clear of their
|
|
648
|
+
parents are taken first, so the defect breaks the incremental model it is meant
|
|
649
|
+
to and no test besides.
|
|
650
|
+
"""
|
|
651
|
+
if not table.rules or not candidates:
|
|
652
|
+
return set()
|
|
653
|
+
checks = [
|
|
654
|
+
(
|
|
655
|
+
floor_on_day(table.frame, rule, table.frames).tolist(),
|
|
656
|
+
moments(table.frame[rule.column], rule.kind).tolist(),
|
|
657
|
+
)
|
|
658
|
+
for rule in table.rules
|
|
659
|
+
]
|
|
660
|
+
values = table.frame[name].tolist()
|
|
661
|
+
cutoffs: dict[int, Any] = {}
|
|
662
|
+
risky = set()
|
|
663
|
+
for position in candidates:
|
|
664
|
+
day = table.inserted_day[position]
|
|
665
|
+
if day not in cutoffs:
|
|
666
|
+
cutoffs[day] = table.loaded_max(day - 1, name)
|
|
667
|
+
cutoff, moment, back = cutoffs[day], _moment(values[position]), 1
|
|
668
|
+
while cutoff is not None and moment - timedelta(days=back) >= cutoff:
|
|
669
|
+
back += 1
|
|
670
|
+
# A null foreign key or date gives NaT, which compares false.
|
|
671
|
+
if any(own[position] - pd.Timedelta(days=back) < floor[position] for floor, own in checks):
|
|
672
|
+
risky.add(position)
|
|
673
|
+
return risky
|
|
674
|
+
|
|
675
|
+
|
|
637
676
|
def _moment(value: Any) -> datetime:
|
|
638
677
|
return pd.Timestamp(value).to_pydatetime()
|
|
639
678
|
|
|
@@ -818,6 +857,7 @@ def break_tables(
|
|
|
818
857
|
|
|
819
858
|
applied: Applied = []
|
|
820
859
|
tables: dict[str, _Table] = {}
|
|
860
|
+
rules = parent_rules_for(inputs.tables, inputs.refs)
|
|
821
861
|
for key, entries in defects.items():
|
|
822
862
|
if key not in inputs.tables:
|
|
823
863
|
raise ValueError(f"defects name the table {key!r}, which is not in the model")
|
|
@@ -840,6 +880,7 @@ def break_tables(
|
|
|
840
880
|
inputs.refs,
|
|
841
881
|
results,
|
|
842
882
|
inputs.incremental.get(target),
|
|
883
|
+
rules.get(target, []),
|
|
843
884
|
)
|
|
844
885
|
table = tables[target]
|
|
845
886
|
rng = random.Random(defect_stream_seed(base_seed, key, defect))
|
|
@@ -14,6 +14,9 @@ SQL reads:
|
|
|
14
14
|
and `model2data_max_distinct`: as their macros in
|
|
15
15
|
`dbt/templates/hint_macros/model2data_hint_tests.sql`, nulls skipped where the
|
|
16
16
|
SQL skips them.
|
|
17
|
+
- `model2data_not_before_parent`: as its macro in
|
|
18
|
+
`dbt/templates/hint_macros/model2data_parent_tests.sql`: a row dated before
|
|
19
|
+
the earliest parent row its foreign key matches, by day with `granularity: day`.
|
|
17
20
|
- `model2data_when` and `model2data_when_max_null_share`: as their macros in
|
|
18
21
|
`dbt/templates/hint_macros/model2data_when_tests.sql`, a row matching when
|
|
19
22
|
each named column's text is one of its values (a boolean as `true`/`false`
|
|
@@ -338,6 +341,39 @@ def _not_before(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Colu
|
|
|
338
341
|
return bool((mine < theirs).any())
|
|
339
342
|
|
|
340
343
|
|
|
344
|
+
def _not_before_parent(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns) -> bool:
|
|
345
|
+
assert test.parent is not None # `dbt_tests` names the parent of every such test
|
|
346
|
+
parent = seeded.get(test.parent[0])
|
|
347
|
+
if parent is None:
|
|
348
|
+
return False
|
|
349
|
+
arguments = test.arguments
|
|
350
|
+
mine = pd.DataFrame(
|
|
351
|
+
{
|
|
352
|
+
"key": cache.numbers(frame, arguments["foreign_key"]),
|
|
353
|
+
"moment": cache.values(frame, str(test.column)),
|
|
354
|
+
}
|
|
355
|
+
).dropna()
|
|
356
|
+
theirs = pd.DataFrame(
|
|
357
|
+
{
|
|
358
|
+
"key": cache.numbers(parent, arguments["field"]),
|
|
359
|
+
"parent_moment": cache.values(parent, arguments["parent_column"]),
|
|
360
|
+
}
|
|
361
|
+
).dropna()
|
|
362
|
+
if mine.empty or theirs.empty:
|
|
363
|
+
return False
|
|
364
|
+
# A key several parent rows share counts its earliest, as the SQL's `min` does.
|
|
365
|
+
theirs["parent_moment"] = pd.to_datetime(theirs["parent_moment"], format="mixed")
|
|
366
|
+
earliest = theirs.groupby("key", sort=False)["parent_moment"].min().reset_index()
|
|
367
|
+
pairs = mine.merge(earliest, on="key")
|
|
368
|
+
if pairs.empty:
|
|
369
|
+
return False
|
|
370
|
+
child = pd.to_datetime(pairs["moment"], format="mixed")
|
|
371
|
+
parents = pairs["parent_moment"]
|
|
372
|
+
if arguments.get("granularity") == "day":
|
|
373
|
+
child, parents = child.dt.normalize(), parents.dt.normalize()
|
|
374
|
+
return bool((child < parents).any())
|
|
375
|
+
|
|
376
|
+
|
|
341
377
|
def _max_null_share(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns) -> bool:
|
|
342
378
|
if not len(frame):
|
|
343
379
|
return False
|
|
@@ -433,6 +469,7 @@ _CHECKS: dict[str, Callable[[DbtTest, pd.DataFrame, Mapping, Columns], bool]] =
|
|
|
433
469
|
"relationships": _relationships,
|
|
434
470
|
"model2data_between": _between,
|
|
435
471
|
"model2data_not_before": _not_before,
|
|
472
|
+
"model2data_not_before_parent": _not_before_parent,
|
|
436
473
|
"model2data_max_null_share": _max_null_share,
|
|
437
474
|
"model2data_max_distinct": _max_distinct,
|
|
438
475
|
"model2data_when": _when,
|