model2data 1.10.3__tar.gz → 1.10.4__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 (162) hide show
  1. {model2data-1.10.3/model2data.egg-info → model2data-1.10.4}/PKG-INFO +1 -1
  2. {model2data-1.10.3 → model2data-1.10.4}/README.md +3 -3
  3. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/days.py +16 -3
  4. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/faker.py +29 -0
  5. {model2data-1.10.3 → model2data-1.10.4/model2data.egg-info}/PKG-INFO +1 -1
  6. {model2data-1.10.3 → model2data-1.10.4}/pyproject.toml +1 -1
  7. {model2data-1.10.3 → model2data-1.10.4}/tests/test_cli_model.py +31 -0
  8. {model2data-1.10.3 → model2data-1.10.4}/tests/test_days.py +26 -0
  9. {model2data-1.10.3 → model2data-1.10.4}/tests/test_generation.py +64 -0
  10. {model2data-1.10.3 → model2data-1.10.4}/LICENSE +0 -0
  11. {model2data-1.10.3 → model2data-1.10.4}/README_PYPI.md +0 -0
  12. {model2data-1.10.3 → model2data-1.10.4}/model2data/__init__.py +0 -0
  13. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/__init__.py +0 -0
  14. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/LICENSE +0 -0
  15. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/VENDORED.md +0 -0
  16. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/__init__.py +0 -0
  17. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/__init__.py +0 -0
  18. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/base.py +0 -0
  19. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/column.py +0 -0
  20. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/enum.py +0 -0
  21. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/expression.py +0 -0
  22. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/index.py +0 -0
  23. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/note.py +0 -0
  24. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/project.py +0 -0
  25. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/reference.py +0 -0
  26. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/sticky_note.py +0 -0
  27. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/table.py +0 -0
  28. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/_classes/table_group.py +0 -0
  29. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/classes/__init__.py +0 -0
  30. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/constants.py +0 -0
  31. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/database.py +0 -0
  32. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/__init__.py +0 -0
  33. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/column.py +0 -0
  34. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/common.py +0 -0
  35. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/enum.py +0 -0
  36. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/generic.py +0 -0
  37. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/index.py +0 -0
  38. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/project.py +0 -0
  39. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/records.py +0 -0
  40. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/reference.py +0 -0
  41. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/sticky_note.py +0 -0
  42. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/table.py +0 -0
  43. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/table_group.py +0 -0
  44. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/definitions/table_partial.py +0 -0
  45. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/exceptions.py +0 -0
  46. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/parser/__init__.py +0 -0
  47. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/parser/blueprints.py +0 -0
  48. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/parser/parser.py +0 -0
  49. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/__init__.py +0 -0
  50. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/base.py +0 -0
  51. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/__init__.py +0 -0
  52. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/__init__.py +0 -0
  53. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/column.py +0 -0
  54. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/enum.py +0 -0
  55. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/expression.py +0 -0
  56. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/index.py +0 -0
  57. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/note.py +0 -0
  58. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/project.py +0 -0
  59. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/reference.py +0 -0
  60. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/renderer.py +0 -0
  61. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/sticky_note.py +0 -0
  62. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/table.py +0 -0
  63. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/table_group.py +0 -0
  64. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/dbml/default/utils.py +0 -0
  65. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/__init__.py +0 -0
  66. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/__init__.py +0 -0
  67. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/column.py +0 -0
  68. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/enum.py +0 -0
  69. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/expression.py +0 -0
  70. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/index.py +0 -0
  71. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/note.py +0 -0
  72. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/reference.py +0 -0
  73. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/renderer.py +0 -0
  74. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/table.py +0 -0
  75. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/renderer/sql/default/utils.py +0 -0
  76. {model2data-1.10.3 → model2data-1.10.4}/model2data/_vendor/pydbml/tools.py +0 -0
  77. {model2data-1.10.3 → model2data-1.10.4}/model2data/cli.py +0 -0
  78. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/__init__.py +0 -0
  79. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/hint_tests.py +0 -0
  80. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/naming.py +0 -0
  81. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/project.py +0 -0
  82. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  83. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/templates/hint_macros/model2data_hint_tests.sql +0 -0
  84. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/templates/history_macros/model2data_history_tests.sql +0 -0
  85. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  86. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  87. {model2data-1.10.3 → model2data-1.10.4}/model2data/dbt/tests.py +0 -0
  88. {model2data-1.10.3 → model2data-1.10.4}/model2data/defects/__init__.py +0 -0
  89. {model2data-1.10.3 → model2data-1.10.4}/model2data/defects/apply.py +0 -0
  90. {model2data-1.10.3 → model2data-1.10.4}/model2data/defects/checks.py +0 -0
  91. {model2data-1.10.3 → model2data-1.10.4}/model2data/defects/presets.py +0 -0
  92. {model2data-1.10.3 → model2data-1.10.4}/model2data/defects/report.py +0 -0
  93. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/__init__.py +0 -0
  94. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/core.py +0 -0
  95. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/hints.py +0 -0
  96. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/history.py +0 -0
  97. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/kinds.py +0 -0
  98. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/options.py +0 -0
  99. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/relationships.py +0 -0
  100. {model2data-1.10.3 → model2data-1.10.4}/model2data/generate/timeline.py +0 -0
  101. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/__init__.py +0 -0
  102. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/_yaml.py +0 -0
  103. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/dbml.py +0 -0
  104. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/document.py +0 -0
  105. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/dump.py +0 -0
  106. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/engine.py +0 -0
  107. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/errors.py +0 -0
  108. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/reader.py +0 -0
  109. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/types.py +0 -0
  110. {model2data-1.10.3 → model2data-1.10.4}/model2data/model/validate.py +0 -0
  111. {model2data-1.10.3 → model2data-1.10.4}/model2data/output.py +0 -0
  112. {model2data-1.10.3 → model2data-1.10.4}/model2data/parse/__init__.py +0 -0
  113. {model2data-1.10.3 → model2data-1.10.4}/model2data/parse/dbml.py +0 -0
  114. {model2data-1.10.3 → model2data-1.10.4}/model2data/spec/PUBLISHING.md +0 -0
  115. {model2data-1.10.3 → model2data-1.10.4}/model2data/spec/README.md +0 -0
  116. {model2data-1.10.3 → model2data-1.10.4}/model2data/spec/examples/coffee_webshop.model2data.yml +0 -0
  117. {model2data-1.10.3 → model2data-1.10.4}/model2data/spec/model.schema.json +0 -0
  118. {model2data-1.10.3 → model2data-1.10.4}/model2data/utils.py +0 -0
  119. {model2data-1.10.3 → model2data-1.10.4}/model2data.egg-info/SOURCES.txt +0 -0
  120. {model2data-1.10.3 → model2data-1.10.4}/model2data.egg-info/dependency_links.txt +0 -0
  121. {model2data-1.10.3 → model2data-1.10.4}/model2data.egg-info/entry_points.txt +0 -0
  122. {model2data-1.10.3 → model2data-1.10.4}/model2data.egg-info/requires.txt +0 -0
  123. {model2data-1.10.3 → model2data-1.10.4}/model2data.egg-info/top_level.txt +0 -0
  124. {model2data-1.10.3 → model2data-1.10.4}/setup.cfg +0 -0
  125. {model2data-1.10.3 → model2data-1.10.4}/tests/test_as_of_anchor.py +0 -0
  126. {model2data-1.10.3 → model2data-1.10.4}/tests/test_cli.py +0 -0
  127. {model2data-1.10.3 → model2data-1.10.4}/tests/test_column_time_hints.py +0 -0
  128. {model2data-1.10.3 → model2data-1.10.4}/tests/test_coverage_gaps.py +0 -0
  129. {model2data-1.10.3 → model2data-1.10.4}/tests/test_dbml_parser.py +0 -0
  130. {model2data-1.10.3 → model2data-1.10.4}/tests/test_dbml_parser_fuzz.py +0 -0
  131. {model2data-1.10.3 → model2data-1.10.4}/tests/test_dbt_integration.py +0 -0
  132. {model2data-1.10.3 → model2data-1.10.4}/tests/test_dbt_naming.py +0 -0
  133. {model2data-1.10.3 → model2data-1.10.4}/tests/test_dbt_project.py +0 -0
  134. {model2data-1.10.3 → model2data-1.10.4}/tests/test_dbt_tests.py +0 -0
  135. {model2data-1.10.3 → model2data-1.10.4}/tests/test_defects.py +0 -0
  136. {model2data-1.10.3 → model2data-1.10.4}/tests/test_defects_dbt.py +0 -0
  137. {model2data-1.10.3 → model2data-1.10.4}/tests/test_defects_replay.py +0 -0
  138. {model2data-1.10.3 → model2data-1.10.4}/tests/test_defects_report.py +0 -0
  139. {model2data-1.10.3 → model2data-1.10.4}/tests/test_defects_review.py +0 -0
  140. {model2data-1.10.3 → model2data-1.10.4}/tests/test_determinism.py +0 -0
  141. {model2data-1.10.3 → model2data-1.10.4}/tests/test_distributions.py +0 -0
  142. {model2data-1.10.3 → model2data-1.10.4}/tests/test_examples_model.py +0 -0
  143. {model2data-1.10.3 → model2data-1.10.4}/tests/test_faker_name_inference.py +0 -0
  144. {model2data-1.10.3 → model2data-1.10.4}/tests/test_fast_paths.py +0 -0
  145. {model2data-1.10.3 → model2data-1.10.4}/tests/test_hint_tests.py +0 -0
  146. {model2data-1.10.3 → model2data-1.10.4}/tests/test_history.py +0 -0
  147. {model2data-1.10.3 → model2data-1.10.4}/tests/test_issue_lines.py +0 -0
  148. {model2data-1.10.3 → model2data-1.10.4}/tests/test_lone_country.py +0 -0
  149. {model2data-1.10.3 → model2data-1.10.4}/tests/test_model.py +0 -0
  150. {model2data-1.10.3 → model2data-1.10.4}/tests/test_model_dbml.py +0 -0
  151. {model2data-1.10.3 → model2data-1.10.4}/tests/test_model_edges.py +0 -0
  152. {model2data-1.10.3 → model2data-1.10.4}/tests/test_model_messages.py +0 -0
  153. {model2data-1.10.3 → model2data-1.10.4}/tests/test_model_vocabulary.py +0 -0
  154. {model2data-1.10.3 → model2data-1.10.4}/tests/test_options.py +0 -0
  155. {model2data-1.10.3 → model2data-1.10.4}/tests/test_release_stress.py +0 -0
  156. {model2data-1.10.3 → model2data-1.10.4}/tests/test_reproducibility.py +0 -0
  157. {model2data-1.10.3 → model2data-1.10.4}/tests/test_row_identity.py +0 -0
  158. {model2data-1.10.3 → model2data-1.10.4}/tests/test_shaping.py +0 -0
  159. {model2data-1.10.3 → model2data-1.10.4}/tests/test_spec_alignment.py +0 -0
  160. {model2data-1.10.3 → model2data-1.10.4}/tests/test_spec_schema.py +0 -0
  161. {model2data-1.10.3 → model2data-1.10.4}/tests/test_table_seeds.py +0 -0
  162. {model2data-1.10.3 → model2data-1.10.4}/tests/test_timeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.10.3
3
+ Version: 1.10.4
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Author: JB Analytica
6
6
  License-Expression: MIT
@@ -222,14 +222,14 @@ jobs:
222
222
  ```
223
223
 
224
224
  Inputs: `files` (glob, default `**/*.model2data.yml`), `version` (default: the release the tag
225
- points at, e.g. `1.10.3`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
225
+ points at, e.g. `1.10.4`; a specifier such as `>=1.10,<2` also works), `python-version` (3.12).
226
226
 
227
227
  **pre-commit**
228
228
 
229
229
  ```yaml
230
230
  repos:
231
231
  - repo: https://github.com/JB-Analytica/model2data
232
- rev: v1.10.3
232
+ rev: v1.10.4
233
233
  hooks:
234
234
  - id: model2data-validate
235
235
  ```
@@ -243,7 +243,7 @@ model2data:
243
243
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
244
244
  changes: ["**/*.model2data.yml"]
245
245
  script:
246
- - pip install model2data==1.10.3
246
+ - pip install model2data==1.10.4
247
247
  - model2data validate --glob "**/*.model2data.yml"
248
248
  ```
249
249
 
@@ -598,17 +598,26 @@ class _Engine:
598
598
  lone_country=lone,
599
599
  )
600
600
  if unique and pool is None and len(state):
601
- values = self._avoid(values, set(state[column.name].dropna().tolist()), column)
601
+ values = self._avoid(values, set(state[column.name].dropna().tolist()), column, key)
602
602
  return values
603
603
 
604
- def _avoid(self, values: list, taken: set, column: ColumnDef) -> list:
604
+ def _avoid(self, values: list, taken: set, column: ColumnDef, key: str) -> list:
605
605
  """Replace values that repeat one the table already holds."""
606
606
  taken = set(taken)
607
607
  out = []
608
+ repeats = 0
608
609
  for value in values:
609
610
  candidate = value
610
611
  if candidate is not None and candidate in taken:
611
- if isinstance(value, str):
612
+ if column.enum_values:
613
+ # A suffix would make it a value the enum does not have:
614
+ # take a member no row holds yet, or keep the repeat.
615
+ free = [m for m in column.enum_values if m not in taken]
616
+ if free:
617
+ candidate = random.choice(free)
618
+ else:
619
+ repeats += 1
620
+ elif isinstance(value, str):
612
621
  counter = 1
613
622
  while candidate in taken:
614
623
  counter += 1
@@ -623,6 +632,10 @@ class _Engine:
623
632
  if candidate is not None:
624
633
  taken.add(candidate)
625
634
  out.append(candidate)
635
+ if repeats:
636
+ self.warnings.append(
637
+ f"{key}.{column.name}: {repeats} duplicate value(s) on day {self.day}"
638
+ )
626
639
  return out
627
640
 
628
641
  # -- time ---------------------------------------------------------------
@@ -796,6 +796,8 @@ def generate_column_values(
796
796
  # when it carried an explicit `null_rate`.
797
797
  if not force_not_null and "not null" not in column.settings and "pk" not in column.settings:
798
798
  _null_out(values, _null_fraction_for(column, row_count), column.default, row_count)
799
+ if ensure_unique:
800
+ values = _distinct_members(values, column.enum_values, unique_label)
799
801
  return values
800
802
 
801
803
  dtype = column.data_type.lower()
@@ -1150,6 +1152,33 @@ def _distinct_parent_values(fk_values: list, row_count: int, unique_label: str)
1150
1152
  return values
1151
1153
 
1152
1154
 
1155
+ def _distinct_members(values: list, members: list, unique_label: str) -> list:
1156
+ """A unique enum column's values, each repeated member swapped for a free one.
1157
+
1158
+ Only a repeat is drawn again, after the ordinary draw and the null pass, so
1159
+ a draw that already came out distinct keeps every byte (the determinism
1160
+ promise); one with repeats used to fail its generated dbt `unique` test.
1161
+ With fewer members than rows the rest stay repeated and are reported the
1162
+ way `_deduplicate` reports a unique column it could not keep unique.
1163
+ """
1164
+ held = set(values)
1165
+ free = [member for member in members if member not in held]
1166
+ seen: set = set()
1167
+ result = []
1168
+ unresolved = 0
1169
+ for value in values:
1170
+ if value is not None and value in seen:
1171
+ if free:
1172
+ value = free.pop(random.randrange(len(free)))
1173
+ else:
1174
+ unresolved += 1
1175
+ seen.add(value)
1176
+ result.append(value)
1177
+ if unresolved:
1178
+ _duplicate_unique_columns.append(f"{unique_label}: {unresolved} duplicate value(s)")
1179
+ return result
1180
+
1181
+
1153
1182
  # Faker's `text` reaches each word through five layers of provider methods,
1154
1183
  # copying the locale's word list twice per sentence on the way; a text column
1155
1184
  # spends most of a run's generation time there. `_text` and `_sentence` make
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 1.10.3
3
+ Version: 1.10.4
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Author: JB Analytica
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model2data"
7
- version = "1.10.3"
7
+ version = "1.10.4"
8
8
  description = "Generate analytics-ready datasets from DBML models"
9
9
  readme = "README_PYPI.md"
10
10
  requires-python = ">=3.10"
@@ -371,6 +371,37 @@ def test_validate_summary_agrees_with_its_count(tmp_path):
371
371
  assert "❌ 2 of 2 model files do not conform." in both.output
372
372
 
373
373
 
374
+ UNIQUE_ENUM = """\
375
+ model2data: 0.3.0
376
+ name: u
377
+ enums:
378
+ plan: [free, starter, pro, enterprise]
379
+ tables:
380
+ plans:
381
+ columns:
382
+ id: {type: bigint, pk: true}
383
+ plan_name: {type: plan, unique: true, not_null: true}
384
+ run: {rows: 10, rows_per_table: {plans: ROWS}, seed: 42, as_of: 2026-01-01}
385
+ """
386
+
387
+
388
+ def test_a_unique_enum_with_a_member_per_row_writes_each_member_once(in_tmp):
389
+ model = _write(in_tmp, "u.model2data.yml", UNIQUE_ENUM.replace("ROWS", "4"))
390
+ result = runner.invoke(app, ["--file", str(model)])
391
+ assert result.exit_code == 0, result.output
392
+ plans = pd.read_csv(in_tmp / "dbt_u" / "seeds" / "raw" / "plans.csv")
393
+ assert sorted(plans["plan_name"]) == ["enterprise", "free", "pro", "starter"]
394
+ assert "Unique columns left with duplicate values" not in result.output
395
+
396
+
397
+ def test_a_unique_enum_with_too_few_members_warns_in_the_summary(in_tmp):
398
+ model = _write(in_tmp, "u.model2data.yml", UNIQUE_ENUM.replace("ROWS", "6"))
399
+ result = runner.invoke(app, ["--file", str(model)])
400
+ assert result.exit_code == 0, result.output
401
+ assert "Unique columns left with duplicate values" in result.output
402
+ assert "plans.plan_name: 2 duplicate value(s)" in result.output
403
+
404
+
374
405
  def test_action_uses_setup_python_v6():
375
406
  import yaml
376
407
 
@@ -429,6 +429,32 @@ def test_a_unique_foreign_key_runs_out_of_parents_and_says_so():
429
429
  assert days[1].tables["passports"].state["person_id"].dropna().is_unique
430
430
 
431
431
 
432
+ def test_a_unique_enum_takes_free_members_across_days_and_says_when_none_are_left():
433
+ members = ["a", "b", "c", "d", "e", "f"]
434
+ data = {
435
+ "model2data": "0.2.0",
436
+ "enums": {"slot": members},
437
+ "tables": {
438
+ "slots": {
439
+ "incremental": {"new_per_day": 2},
440
+ "columns": {
441
+ "id": {"type": "bigint", "pk": True},
442
+ "name": {"type": "slot", "unique": True, "not_null": True},
443
+ },
444
+ },
445
+ },
446
+ }
447
+ days = generate_days(from_dict(data), 2, base_rows=3, seed=4, as_of=AS_OF)
448
+ # Never a suffixed value the enum does not have.
449
+ for day in days:
450
+ assert set(day.tables["slots"].state["name"]) <= set(members)
451
+ # Day 1 still has three free members for its two rows.
452
+ assert days[1].tables["slots"].state["name"].is_unique
453
+ assert not days[1].warnings
454
+ # Day 2 has one free member for two rows: one repeat, said.
455
+ assert days[2].warnings == ["slots.name: 1 duplicate value(s) on day 2"]
456
+
457
+
432
458
  def test_the_model_roundtrips_with_incremental():
433
459
  model = load(DAILY)
434
460
  assert model.tables["orders"].incremental == Incremental(
@@ -519,6 +519,70 @@ def test_unique_column_with_room_reports_nothing():
519
519
  assert get_duplicate_unique_columns() == []
520
520
 
521
521
 
522
+ PLANS = ["free", "starter", "pro", "enterprise"]
523
+
524
+
525
+ def _plans_table(settings: set[str]) -> dict[str, TableDef]:
526
+ return {
527
+ "plans": TableDef(
528
+ name="plans",
529
+ columns=[
530
+ ColumnDef("id", "bigint", {"pk"}),
531
+ ColumnDef("plan_name", "plan", settings, enum_values=PLANS),
532
+ ],
533
+ )
534
+ }
535
+
536
+
537
+ def test_unique_enum_with_a_member_per_row_takes_each_member_once():
538
+ # The enum has exactly as many members as the table has rows: a draw
539
+ # without replacement, so every member appears once and none twice.
540
+ for seed in range(20):
541
+ df = generate_data_from_dbml(
542
+ _plans_table({"unique", "not null"}), [], base_rows=4, seed=seed
543
+ )["plans"]
544
+ assert sorted(df["plan_name"]) == sorted(PLANS), seed
545
+ assert get_duplicate_unique_columns() == []
546
+
547
+
548
+ def test_unique_enum_with_more_members_than_rows_stays_unique():
549
+ df = generate_data_from_dbml(_plans_table({"unique", "not null"}), [], base_rows=3, seed=1)[
550
+ "plans"
551
+ ]
552
+ assert df["plan_name"].is_unique
553
+ assert set(df["plan_name"]) <= set(PLANS)
554
+ assert get_duplicate_unique_columns() == []
555
+
556
+
557
+ def test_unique_enum_with_fewer_members_than_rows_is_reported():
558
+ df = generate_data_from_dbml(_plans_table({"unique", "not null"}), [], base_rows=6, seed=1)[
559
+ "plans"
560
+ ]
561
+ # Every member is used before any repeats, and the two repeats are said.
562
+ assert set(df["plan_name"]) == set(PLANS)
563
+ assert get_duplicate_unique_columns() == ["plans.plan_name: 2 duplicate value(s)"]
564
+
565
+
566
+ def test_unique_enum_that_draws_distinct_keeps_the_bytes_of_the_plain_draw():
567
+ # The determinism promise: only a repeat is drawn again, so a seed whose
568
+ # ordinary draw already came out distinct gives the same table as before.
569
+ kept = 0
570
+ for seed in range(40):
571
+ plain = generate_data_from_dbml(_plans_table({"not null"}), [], base_rows=3, seed=seed)
572
+ unique = generate_data_from_dbml(
573
+ _plans_table({"unique", "not null"}), [], base_rows=3, seed=seed
574
+ )
575
+ if plain["plans"]["plan_name"].is_unique:
576
+ kept += 1
577
+ assert plain["plans"].equals(unique["plans"]), seed
578
+ assert kept
579
+
580
+
581
+ def test_nullable_unique_enum_keeps_its_non_null_values_distinct():
582
+ df = generate_data_from_dbml(_plans_table({"unique"}), [], base_rows=4, seed=2)["plans"]
583
+ assert df["plan_name"].dropna().is_unique
584
+
585
+
522
586
  def test_resolvable_composite_key_reports_nothing():
523
587
  # The mirror of the test above: when the dedup pass genuinely succeeds,
524
588
  # the warning must stay silent (no false positives).
File without changes
File without changes
File without changes