model2data 1.13.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.
Files changed (198) hide show
  1. {model2data-1.13.0/model2data.egg-info → model2data-1.16.0}/PKG-INFO +14 -8
  2. {model2data-1.13.0 → model2data-1.16.0}/README.md +13 -7
  3. {model2data-1.13.0 → model2data-1.16.0}/README_PYPI.md +13 -7
  4. {model2data-1.13.0 → model2data-1.16.0}/model2data/cli.py +80 -18
  5. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/hint_tests.py +60 -7
  6. model2data-1.16.0/model2data/dbt/templates/hint_macros/model2data_parent_tests.sql +37 -0
  7. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/tests.py +15 -7
  8. {model2data-1.13.0 → model2data-1.16.0}/model2data/defects/apply.py +42 -1
  9. {model2data-1.13.0 → model2data-1.16.0}/model2data/defects/checks.py +37 -0
  10. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/core.py +147 -4
  11. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/days.py +29 -1
  12. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/faker.py +42 -0
  13. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/hints.py +31 -10
  14. model2data-1.16.0/model2data/generate/parents.py +437 -0
  15. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/timeline.py +109 -18
  16. {model2data-1.13.0 → model2data-1.16.0}/model2data/guide/setup.md +3 -2
  17. {model2data-1.13.0 → model2data-1.16.0}/model2data/guide/triage.md +11 -1
  18. {model2data-1.13.0 → model2data-1.16.0}/model2data/guide/tune.md +26 -6
  19. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/__init__.py +5 -0
  20. model2data-1.16.0/model2data/metrics/lightdash.py +597 -0
  21. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/__init__.py +3 -2
  22. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/dbml.py +10 -5
  23. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/document.py +19 -2
  24. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/errors.py +22 -0
  25. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/types.py +1 -1
  26. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/validate.py +227 -40
  27. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/PUBLISHING.md +14 -14
  28. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/README.md +48 -5
  29. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/examples/coffee_webshop.model2data.yml +3 -3
  30. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/metrics/README.md +3 -2
  31. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/model.schema.json +24 -9
  32. {model2data-1.13.0 → model2data-1.16.0/model2data.egg-info}/PKG-INFO +14 -8
  33. {model2data-1.13.0 → model2data-1.16.0}/model2data.egg-info/SOURCES.txt +5 -0
  34. {model2data-1.13.0 → model2data-1.16.0}/pyproject.toml +1 -1
  35. {model2data-1.13.0 → model2data-1.16.0}/tests/test_cli.py +37 -0
  36. {model2data-1.13.0 → model2data-1.16.0}/tests/test_cli_model.py +2 -2
  37. {model2data-1.13.0 → model2data-1.16.0}/tests/test_column_time_hints.py +2 -1
  38. {model2data-1.13.0 → model2data-1.16.0}/tests/test_defects.py +8 -2
  39. {model2data-1.13.0 → model2data-1.16.0}/tests/test_defects_dbt.py +31 -0
  40. {model2data-1.13.0 → model2data-1.16.0}/tests/test_defects_review.py +4 -2
  41. {model2data-1.13.0 → model2data-1.16.0}/tests/test_distributions.py +8 -6
  42. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_cli.py +58 -1
  43. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_dbt.py +69 -0
  44. model2data-1.16.0/tests/test_metrics_lightdash.py +645 -0
  45. {model2data-1.13.0 → model2data-1.16.0}/tests/test_model_dbml.py +3 -1
  46. {model2data-1.13.0 → model2data-1.16.0}/tests/test_model_messages.py +4 -4
  47. model2data-1.16.0/tests/test_parent_dates.py +882 -0
  48. {model2data-1.13.0 → model2data-1.16.0}/tests/test_shaping.py +28 -26
  49. {model2data-1.13.0 → model2data-1.16.0}/tests/test_spec_alignment.py +133 -1
  50. {model2data-1.13.0 → model2data-1.16.0}/tests/test_table_seeds.py +11 -5
  51. {model2data-1.13.0 → model2data-1.16.0}/tests/test_timeline.py +31 -28
  52. {model2data-1.13.0 → model2data-1.16.0}/LICENSE +0 -0
  53. {model2data-1.13.0 → model2data-1.16.0}/model2data/__init__.py +0 -0
  54. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/__init__.py +0 -0
  55. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  56. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  57. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  58. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  59. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  60. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  61. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  62. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  63. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  64. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  65. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  66. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  67. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  68. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  69. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  70. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  71. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/constants.py +0 -0
  72. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/database.py +0 -0
  73. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  74. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  75. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  76. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  77. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  78. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  79. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  80. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  81. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  82. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  83. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  84. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  85. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  86. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  87. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  88. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  89. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  90. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  91. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  92. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  93. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  94. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  95. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  96. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  97. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  98. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  99. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  100. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  101. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  102. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  103. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  104. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  105. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  106. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  107. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  108. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  109. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  110. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  111. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  112. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  113. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  114. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  115. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  116. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  117. {model2data-1.13.0 → model2data-1.16.0}/model2data/_vendor/pydbml/tools.py +0 -0
  118. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/__init__.py +0 -0
  119. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/naming.py +0 -0
  120. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/project.py +0 -0
  121. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  122. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  123. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/templates/hint_macros/model2data_when_tests.sql +0 -0
  124. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  125. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  126. {model2data-1.13.0 → model2data-1.16.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  127. {model2data-1.13.0 → model2data-1.16.0}/model2data/defects/__init__.py +0 -0
  128. {model2data-1.13.0 → model2data-1.16.0}/model2data/defects/presets.py +0 -0
  129. {model2data-1.13.0 → model2data-1.16.0}/model2data/defects/report.py +0 -0
  130. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/__init__.py +0 -0
  131. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/history.py +0 -0
  132. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/kinds.py +0 -0
  133. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/options.py +0 -0
  134. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/relationships.py +0 -0
  135. {model2data-1.13.0 → model2data-1.16.0}/model2data/generate/when.py +0 -0
  136. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/check.py +0 -0
  137. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/columns.py +0 -0
  138. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/dbt.py +0 -0
  139. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/expression.py +0 -0
  140. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/graph.py +0 -0
  141. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/infer.py +0 -0
  142. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/ossie.py +0 -0
  143. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/reader.py +0 -0
  144. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/semantic.py +0 -0
  145. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/sql.py +0 -0
  146. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/types.py +0 -0
  147. {model2data-1.13.0 → model2data-1.16.0}/model2data/metrics/values.py +0 -0
  148. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/_yaml.py +0 -0
  149. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/dump.py +0 -0
  150. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/engine.py +0 -0
  151. {model2data-1.13.0 → model2data-1.16.0}/model2data/model/reader.py +0 -0
  152. {model2data-1.13.0 → model2data-1.16.0}/model2data/output.py +0 -0
  153. {model2data-1.13.0 → model2data-1.16.0}/model2data/parse/__init__.py +0 -0
  154. {model2data-1.13.0 → model2data-1.16.0}/model2data/parse/dbml.py +0 -0
  155. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/examples/coffee_webshop.metrics.yml +0 -0
  156. {model2data-1.13.0 → model2data-1.16.0}/model2data/spec/metrics/metrics.schema.json +0 -0
  157. {model2data-1.13.0 → model2data-1.16.0}/model2data/utils.py +0 -0
  158. {model2data-1.13.0 → model2data-1.16.0}/model2data.egg-info/dependency_links.txt +0 -0
  159. {model2data-1.13.0 → model2data-1.16.0}/model2data.egg-info/entry_points.txt +0 -0
  160. {model2data-1.13.0 → model2data-1.16.0}/model2data.egg-info/requires.txt +0 -0
  161. {model2data-1.13.0 → model2data-1.16.0}/model2data.egg-info/top_level.txt +0 -0
  162. {model2data-1.13.0 → model2data-1.16.0}/setup.cfg +0 -0
  163. {model2data-1.13.0 → model2data-1.16.0}/tests/test_as_of_anchor.py +0 -0
  164. {model2data-1.13.0 → model2data-1.16.0}/tests/test_coverage_gaps.py +0 -0
  165. {model2data-1.13.0 → model2data-1.16.0}/tests/test_days.py +0 -0
  166. {model2data-1.13.0 → model2data-1.16.0}/tests/test_dbml_parser.py +0 -0
  167. {model2data-1.13.0 → model2data-1.16.0}/tests/test_dbml_parser_fuzz.py +0 -0
  168. {model2data-1.13.0 → model2data-1.16.0}/tests/test_dbt_integration.py +0 -0
  169. {model2data-1.13.0 → model2data-1.16.0}/tests/test_dbt_naming.py +0 -0
  170. {model2data-1.13.0 → model2data-1.16.0}/tests/test_dbt_project.py +0 -0
  171. {model2data-1.13.0 → model2data-1.16.0}/tests/test_dbt_tests.py +0 -0
  172. {model2data-1.13.0 → model2data-1.16.0}/tests/test_defects_replay.py +0 -0
  173. {model2data-1.13.0 → model2data-1.16.0}/tests/test_defects_report.py +0 -0
  174. {model2data-1.13.0 → model2data-1.16.0}/tests/test_determinism.py +0 -0
  175. {model2data-1.13.0 → model2data-1.16.0}/tests/test_examples_model.py +0 -0
  176. {model2data-1.13.0 → model2data-1.16.0}/tests/test_faker_name_inference.py +0 -0
  177. {model2data-1.13.0 → model2data-1.16.0}/tests/test_fast_paths.py +0 -0
  178. {model2data-1.13.0 → model2data-1.16.0}/tests/test_generation.py +0 -0
  179. {model2data-1.13.0 → model2data-1.16.0}/tests/test_guide.py +0 -0
  180. {model2data-1.13.0 → model2data-1.16.0}/tests/test_hint_tests.py +0 -0
  181. {model2data-1.13.0 → model2data-1.16.0}/tests/test_history.py +0 -0
  182. {model2data-1.13.0 → model2data-1.16.0}/tests/test_issue_lines.py +0 -0
  183. {model2data-1.13.0 → model2data-1.16.0}/tests/test_lone_country.py +0 -0
  184. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_expression.py +0 -0
  185. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_ossie.py +0 -0
  186. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_reader.py +0 -0
  187. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_semantic.py +0 -0
  188. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_spec.py +0 -0
  189. {model2data-1.13.0 → model2data-1.16.0}/tests/test_metrics_values.py +0 -0
  190. {model2data-1.13.0 → model2data-1.16.0}/tests/test_model.py +0 -0
  191. {model2data-1.13.0 → model2data-1.16.0}/tests/test_model_edges.py +0 -0
  192. {model2data-1.13.0 → model2data-1.16.0}/tests/test_model_vocabulary.py +0 -0
  193. {model2data-1.13.0 → model2data-1.16.0}/tests/test_options.py +0 -0
  194. {model2data-1.13.0 → model2data-1.16.0}/tests/test_release_stress.py +0 -0
  195. {model2data-1.13.0 → model2data-1.16.0}/tests/test_reproducibility.py +0 -0
  196. {model2data-1.13.0 → model2data-1.16.0}/tests/test_row_identity.py +0 -0
  197. {model2data-1.13.0 → model2data-1.16.0}/tests/test_spec_schema.py +0 -0
  198. {model2data-1.13.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.13.0
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. Composite primary and
93
- unique keys hold, and a column that repeats a parent's value through a foreign key
94
- (`orders.customer_email`) holds the email of the order's own customer.
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
@@ -131,6 +133,9 @@ orphans, nulls, invalid values, messy text, late-arriving rows. Every run with d
131
133
  orders, average order value. The run writes each metric's value over the generated data to
132
134
  `metric_values.json`, overall and per month, a dbt test per metric that must reproduce it on the
133
135
  warehouse, and an [Apache Ossie](https://github.com/apache/ossie) semantic model under `osi/`.
136
+ New in 1.14.0: `--lightdash` writes the same metrics into the staging models' YAML as
137
+ [Lightdash](https://www.lightdash.com/) reads them, filters, joins and ratios included, so the
138
+ dashboard's revenue can be checked against the number the run already knows.
134
139
  → [Metrics with known values](https://github.com/JB-Analytica/model2data/blob/main/docs/metrics.md)
135
140
 
136
141
  **Validate in CI.** `model2data validate` checks models and metrics files and prints GitHub
@@ -272,7 +277,7 @@ definition goes in; nothing but synthetic data comes out.
272
277
  `accepted_values` fail for the right reason.
273
278
  - **A team defining metrics once.** Write revenue in one metrics file, check every number the
274
279
  semantic layer or dashboard shows against `metric_values.json`, let `dbt build` prove the SQL,
275
- and hand the definitions on as Apache Ossie.
280
+ and hand the definitions on as Apache Ossie or straight to Lightdash.
276
281
 
277
282
  ## How it works
278
283
 
@@ -298,7 +303,8 @@ model (.model2data.yml or DBML) + metrics file (optional)
298
303
  model's hints, table and column `description:` fields from the model, and a profile for
299
304
  DuckDB (zero-config, file-based) or Postgres.
300
305
  4. **Compute metrics.** With `--metrics`, computes each metric over the generated data and writes
301
- its known value, a dbt test that checks it, and the Apache Ossie file.
306
+ its known value, a dbt test that checks it, and the Apache Ossie file; with `--lightdash`,
307
+ Lightdash metrics and dimensions in the staging models' YAML.
302
308
 
303
309
  ---
304
310
 
@@ -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. Composite primary and
46
- unique keys hold, and a column that repeats a parent's value through a foreign key
47
- (`orders.customer_email`) holds the email of the order's own customer.
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
@@ -84,6 +86,9 @@ orphans, nulls, invalid values, messy text, late-arriving rows. Every run with d
84
86
  orders, average order value. The run writes each metric's value over the generated data to
85
87
  `metric_values.json`, overall and per month, a dbt test per metric that must reproduce it on the
86
88
  warehouse, and an [Apache Ossie](https://github.com/apache/ossie) semantic model under `osi/`.
89
+ New in 1.14.0: `--lightdash` writes the same metrics into the staging models' YAML as
90
+ [Lightdash](https://www.lightdash.com/) reads them, filters, joins and ratios included, so the
91
+ dashboard's revenue can be checked against the number the run already knows.
87
92
  → [Metrics with known values](docs/metrics.md)
88
93
 
89
94
  **Validate in CI.** `model2data validate` checks models and metrics files and prints GitHub
@@ -225,7 +230,7 @@ definition goes in; nothing but synthetic data comes out.
225
230
  `accepted_values` fail for the right reason.
226
231
  - **A team defining metrics once.** Write revenue in one metrics file, check every number the
227
232
  semantic layer or dashboard shows against `metric_values.json`, let `dbt build` prove the SQL,
228
- and hand the definitions on as Apache Ossie.
233
+ and hand the definitions on as Apache Ossie or straight to Lightdash.
229
234
 
230
235
  ## How it works
231
236
 
@@ -281,7 +286,8 @@ flowchart LR
281
286
  model's hints, table and column `description:` fields from the model, and a profile for
282
287
  DuckDB (zero-config, file-based) or Postgres.
283
288
  4. **Compute metrics.** With `--metrics`, computes each metric over the generated data and writes
284
- its known value, a dbt test that checks it, and the Apache Ossie file.
289
+ its known value, a dbt test that checks it, and the Apache Ossie file; with `--lightdash`,
290
+ Lightdash metrics and dimensions in the staging models' YAML.
285
291
 
286
292
  ---
287
293
 
@@ -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. Composite primary and
46
- unique keys hold, and a column that repeats a parent's value through a foreign key
47
- (`orders.customer_email`) holds the email of the order's own customer.
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
@@ -84,6 +86,9 @@ orphans, nulls, invalid values, messy text, late-arriving rows. Every run with d
84
86
  orders, average order value. The run writes each metric's value over the generated data to
85
87
  `metric_values.json`, overall and per month, a dbt test per metric that must reproduce it on the
86
88
  warehouse, and an [Apache Ossie](https://github.com/apache/ossie) semantic model under `osi/`.
89
+ New in 1.14.0: `--lightdash` writes the same metrics into the staging models' YAML as
90
+ [Lightdash](https://www.lightdash.com/) reads them, filters, joins and ratios included, so the
91
+ dashboard's revenue can be checked against the number the run already knows.
87
92
  → [Metrics with known values](https://github.com/JB-Analytica/model2data/blob/main/docs/metrics.md)
88
93
 
89
94
  **Validate in CI.** `model2data validate` checks models and metrics files and prints GitHub
@@ -225,7 +230,7 @@ definition goes in; nothing but synthetic data comes out.
225
230
  `accepted_values` fail for the right reason.
226
231
  - **A team defining metrics once.** Write revenue in one metrics file, check every number the
227
232
  semantic layer or dashboard shows against `metric_values.json`, let `dbt build` prove the SQL,
228
- and hand the definitions on as Apache Ossie.
233
+ and hand the definitions on as Apache Ossie or straight to Lightdash.
229
234
 
230
235
  ## How it works
231
236
 
@@ -251,7 +256,8 @@ model (.model2data.yml or DBML) + metrics file (optional)
251
256
  model's hints, table and column `description:` fields from the model, and a profile for
252
257
  DuckDB (zero-config, file-based) or Postgres.
253
258
  4. **Compute metrics.** With `--metrics`, computes each metric over the generated data and writes
254
- its known value, a dbt test that checks it, and the Apache Ossie file.
259
+ its known value, a dbt test that checks it, and the Apache Ossie file; with `--lightdash`,
260
+ Lightdash metrics and dimensions in the staging models' YAML.
255
261
 
256
262
  ---
257
263
 
@@ -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
  )
@@ -50,13 +51,16 @@ from model2data.metrics import (
50
51
  SPEC_VERSION as METRICS_SPEC_VERSION,
51
52
  )
52
53
  from model2data.metrics import (
54
+ LightdashExport,
53
55
  Metrics,
54
56
  MetricsError,
55
57
  is_metrics_file,
56
58
  known_values,
57
59
  metrics_stem,
58
60
  sibling_model,
61
+ to_lightdash,
59
62
  to_ossie,
63
+ write_lightdash,
60
64
  write_metric_tests,
61
65
  )
62
66
  from model2data.metrics import load as load_metrics
@@ -181,7 +185,7 @@ app = typer.Typer(
181
185
  cls=_GenerateByDefault,
182
186
  help=(
183
187
  "model2data: Generate analytics-ready datasets from a data model.\n\n"
184
- "Given a model -- a .model2data.yml document (spec 0.4.0), the same as JSON,\n"
188
+ "Given a model -- a .model2data.yml document (spec 0.5.0), the same as JSON,\n"
185
189
  "or a DBML file -- this tool produces:\n"
186
190
  "• Synthetic but realistic data\n"
187
191
  "• A runnable dbt project scaffold\n"
@@ -398,8 +402,9 @@ def main(
398
402
  "warn",
399
403
  "--hint-tests",
400
404
  help=(
401
- "Write the model's generation hints as dbt tests (min/max range, after, "
402
- "null_rate, distinct, when, grain) at this severity: error, warn or off. They "
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 "
403
408
  "describe intent, so they warn by default rather than break a first dbt "
404
409
  "build on real data."
405
410
  ),
@@ -471,6 +476,16 @@ def main(
471
476
  "without it. Not picked up from beside the model: pass it."
472
477
  ),
473
478
  ),
479
+ lightdash: bool = typer.Option(
480
+ False,
481
+ "--lightdash",
482
+ help=(
483
+ "Add Lightdash metrics and dimensions to the staging models' YAML\n"
484
+ "(models/staging/stg_*.yml, under config.meta): the --metrics file's metrics,\n"
485
+ "or without one the metrics the model implies. Without it those files are\n"
486
+ "unchanged."
487
+ ),
488
+ ),
474
489
  ):
475
490
  """
476
491
  Generate synthetic data and a dbt project from a model.
@@ -726,6 +741,13 @@ def main(
726
741
  # Metrics: only added files, computed from what the seeds hold
727
742
  # -------------------------
728
743
  metrics_count: Optional[int] = None
744
+ lightdash_export = None
745
+ if _given(lightdash):
746
+ typer.echo("🔦 Adding Lightdash metrics and dimensions to the staging models...")
747
+ lightdash_export = to_lightdash(
748
+ resolve_metrics(model, metrics_doc, model_name=_model_stem(file))
749
+ )
750
+ write_lightdash(dest, lightdash_export)
729
751
  if metrics_doc is not None and metrics_file is not None:
730
752
  typer.echo("📏 Computing each metric's known value and its dbt test...")
731
753
  semantic = resolve_metrics(model, metrics_doc, model_name=_model_stem(file))
@@ -752,6 +774,7 @@ def main(
752
774
  cyclic_tables = get_cyclic_tables()
753
775
  unresolved_keys = get_unresolved_composite_keys()
754
776
  duplicate_unique = get_duplicate_unique_columns()
777
+ capped_tables = get_capped_tables()
755
778
 
756
779
  typer.echo("\n📊 Summary")
757
780
  typer.echo(f" Tables generated: {len(generated_tables)}")
@@ -790,6 +813,13 @@ def main(
790
813
  )
791
814
  for label in duplicate_unique:
792
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}")
793
823
 
794
824
  if metrics_count is not None:
795
825
  typer.echo(
@@ -797,6 +827,13 @@ def main(
797
827
  f"osi/{project_name}.yml, data-tests/metrics/)"
798
828
  )
799
829
 
830
+ if lightdash_export is not None:
831
+ typer.echo(
832
+ f" Lightdash: {len(lightdash_export.metrics)} metrics "
833
+ "(models/staging/stg_*.yml, config.meta)"
834
+ )
835
+ _print_losses("Not expressible in Lightdash, left out", lightdash_export.lossiness)
836
+
800
837
  if report is not None:
801
838
  _print_defects(report)
802
839
 
@@ -810,12 +847,13 @@ def main(
810
847
 
811
848
 
812
849
  def _spec_of(file: Path) -> str:
813
- """The spec a conforming model is written against: 0.4.0, 0.3.0, or 0.2.0 (DBML without
814
- `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)."""
815
852
  version = str(load(file).version)
816
- return (
817
- "0.4.0" if version.startswith("0.4") else "0.3.0" if version.startswith("0.3") else "0.2.0"
818
- )
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"
819
857
 
820
858
 
821
859
  def _print_defects(report: DefectsReport) -> None:
@@ -907,11 +945,12 @@ def validate_command(
907
945
  ),
908
946
  ),
909
947
  ):
910
- """Check that models conform to spec 0.4.0 (or 0.2.x, 0.3.x), printing every issue with its path.
948
+ """Check that models conform to spec 0.5.0 (or 0.2.x-0.4.x), printing every issue with its path.
911
949
 
912
950
  Takes one or more files and/or --glob patterns. A metrics file (*.metrics.yml) is checked
913
951
  against metrics spec 0.1.0 and against its model. Exits 1 when any file has an error;
914
- 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.
915
954
  """
916
955
  if output_format not in ("text", "github"):
917
956
  raise typer.BadParameter("must be 'text' or 'github'", param_hint="--format")
@@ -1002,7 +1041,8 @@ def convert_command(
1002
1041
  force: bool = typer.Option(False, "--force", help="Overwrite the output file if it exists."),
1003
1042
  ):
1004
1043
  """Convert a model -- DBML, typically -- to a .model2data.yml document (spec 0.2.0, or
1005
- 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)."""
1006
1046
  try:
1007
1047
  model = load(file)
1008
1048
  except ModelError as error:
@@ -1074,7 +1114,14 @@ metrics_app = typer.Typer(
1074
1114
  )
1075
1115
  app.add_typer(metrics_app, name="metrics")
1076
1116
 
1077
- _METRICS_EXPORTS = ("ossie",)
1117
+ _METRICS_EXPORTS = ("ossie", "lightdash")
1118
+
1119
+
1120
+ def _print_losses(heading: str, losses: list) -> None:
1121
+ if losses:
1122
+ typer.echo(f"ℹ️ {heading}:")
1123
+ for loss in losses:
1124
+ typer.echo(f" - {loss}")
1078
1125
 
1079
1126
 
1080
1127
  def _metrics_inputs(file: Path, metrics_file: Optional[Path]) -> Any:
@@ -1138,7 +1185,14 @@ def metrics_list_command(
1138
1185
  def metrics_export_command(
1139
1186
  file: Path = _MODEL_OPTION,
1140
1187
  metrics_file: Optional[Path] = _METRICS_OPTION,
1141
- to: str = typer.Option("ossie", "--to", help="The format: 'ossie' (Apache Ossie 0.1.1, YAML)."),
1188
+ to: str = typer.Option(
1189
+ "ossie",
1190
+ "--to",
1191
+ help=(
1192
+ "The format: 'ossie' (Apache Ossie 0.1.1, YAML) or 'lightdash' (dbt model "
1193
+ "properties with Lightdash's config.meta, YAML)."
1194
+ ),
1195
+ ),
1142
1196
  output: Optional[Path] = typer.Option( # noqa: B008
1143
1197
  None,
1144
1198
  "--output",
@@ -1153,6 +1207,11 @@ def metrics_export_command(
1153
1207
  Ossie datasets read the staging models of the dbt project `generate` writes
1154
1208
  (staging.stg_<table>). What Ossie 0.1.1 cannot say is listed at the top of the file,
1155
1209
  and carried in custom_extensions.
1210
+
1211
+ Lightdash's file describes those staging models (stg_<table>) as a dbt properties
1212
+ file. dbt reads a model's properties from one file only, so in a project `generate`
1213
+ wrote, use `generate --lightdash` instead: it merges the same meta into the staging
1214
+ models' own YAML. What Lightdash cannot say is listed at the top of the file.
1156
1215
  """
1157
1216
  to = (_given(to) or "ossie").lower()
1158
1217
  if to not in _METRICS_EXPORTS:
@@ -1160,7 +1219,7 @@ def metrics_export_command(
1160
1219
  f"Choose one of: {', '.join(_METRICS_EXPORTS)}.", param_hint="--to"
1161
1220
  )
1162
1221
  semantic = _metrics_inputs(file, _given(metrics_file))
1163
- export = to_ossie(semantic)
1222
+ export = to_ossie(semantic) if to == "ossie" else to_lightdash(semantic)
1164
1223
  text = export.to_yaml()
1165
1224
  output = _given(output)
1166
1225
  if output is None:
@@ -1170,8 +1229,11 @@ def metrics_export_command(
1170
1229
  typer.echo(f"❌ {output} already exists. Use --force to overwrite it.")
1171
1230
  raise typer.Exit(1)
1172
1231
  output.write_text(text, encoding="utf-8")
1232
+ if isinstance(export, LightdashExport):
1233
+ typer.echo(f"✅ Wrote {output} (Lightdash, dbt YAML, {len(export.metrics)} metrics)")
1234
+ _print_losses("Not expressible in Lightdash, left out", export.lossiness)
1235
+ return
1173
1236
  typer.echo(f"✅ Wrote {output} (Apache Ossie 0.1.1, {len(semantic.metrics)} metrics)")
1174
- if export.lossiness:
1175
- typer.echo("ℹ️ Not expressible in Ossie 0.1.1 itself, carried in custom_extensions:")
1176
- for loss in export.lossiness:
1177
- typer.echo(f" - {loss}")
1237
+ _print_losses(
1238
+ "Not expressible in Ossie 0.1.1 itself, carried in custom_extensions", export.lossiness
1239
+ )
@@ -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`, written only for a model that has one, so
32
- a project without `when` is what it was: plain SQL, no dbt package.
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
- tables: Mapping[str, TableDef] = to_engine(model_or_tables).tables
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 = note.get("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 %}