model2data 1.10.4__tar.gz → 1.11.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 (169) hide show
  1. {model2data-1.10.4/model2data.egg-info → model2data-1.11.0}/PKG-INFO +3 -2
  2. {model2data-1.10.4 → model2data-1.11.0}/README.md +30 -4
  3. {model2data-1.10.4 → model2data-1.11.0}/README_PYPI.md +2 -1
  4. {model2data-1.10.4 → model2data-1.11.0}/model2data/cli.py +57 -7
  5. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/hint_tests.py +58 -9
  6. model2data-1.11.0/model2data/dbt/templates/hint_macros/model2data_when_tests.sql +56 -0
  7. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/tests.py +2 -1
  8. {model2data-1.10.4 → model2data-1.11.0}/model2data/defects/checks.py +49 -0
  9. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/core.py +203 -55
  10. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/days.py +38 -1
  11. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/faker.py +1 -1
  12. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/hints.py +27 -0
  13. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/kinds.py +6 -2
  14. model2data-1.11.0/model2data/generate/when.py +385 -0
  15. model2data-1.11.0/model2data/guide/setup.md +76 -0
  16. model2data-1.11.0/model2data/guide/triage.md +68 -0
  17. model2data-1.11.0/model2data/guide/tune.md +139 -0
  18. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/__init__.py +1 -1
  19. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/dbml.py +7 -0
  20. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/document.py +17 -4
  21. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/types.py +1 -1
  22. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/validate.py +157 -11
  23. {model2data-1.10.4 → model2data-1.11.0}/model2data/spec/PUBLISHING.md +15 -11
  24. {model2data-1.10.4 → model2data-1.11.0}/model2data/spec/README.md +37 -4
  25. {model2data-1.10.4 → model2data-1.11.0}/model2data/spec/model.schema.json +19 -5
  26. {model2data-1.10.4 → model2data-1.11.0/model2data.egg-info}/PKG-INFO +3 -2
  27. {model2data-1.10.4 → model2data-1.11.0}/model2data.egg-info/SOURCES.txt +8 -1
  28. {model2data-1.10.4 → model2data-1.11.0}/pyproject.toml +2 -1
  29. {model2data-1.10.4 → model2data-1.11.0}/tests/test_cli.py +2 -2
  30. {model2data-1.10.4 → model2data-1.11.0}/tests/test_defects.py +3 -1
  31. {model2data-1.10.4 → model2data-1.11.0}/tests/test_generation.py +70 -6
  32. model2data-1.11.0/tests/test_guide.py +186 -0
  33. {model2data-1.10.4 → model2data-1.11.0}/tests/test_model_messages.py +4 -4
  34. {model2data-1.10.4 → model2data-1.11.0}/tests/test_spec_alignment.py +109 -0
  35. model2data-1.11.0/tests/test_when.py +974 -0
  36. {model2data-1.10.4 → model2data-1.11.0}/LICENSE +0 -0
  37. {model2data-1.10.4 → model2data-1.11.0}/model2data/__init__.py +0 -0
  38. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/__init__.py +0 -0
  39. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/LICENSE +0 -0
  40. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  41. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/__init__.py +0 -0
  42. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  43. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  44. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  45. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  46. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  47. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  48. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  49. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  50. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  51. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  52. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  53. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  54. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  55. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/constants.py +0 -0
  56. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/database.py +0 -0
  57. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  58. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  59. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  60. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  61. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  62. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  63. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  64. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  65. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  66. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  67. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  68. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  69. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  70. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/exceptions.py +0 -0
  71. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  72. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  73. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  74. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  75. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  76. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  77. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  78. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  79. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  80. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  81. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  82. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  83. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  84. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  85. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  86. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  87. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  88. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  89. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  90. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  91. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  92. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  93. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  94. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  95. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  96. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  97. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  98. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  99. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  100. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  101. {model2data-1.10.4 → model2data-1.11.0}/model2data/_vendor/pydbml/tools.py +0 -0
  102. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/__init__.py +0 -0
  103. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/naming.py +0 -0
  104. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/project.py +0 -0
  105. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  106. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  107. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  108. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  109. {model2data-1.10.4 → model2data-1.11.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  110. {model2data-1.10.4 → model2data-1.11.0}/model2data/defects/__init__.py +0 -0
  111. {model2data-1.10.4 → model2data-1.11.0}/model2data/defects/apply.py +0 -0
  112. {model2data-1.10.4 → model2data-1.11.0}/model2data/defects/presets.py +0 -0
  113. {model2data-1.10.4 → model2data-1.11.0}/model2data/defects/report.py +0 -0
  114. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/__init__.py +0 -0
  115. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/history.py +0 -0
  116. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/options.py +0 -0
  117. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/relationships.py +0 -0
  118. {model2data-1.10.4 → model2data-1.11.0}/model2data/generate/timeline.py +0 -0
  119. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/_yaml.py +0 -0
  120. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/dump.py +0 -0
  121. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/engine.py +0 -0
  122. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/errors.py +0 -0
  123. {model2data-1.10.4 → model2data-1.11.0}/model2data/model/reader.py +0 -0
  124. {model2data-1.10.4 → model2data-1.11.0}/model2data/output.py +0 -0
  125. {model2data-1.10.4 → model2data-1.11.0}/model2data/parse/__init__.py +0 -0
  126. {model2data-1.10.4 → model2data-1.11.0}/model2data/parse/dbml.py +0 -0
  127. {model2data-1.10.4 → model2data-1.11.0}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
  128. {model2data-1.10.4 → model2data-1.11.0}/model2data/utils.py +0 -0
  129. {model2data-1.10.4 → model2data-1.11.0}/model2data.egg-info/dependency_links.txt +0 -0
  130. {model2data-1.10.4 → model2data-1.11.0}/model2data.egg-info/entry_points.txt +0 -0
  131. {model2data-1.10.4 → model2data-1.11.0}/model2data.egg-info/requires.txt +0 -0
  132. {model2data-1.10.4 → model2data-1.11.0}/model2data.egg-info/top_level.txt +0 -0
  133. {model2data-1.10.4 → model2data-1.11.0}/setup.cfg +0 -0
  134. {model2data-1.10.4 → model2data-1.11.0}/tests/test_as_of_anchor.py +0 -0
  135. {model2data-1.10.4 → model2data-1.11.0}/tests/test_cli_model.py +0 -0
  136. {model2data-1.10.4 → model2data-1.11.0}/tests/test_column_time_hints.py +0 -0
  137. {model2data-1.10.4 → model2data-1.11.0}/tests/test_coverage_gaps.py +0 -0
  138. {model2data-1.10.4 → model2data-1.11.0}/tests/test_days.py +0 -0
  139. {model2data-1.10.4 → model2data-1.11.0}/tests/test_dbml_parser.py +0 -0
  140. {model2data-1.10.4 → model2data-1.11.0}/tests/test_dbml_parser_fuzz.py +0 -0
  141. {model2data-1.10.4 → model2data-1.11.0}/tests/test_dbt_integration.py +0 -0
  142. {model2data-1.10.4 → model2data-1.11.0}/tests/test_dbt_naming.py +0 -0
  143. {model2data-1.10.4 → model2data-1.11.0}/tests/test_dbt_project.py +0 -0
  144. {model2data-1.10.4 → model2data-1.11.0}/tests/test_dbt_tests.py +0 -0
  145. {model2data-1.10.4 → model2data-1.11.0}/tests/test_defects_dbt.py +0 -0
  146. {model2data-1.10.4 → model2data-1.11.0}/tests/test_defects_replay.py +0 -0
  147. {model2data-1.10.4 → model2data-1.11.0}/tests/test_defects_report.py +0 -0
  148. {model2data-1.10.4 → model2data-1.11.0}/tests/test_defects_review.py +0 -0
  149. {model2data-1.10.4 → model2data-1.11.0}/tests/test_determinism.py +0 -0
  150. {model2data-1.10.4 → model2data-1.11.0}/tests/test_distributions.py +0 -0
  151. {model2data-1.10.4 → model2data-1.11.0}/tests/test_examples_model.py +0 -0
  152. {model2data-1.10.4 → model2data-1.11.0}/tests/test_faker_name_inference.py +0 -0
  153. {model2data-1.10.4 → model2data-1.11.0}/tests/test_fast_paths.py +0 -0
  154. {model2data-1.10.4 → model2data-1.11.0}/tests/test_hint_tests.py +0 -0
  155. {model2data-1.10.4 → model2data-1.11.0}/tests/test_history.py +0 -0
  156. {model2data-1.10.4 → model2data-1.11.0}/tests/test_issue_lines.py +0 -0
  157. {model2data-1.10.4 → model2data-1.11.0}/tests/test_lone_country.py +0 -0
  158. {model2data-1.10.4 → model2data-1.11.0}/tests/test_model.py +0 -0
  159. {model2data-1.10.4 → model2data-1.11.0}/tests/test_model_dbml.py +0 -0
  160. {model2data-1.10.4 → model2data-1.11.0}/tests/test_model_edges.py +0 -0
  161. {model2data-1.10.4 → model2data-1.11.0}/tests/test_model_vocabulary.py +0 -0
  162. {model2data-1.10.4 → model2data-1.11.0}/tests/test_options.py +0 -0
  163. {model2data-1.10.4 → model2data-1.11.0}/tests/test_release_stress.py +0 -0
  164. {model2data-1.10.4 → model2data-1.11.0}/tests/test_reproducibility.py +0 -0
  165. {model2data-1.10.4 → model2data-1.11.0}/tests/test_row_identity.py +0 -0
  166. {model2data-1.10.4 → model2data-1.11.0}/tests/test_shaping.py +0 -0
  167. {model2data-1.10.4 → model2data-1.11.0}/tests/test_spec_schema.py +0 -0
  168. {model2data-1.10.4 → model2data-1.11.0}/tests/test_table_seeds.py +0 -0
  169. {model2data-1.10.4 → model2data-1.11.0}/tests/test_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.10.4
3
+ Version: 1.11.0
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Author: JB Analytica
6
6
  License-Expression: MIT
@@ -132,7 +132,7 @@ access required.
132
132
  ## The model file
133
133
 
134
134
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its format
135
- is [spec 0.3.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
135
+ is [spec 0.4.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
136
136
  check against:
137
137
 
138
138
  ```yaml
@@ -292,6 +292,7 @@ dbt package, so `dbt build` still works offline):
292
292
  | `generate: {after: other}` | `model2data_not_before` (where both are not null) | none |
293
293
  | `generate: {null_rate}` | `model2data_max_null_share`: nulls at most `null_rate` + tolerance | `--test-tolerance`, default 0.1 |
294
294
  | `generate: {distinct: n}` | `model2data_max_distinct`: at most `n` distinct values | none |
295
+ | `generate: {when}` | `model2data_when`: null exactly where the condition does not hold (with `null_rate`, only there; its null share is then tested among the matching rows) | none |
295
296
  | `grain` on a table | `model2data_unique_combination` | none |
296
297
  | an enum-typed column | `accepted_values` (always written) | none |
297
298
 
@@ -16,6 +16,13 @@ relationship-preserving synthetic data
16
16
  DuckDB or Postgres profile. No sample data to hunt down, no dbt boilerplate to hand-write, no
17
17
  production data to risk exposing.
18
18
 
19
+ Driving model2data from a coding agent? Have it start here; the instructions ship with the
20
+ installed version and pick a topic from the current directory:
21
+
22
+ ```bash
23
+ uvx model2data@latest guide
24
+ ```
25
+
19
26
  A working analytics stack — real (synthetic) data, tested dbt models, queryable in
20
27
  DuckDB — from a schema file, in seconds:
21
28
 
@@ -123,7 +130,7 @@ flowchart LR
123
130
  ## The model file
124
131
 
125
132
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its
126
- format is [spec 0.3.0](model2data/spec/README.md), with a JSON Schema
133
+ format is [spec 0.4.0](model2data/spec/README.md), with a JSON Schema
127
134
  ([`model.schema.json`](model2data/spec/model.schema.json)) your editor can autocomplete and check
128
135
  against:
129
136
 
@@ -222,14 +229,14 @@ jobs:
222
229
  ```
223
230
 
224
231
  Inputs: `files` (glob, default `**/*.model2data.yml`), `version` (default: the release the tag
225
- points at, e.g. `1.10.4`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
232
+ points at, e.g. `1.11.0`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
226
233
 
227
234
  **pre-commit**
228
235
 
229
236
  ```yaml
230
237
  repos:
231
238
  - repo: https://github.com/JB-Analytica/model2data
232
- rev: v1.10.4
239
+ rev: v1.11.0
233
240
  hooks:
234
241
  - id: model2data-validate
235
242
  ```
@@ -243,7 +250,7 @@ model2data:
243
250
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
244
251
  changes: ["**/*.model2data.yml"]
245
252
  script:
246
- - pip install model2data==1.10.4
253
+ - pip install model2data==1.11.0
247
254
  - model2data validate --glob "**/*.model2data.yml"
248
255
  ```
249
256
 
@@ -394,6 +401,24 @@ shipped_at:
394
401
  generate: {after: ordered_at}
395
402
  ```
396
403
 
404
+ A column that only has a value in some states says which with `when` (spec 0.4.0: the model says
405
+ `model2data: 0.4.0`): it holds a value on
406
+ exactly the rows whose named column holds one of the listed values, and is null on every other
407
+ row — no `pending` order with a `shipped_at`, no `cancelled` subscription without a
408
+ `cancelled_at`:
409
+
410
+ ```yaml
411
+ shipped_at:
412
+ type: timestamp
413
+ generate: {after: ordered_at, when: {status: [shipped, delivered]}}
414
+ ```
415
+
416
+ The named column is another column of the table (an enum, boolean, number or text column; several
417
+ named must all match), and the column carrying `when` is nullable. On the matching rows it is
418
+ never null, unless it has a `null_rate`, which then counts only those rows. On days after the
419
+ first (`--days`), a row that moves into a listed status gets its value that day and a row that
420
+ moves out loses it. Every other column comes out exactly as it would without the hint.
421
+
397
422
  The flags above (or `run.shape` in the model) shape every date and timestamp column the same
398
423
  way, run-wide. `business_hours`, `growth`, and `seasonality` column hints override that for one
399
424
  column at a time — the whole point being a run can be uniform everywhere except the one column
@@ -525,6 +550,7 @@ dbt package, so `dbt build` still works offline):
525
550
  | `generate: {after: other}` | `model2data_not_before` (where both are not null) | none |
526
551
  | `generate: {null_rate}` | `model2data_max_null_share`: nulls at most `null_rate` + tolerance | `--test-tolerance`, default 0.1 |
527
552
  | `generate: {distinct: n}` | `model2data_max_distinct`: at most `n` distinct values | none |
553
+ | `generate: {when}` | `model2data_when`: null exactly where the condition does not hold (with `null_rate`, only there; its null share is then tested among the matching rows) | none |
528
554
  | `grain` on a table | `model2data_unique_combination` | none |
529
555
  | an enum-typed column | `accepted_values` (always written) | none |
530
556
 
@@ -85,7 +85,7 @@ access required.
85
85
  ## The model file
86
86
 
87
87
  A model is one YAML document, `<name>.model2data.yml` — or the same document as JSON. Its format
88
- is [spec 0.3.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
88
+ is [spec 0.4.0](https://github.com/JB-Analytica/model2data/blob/main/model2data/spec/README.md), with a JSON Schema your editor can autocomplete and
89
89
  check against:
90
90
 
91
91
  ```yaml
@@ -245,6 +245,7 @@ dbt package, so `dbt build` still works offline):
245
245
  | `generate: {after: other}` | `model2data_not_before` (where both are not null) | none |
246
246
  | `generate: {null_rate}` | `model2data_max_null_share`: nulls at most `null_rate` + tolerance | `--test-tolerance`, default 0.1 |
247
247
  | `generate: {distinct: n}` | `model2data_max_distinct`: at most `n` distinct values | none |
248
+ | `generate: {when}` | `model2data_when`: null exactly where the condition does not hold (with `null_rate`, only there; its null share is then tested among the matching rows) | none |
248
249
  | `grain` on a table | `model2data_unique_combination` | none |
249
250
  | an enum-typed column | `accepted_values` (always written) | none |
250
251
 
@@ -2,6 +2,8 @@ import random
2
2
  import shutil
3
3
  from collections.abc import Iterable
4
4
  from datetime import date, datetime
5
+ from enum import Enum
6
+ from importlib import resources
5
7
  from pathlib import Path
6
8
  from typing import Any, Optional
7
9
 
@@ -163,13 +165,13 @@ app = typer.Typer(
163
165
  cls=_GenerateByDefault,
164
166
  help=(
165
167
  "model2data: Generate analytics-ready datasets from a data model.\n\n"
166
- "Given a model -- a .model2data.yml document (spec 0.3.0), the same as JSON,\n"
168
+ "Given a model -- a .model2data.yml document (spec 0.4.0), the same as JSON,\n"
167
169
  "or a DBML file -- this tool produces:\n"
168
170
  "• Synthetic but realistic data\n"
169
171
  "• A runnable dbt project scaffold\n"
170
172
  "• dbt seeds, staging models, and profiles\n\n"
171
173
  "`model2data --file MODEL` generates; `validate` and `convert` check and\n"
172
- "convert a model."
174
+ "convert a model. Agents: run `model2data guide` first."
173
175
  ),
174
176
  add_completion=False,
175
177
  )
@@ -369,7 +371,7 @@ def main(
369
371
  "--hint-tests",
370
372
  help=(
371
373
  "Write the model's generation hints as dbt tests (min/max range, after, "
372
- "null_rate, distinct, grain) at this severity: error, warn or off. They "
374
+ "null_rate, distinct, when, grain) at this severity: error, warn or off. They "
373
375
  "describe intent, so they warn by default rather than break a first dbt "
374
376
  "build on real data."
375
377
  ),
@@ -735,8 +737,12 @@ def main(
735
737
 
736
738
 
737
739
  def _spec_of(file: Path) -> str:
738
- """The spec a conforming model is written against: 0.3.0, or 0.2.0 (DBML included)."""
739
- return "0.3.0" if str(load(file).version).startswith("0.3") else "0.2.0"
740
+ """The spec a conforming model is written against: 0.4.0, 0.3.0, or 0.2.0 (DBML without
741
+ `when` included)."""
742
+ version = str(load(file).version)
743
+ return (
744
+ "0.4.0" if version.startswith("0.4") else "0.3.0" if version.startswith("0.3") else "0.2.0"
745
+ )
740
746
 
741
747
 
742
748
  def _print_defects(report: DefectsReport) -> None:
@@ -815,7 +821,7 @@ def validate_command(
815
821
  False, "--require-files", help="Exit 1 when no model file was given or matched."
816
822
  ),
817
823
  ):
818
- """Check that models conform to spec 0.3.0 (or 0.2.x), printing every issue with its path.
824
+ """Check that models conform to spec 0.4.0 (or 0.2.x, 0.3.x), printing every issue with its path.
819
825
 
820
826
  Takes one or more files and/or --glob patterns. Exits 1 when any file has an error;
821
827
  warnings are printed, and the model conforms.
@@ -874,7 +880,7 @@ def convert_command(
874
880
  force: bool = typer.Option(False, "--force", help="Overwrite the output file if it exists."),
875
881
  ):
876
882
  """Convert a model -- DBML, typically -- to a .model2data.yml document (spec 0.2.0, or
877
- 0.3.0 when it has defects)."""
883
+ 0.3.0 when it has defects, 0.4.0 when it has `when`)."""
878
884
  try:
879
885
  model = load(file)
880
886
  except ModelError as error:
@@ -891,3 +897,47 @@ def convert_command(
891
897
  raise typer.Exit(1)
892
898
  output.write_text(text, encoding="utf-8")
893
899
  typer.echo(f"✅ Wrote {output}")
900
+
901
+
902
+ class GuideTopic(str, Enum):
903
+ setup = "setup"
904
+ triage = "triage"
905
+ tune = "tune"
906
+
907
+
908
+ _MODEL_PATTERNS = ("*.model2data.yml", "*.model2data.yaml", "*.model2data.json", "*.dbml")
909
+
910
+
911
+ def _detect_topic(cwd: Path) -> tuple[GuideTopic, str]:
912
+ """`triage` when the current directory holds a model, else `setup`, and why.
913
+
914
+ Reads file names in `cwd` only: no subdirectories, no network.
915
+ """
916
+ models = sorted(path.name for pattern in _MODEL_PATTERNS for path in cwd.glob(pattern))
917
+ if models:
918
+ return GuideTopic.triage, f"found {', '.join(models)}"
919
+ return GuideTopic.setup, "no *.model2data.yml or *.dbml in this directory"
920
+
921
+
922
+ @app.command("guide")
923
+ def guide_command(
924
+ topic: Optional[GuideTopic] = typer.Argument( # noqa: B008
925
+ None,
926
+ help=(
927
+ "setup: no model here yet -- install, write one, first run. "
928
+ "triage: what validate, generate and dbt build printed, and what to do. "
929
+ "tune: every option, model key and exit code. "
930
+ "Default: picked from the current directory (a *.model2data.yml or *.dbml "
931
+ "file in it means triage)."
932
+ ),
933
+ ),
934
+ ):
935
+ """Print instructions for an agent about to use model2data, as Markdown.
936
+
937
+ Reads nothing but file names in the current directory and writes nothing.
938
+ """
939
+ if topic is None:
940
+ topic, reason = _detect_topic(Path.cwd())
941
+ typer.echo(f"# model2data guide: {reason} -> {topic.value}\n")
942
+ page = resources.files("model2data").joinpath(f"guide/{topic.value}.md")
943
+ typer.echo(page.read_text("utf-8"), nl=False)
@@ -14,19 +14,27 @@ What becomes a test, and what does not:
14
14
  | `generate.after: other` | `model2data_not_before` | none |
15
15
  | `generate.null_rate` | `model2data_max_null_share` | yes |
16
16
  | `generate.distinct: n` | `model2data_max_distinct` | none |
17
+ | `generate.when` | `model2data_when` | none |
17
18
  | table `grain` | `model2data_unique_combination` | none |
18
19
 
20
+ A column with `when` and `null_rate` has its null share tested among the rows
21
+ `when` matches (`model2data_when_max_null_share`), since only those rows can
22
+ hold a value, and its `model2data_when` test lets those rows be null.
23
+
19
24
  Enum columns keep the `accepted_values` test `generate_dbt_yml` always wrote.
20
25
  `true_rate`, `weights`, `skew`, `distribution` and the temporal shape hints
21
26
  (`business_hours`, `growth`, `seasonality`) describe a statistical shape, not a
22
27
  constraint a row can break, so they produce no test.
23
28
 
24
29
  The generic tests live in `macros/model2data_hint_tests.sql` of the generated
25
- project (see `write_hint_macros`): plain SQL, no dbt package.
30
+ 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.
26
33
  """
27
34
 
28
35
  from __future__ import annotations
29
36
 
37
+ import copy
30
38
  from collections.abc import Mapping
31
39
  from dataclasses import dataclass, field
32
40
  from pathlib import Path
@@ -43,6 +51,8 @@ DEFAULT_TOLERANCE = 0.1
43
51
 
44
52
  MACROS_FILE = "model2data_hint_tests.sql"
45
53
  _MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / MACROS_FILE
54
+ WHEN_MACROS_FILE = "model2data_when_tests.sql"
55
+ _WHEN_MACROS_SOURCE = Path(__file__).parent / "templates" / "hint_macros" / WHEN_MACROS_FILE
46
56
 
47
57
 
48
58
  @dataclass(frozen=True)
@@ -53,7 +63,7 @@ class HintTest:
53
63
  `column` is None for a table-level test (grain). `test` is the generic
54
64
  test's name, `arguments` its parameters as dbt's `arguments:` block, and
55
65
  `hint` the hint it comes from (`min`, `max`, `after`, `null_rate`,
56
- `distinct`, `grain`). `min` and `max` of one column share one test.
66
+ `distinct`, `when`, `grain`). `min` and `max` of one column share one test.
57
67
  """
58
68
 
59
69
  table: str
@@ -148,14 +158,39 @@ def _column_tests(
148
158
  tests.append(HintTest(table, column.name, "model2data_not_before", arguments, "after"))
149
159
 
150
160
  null_rate = note.get("null_rate")
151
- if isinstance(null_rate, (int, float)) and not isinstance(null_rate, bool):
161
+ if not isinstance(null_rate, (int, float)) or isinstance(null_rate, bool):
162
+ null_rate = None
163
+ conditions = _when_conditions(note.get("when"), kinds_of)
164
+ if conditions is not None:
165
+ when_arguments: dict[str, Any] = {"conditions": conditions}
166
+ if null_rate is not None:
167
+ when_arguments["required"] = False
168
+ tests.append(HintTest(table, column.name, "model2data_when", when_arguments, "when"))
169
+
170
+ if null_rate is not None:
152
171
  # Rounded so 0.1 + 0.1 reaches the YAML as 0.2, not 0.2000000000000001.
153
172
  limit = round(min(1.0, null_rate + tolerance), 10)
154
- tests.append(
155
- HintTest(
156
- table, column.name, "model2data_max_null_share", {"max_share": limit}, "null_rate"
173
+ if conditions is None:
174
+ tests.append(
175
+ HintTest(
176
+ table,
177
+ column.name,
178
+ "model2data_max_null_share",
179
+ {"max_share": limit},
180
+ "null_rate",
181
+ )
182
+ )
183
+ else:
184
+ tests.append(
185
+ HintTest(
186
+ table,
187
+ column.name,
188
+ "model2data_when_max_null_share",
189
+ # Its own copy, or the YAML would share the mapping as an anchor.
190
+ {"conditions": copy.deepcopy(conditions), "max_share": limit},
191
+ "null_rate",
192
+ )
157
193
  )
158
- )
159
194
 
160
195
  distinct = note.get("distinct")
161
196
  if isinstance(distinct, int) and not isinstance(distinct, bool) and distinct > 0:
@@ -167,14 +202,28 @@ def _column_tests(
167
202
  return tests
168
203
 
169
204
 
170
- def write_hint_macros(dest: Path) -> Path:
205
+ def _when_conditions(when: Any, kinds_of: dict[str, ColumnDef]) -> Optional[dict[str, list]]:
206
+ """A `when` hint as the test's `conditions`: an enum's values as the member text it holds."""
207
+ if not isinstance(when, dict) or not when or not all(name in kinds_of for name in when):
208
+ return None
209
+ return {
210
+ name: [str(value) for value in values] if kinds_of[name].enum_values else list(values)
211
+ for name, values in when.items()
212
+ }
213
+
214
+
215
+ def write_hint_macros(dest: Path, *, when: bool = False) -> Path:
171
216
  """Write the generic tests into `dest/macros/`, and return the file's path.
172
217
 
173
218
  A project whose YAML holds `hint_tests_for` tests needs this file next to
174
- it; `generate_dbt_yml` writes it itself when it emits any.
219
+ it; `generate_dbt_yml` writes it itself when it emits any. With `when`, the
220
+ file of the `when` tests is written beside it (`WHEN_MACROS_FILE`), which a
221
+ project holding a `model2data_when` test needs too.
175
222
  """
176
223
  macros = dest / "macros"
177
224
  macros.mkdir(parents=True, exist_ok=True)
178
225
  target = macros / MACROS_FILE
179
226
  target.write_text(_MACROS_SOURCE.read_text())
227
+ if when:
228
+ (macros / WHEN_MACROS_FILE).write_text(_WHEN_MACROS_SOURCE.read_text())
180
229
  return target
@@ -0,0 +1,56 @@
1
+ {#-
2
+ Generic tests model2data writes for a column's `when` hint: the column holds a
3
+ value only on the rows whose named columns hold one of the listed values.
4
+ Written beside model2data_hint_tests.sql, and only by a model that has a
5
+ `when`. Plain ANSI SQL, runs on DuckDB and Postgres.
6
+ `conditions` maps a raw column name to the values it must hold; a row whose
7
+ named column is null does not match.
8
+ -#}
9
+
10
+ {% macro model2data_when_literal(value) -%}
11
+ {%- if value is sameas true -%}true
12
+ {%- elif value is sameas false -%}false
13
+ {%- elif value is number -%}{{ value }}
14
+ {%- else -%}'{{ value | string | replace("'", "''") }}'
15
+ {%- endif -%}
16
+ {%- endmacro %}
17
+
18
+
19
+ {% macro model2data_when_matches(conditions) -%}
20
+ (case when true
21
+ {%- for column, values in conditions.items() %}
22
+ and {{ adapter.quote(column) }} in (
23
+ {%- for value in values -%}
24
+ {{ model2data_when_literal(value) }}{% if not loop.last %}, {% endif %}
25
+ {%- endfor -%}
26
+ )
27
+ {%- endfor %}
28
+ then 1 else 0 end = 1)
29
+ {%- endmacro %}
30
+
31
+
32
+ {#-
33
+ A row that does not match holds a value, or (unless `required` is false, for
34
+ a column with a `null_rate`) a row that matches holds none.
35
+ -#}
36
+ {% test model2data_when(model, column_name, conditions, required=true) %}
37
+ select {{ column_name }} as value
38
+ from {{ model }}
39
+ where (not {{ model2data_when_matches(conditions) }} and {{ column_name }} is not null)
40
+ {%- if required %}
41
+ or ({{ model2data_when_matches(conditions) }} and {{ column_name }} is null)
42
+ {%- endif %}
43
+ {% endtest %}
44
+
45
+
46
+ {#- `null_rate` with `when`: the null share among the matching rows only. -#}
47
+ {% test model2data_when_max_null_share(model, column_name, conditions, max_share) %}
48
+ select null_share
49
+ from (
50
+ select
51
+ avg(case when {{ column_name }} is null then 1.0 else 0.0 end) as null_share
52
+ from {{ model }}
53
+ where {{ model2data_when_matches(conditions) }}
54
+ ) as shares
55
+ where null_share > {{ max_share }}
56
+ {% endtest %}
@@ -68,7 +68,8 @@ def generate_dbt_yml(
68
68
 
69
69
  hinted = _hinted(tables, hint_tests, test_tolerance)
70
70
  if hinted:
71
- write_hint_macros(dest)
71
+ when = any(test.hint == "when" for tests in hinted.values() for test in tests)
72
+ write_hint_macros(dest, when=when)
72
73
  if any(_history_entries(table) for table in tables.values()):
73
74
  write_history_macros(dest)
74
75
  fk_map = _fk_map(tables, refs)
@@ -14,6 +14,10 @@ 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_when` and `model2data_when_max_null_share`: as their macros in
18
+ `dbt/templates/hint_macros/model2data_when_tests.sql`, a row matching when
19
+ each named column's text is one of its values (a boolean as `true`/`false`
20
+ in any case, a number as a number).
17
21
  - `model2data_unique_combination` and a composite key's `unique_combination`: a
18
22
  combination of values on two rows, nulls grouping together as in `group by`.
19
23
  - A history table's `model2data_one_current_row` (a key with other than one
@@ -342,6 +346,49 @@ def _max_null_share(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache:
342
346
  return nulls / len(frame) > test.arguments["max_share"]
343
347
 
344
348
 
349
+ def _matches(test: DbtTest, frame: pd.DataFrame, cache: Columns) -> list[bool]:
350
+ """Per row: does each column of the test's `conditions` hold one of its values?"""
351
+ result = [True] * len(frame)
352
+ for column, values in test.arguments["conditions"].items():
353
+ texts = {value for value in values if isinstance(value, str)}
354
+ flags = {str(value).lower() for value in values if isinstance(value, bool)}
355
+ numbers = {
356
+ float(value)
357
+ for value in values
358
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
359
+ }
360
+ for row, text in enumerate(cache.values(frame, column)):
361
+ if text is None or not (
362
+ text in texts or text.lower() in flags or _number(text) in numbers
363
+ ):
364
+ result[row] = False
365
+ return result
366
+
367
+
368
+ def _when(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns) -> bool:
369
+ required = test.arguments.get("required", True)
370
+ values = cache.values(frame, str(test.column))
371
+ for matches, value in zip(_matches(test, frame, cache), values, strict=True):
372
+ if (not matches and value is not None) or (required and matches and value is None):
373
+ return True
374
+ return False
375
+
376
+
377
+ def _when_max_null_share(
378
+ test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns
379
+ ) -> bool:
380
+ values = cache.values(frame, str(test.column))
381
+ held = [
382
+ value
383
+ for matches, value in zip(_matches(test, frame, cache), values, strict=True)
384
+ if matches
385
+ ]
386
+ if not held:
387
+ return False
388
+ nulls = sum(value is None for value in held)
389
+ return nulls / len(held) > test.arguments["max_share"]
390
+
391
+
345
392
  def _max_distinct(test: DbtTest, frame: pd.DataFrame, seeded: Mapping, cache: Columns) -> bool:
346
393
  return len(cache.number_set(frame, str(test.column))) > test.arguments["max_count"]
347
394
 
@@ -388,6 +435,8 @@ _CHECKS: dict[str, Callable[[DbtTest, pd.DataFrame, Mapping, Columns], bool]] =
388
435
  "model2data_not_before": _not_before,
389
436
  "model2data_max_null_share": _max_null_share,
390
437
  "model2data_max_distinct": _max_distinct,
438
+ "model2data_when": _when,
439
+ "model2data_when_max_null_share": _when_max_null_share,
391
440
  "model2data_unique_combination": _unique_combination,
392
441
  "unique_combination": _unique_combination,
393
442
  "model2data_one_current_row": _one_current_row,