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.
Files changed (198) hide show
  1. {model2data-1.14.0/model2data.egg-info → model2data-1.16.0}/PKG-INFO +8 -6
  2. {model2data-1.14.0 → model2data-1.16.0}/README.md +7 -5
  3. {model2data-1.14.0 → model2data-1.16.0}/README_PYPI.md +7 -5
  4. {model2data-1.14.0 → model2data-1.16.0}/model2data/cli.py +24 -11
  5. {model2data-1.14.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.14.0 → model2data-1.16.0}/model2data/dbt/tests.py +15 -7
  8. {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/apply.py +42 -1
  9. {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/checks.py +37 -0
  10. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/core.py +147 -4
  11. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/days.py +29 -1
  12. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/faker.py +42 -0
  13. {model2data-1.14.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.14.0 → model2data-1.16.0}/model2data/generate/timeline.py +109 -18
  16. {model2data-1.14.0 → model2data-1.16.0}/model2data/guide/setup.md +3 -2
  17. {model2data-1.14.0 → model2data-1.16.0}/model2data/guide/triage.md +11 -1
  18. {model2data-1.14.0 → model2data-1.16.0}/model2data/guide/tune.md +22 -4
  19. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/__init__.py +3 -2
  20. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/dbml.py +10 -5
  21. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/document.py +19 -2
  22. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/errors.py +22 -0
  23. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/types.py +1 -1
  24. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/validate.py +227 -40
  25. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/PUBLISHING.md +14 -12
  26. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/README.md +48 -5
  27. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/examples/coffee_webshop.model2data.yml +3 -3
  28. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/model.schema.json +24 -9
  29. {model2data-1.14.0 → model2data-1.16.0/model2data.egg-info}/PKG-INFO +8 -6
  30. {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/SOURCES.txt +3 -0
  31. {model2data-1.14.0 → model2data-1.16.0}/pyproject.toml +1 -1
  32. {model2data-1.14.0 → model2data-1.16.0}/tests/test_cli.py +37 -0
  33. {model2data-1.14.0 → model2data-1.16.0}/tests/test_cli_model.py +2 -2
  34. {model2data-1.14.0 → model2data-1.16.0}/tests/test_column_time_hints.py +2 -1
  35. {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects.py +8 -2
  36. {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_dbt.py +31 -0
  37. {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_review.py +4 -2
  38. {model2data-1.14.0 → model2data-1.16.0}/tests/test_distributions.py +8 -6
  39. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_cli.py +1 -1
  40. {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_dbml.py +3 -1
  41. {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_messages.py +4 -4
  42. model2data-1.16.0/tests/test_parent_dates.py +882 -0
  43. {model2data-1.14.0 → model2data-1.16.0}/tests/test_shaping.py +28 -26
  44. {model2data-1.14.0 → model2data-1.16.0}/tests/test_spec_alignment.py +133 -1
  45. {model2data-1.14.0 → model2data-1.16.0}/tests/test_table_seeds.py +11 -5
  46. {model2data-1.14.0 → model2data-1.16.0}/tests/test_timeline.py +31 -28
  47. {model2data-1.14.0 → model2data-1.16.0}/LICENSE +0 -0
  48. {model2data-1.14.0 → model2data-1.16.0}/model2data/__init__.py +0 -0
  49. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/__init__.py +0 -0
  50. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  51. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  52. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  53. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  54. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  55. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  56. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  57. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  58. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  59. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  60. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  61. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  62. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  63. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  64. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  65. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  66. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/constants.py +0 -0
  67. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/database.py +0 -0
  68. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  69. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  70. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  71. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  72. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  73. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  74. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  75. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  76. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  77. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  78. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  79. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  80. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  81. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  82. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  83. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  84. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  85. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  86. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  87. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  88. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  89. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  90. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  91. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  92. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  93. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  94. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  95. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  96. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  97. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  98. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  99. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  100. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  101. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  102. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  103. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  104. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  105. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  106. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  107. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  108. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  109. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  110. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  111. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  112. {model2data-1.14.0 → model2data-1.16.0}/model2data/_vendor/pydbml/tools.py +0 -0
  113. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/__init__.py +0 -0
  114. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/naming.py +0 -0
  115. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/project.py +0 -0
  116. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  117. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  118. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/hint_macros/model2data_when_tests.sql +0 -0
  119. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  120. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  121. {model2data-1.14.0 → model2data-1.16.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  122. {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/__init__.py +0 -0
  123. {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/presets.py +0 -0
  124. {model2data-1.14.0 → model2data-1.16.0}/model2data/defects/report.py +0 -0
  125. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/__init__.py +0 -0
  126. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/history.py +0 -0
  127. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/kinds.py +0 -0
  128. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/options.py +0 -0
  129. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/relationships.py +0 -0
  130. {model2data-1.14.0 → model2data-1.16.0}/model2data/generate/when.py +0 -0
  131. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/__init__.py +0 -0
  132. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/check.py +0 -0
  133. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/columns.py +0 -0
  134. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/dbt.py +0 -0
  135. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/expression.py +0 -0
  136. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/graph.py +0 -0
  137. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/infer.py +0 -0
  138. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/lightdash.py +0 -0
  139. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/ossie.py +0 -0
  140. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/reader.py +0 -0
  141. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/semantic.py +0 -0
  142. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/sql.py +0 -0
  143. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/types.py +0 -0
  144. {model2data-1.14.0 → model2data-1.16.0}/model2data/metrics/values.py +0 -0
  145. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/_yaml.py +0 -0
  146. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/dump.py +0 -0
  147. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/engine.py +0 -0
  148. {model2data-1.14.0 → model2data-1.16.0}/model2data/model/reader.py +0 -0
  149. {model2data-1.14.0 → model2data-1.16.0}/model2data/output.py +0 -0
  150. {model2data-1.14.0 → model2data-1.16.0}/model2data/parse/__init__.py +0 -0
  151. {model2data-1.14.0 → model2data-1.16.0}/model2data/parse/dbml.py +0 -0
  152. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/examples/coffee_webshop.metrics.yml +0 -0
  153. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/metrics/README.md +0 -0
  154. {model2data-1.14.0 → model2data-1.16.0}/model2data/spec/metrics/metrics.schema.json +0 -0
  155. {model2data-1.14.0 → model2data-1.16.0}/model2data/utils.py +0 -0
  156. {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/dependency_links.txt +0 -0
  157. {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/entry_points.txt +0 -0
  158. {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/requires.txt +0 -0
  159. {model2data-1.14.0 → model2data-1.16.0}/model2data.egg-info/top_level.txt +0 -0
  160. {model2data-1.14.0 → model2data-1.16.0}/setup.cfg +0 -0
  161. {model2data-1.14.0 → model2data-1.16.0}/tests/test_as_of_anchor.py +0 -0
  162. {model2data-1.14.0 → model2data-1.16.0}/tests/test_coverage_gaps.py +0 -0
  163. {model2data-1.14.0 → model2data-1.16.0}/tests/test_days.py +0 -0
  164. {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbml_parser.py +0 -0
  165. {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbml_parser_fuzz.py +0 -0
  166. {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_integration.py +0 -0
  167. {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_naming.py +0 -0
  168. {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_project.py +0 -0
  169. {model2data-1.14.0 → model2data-1.16.0}/tests/test_dbt_tests.py +0 -0
  170. {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_replay.py +0 -0
  171. {model2data-1.14.0 → model2data-1.16.0}/tests/test_defects_report.py +0 -0
  172. {model2data-1.14.0 → model2data-1.16.0}/tests/test_determinism.py +0 -0
  173. {model2data-1.14.0 → model2data-1.16.0}/tests/test_examples_model.py +0 -0
  174. {model2data-1.14.0 → model2data-1.16.0}/tests/test_faker_name_inference.py +0 -0
  175. {model2data-1.14.0 → model2data-1.16.0}/tests/test_fast_paths.py +0 -0
  176. {model2data-1.14.0 → model2data-1.16.0}/tests/test_generation.py +0 -0
  177. {model2data-1.14.0 → model2data-1.16.0}/tests/test_guide.py +0 -0
  178. {model2data-1.14.0 → model2data-1.16.0}/tests/test_hint_tests.py +0 -0
  179. {model2data-1.14.0 → model2data-1.16.0}/tests/test_history.py +0 -0
  180. {model2data-1.14.0 → model2data-1.16.0}/tests/test_issue_lines.py +0 -0
  181. {model2data-1.14.0 → model2data-1.16.0}/tests/test_lone_country.py +0 -0
  182. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_dbt.py +0 -0
  183. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_expression.py +0 -0
  184. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_lightdash.py +0 -0
  185. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_ossie.py +0 -0
  186. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_reader.py +0 -0
  187. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_semantic.py +0 -0
  188. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_spec.py +0 -0
  189. {model2data-1.14.0 → model2data-1.16.0}/tests/test_metrics_values.py +0 -0
  190. {model2data-1.14.0 → model2data-1.16.0}/tests/test_model.py +0 -0
  191. {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_edges.py +0 -0
  192. {model2data-1.14.0 → model2data-1.16.0}/tests/test_model_vocabulary.py +0 -0
  193. {model2data-1.14.0 → model2data-1.16.0}/tests/test_options.py +0 -0
  194. {model2data-1.14.0 → model2data-1.16.0}/tests/test_release_stress.py +0 -0
  195. {model2data-1.14.0 → model2data-1.16.0}/tests/test_reproducibility.py +0 -0
  196. {model2data-1.14.0 → model2data-1.16.0}/tests/test_row_identity.py +0 -0
  197. {model2data-1.14.0 → model2data-1.16.0}/tests/test_spec_schema.py +0 -0
  198. {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.14.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
@@ -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
@@ -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
@@ -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.4.0), the same as JSON,\n"
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: 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 "
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 without
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
- return (
844
- "0.4.0" if version.startswith("0.4") else "0.3.0" if version.startswith("0.3") else "0.2.0"
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.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.
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`, 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 %}
@@ -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
- when = any(test.hint == "when" for tests in hinted.values() for test in tests)
72
- write_hint_macros(dest, when=when)
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, set())
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,