model2data 0.4.1__tar.gz → 0.5.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 (36) hide show
  1. {model2data-0.4.1 → model2data-0.5.0}/PKG-INFO +31 -19
  2. {model2data-0.4.1 → model2data-0.5.0}/README.md +27 -15
  3. {model2data-0.4.1 → model2data-0.5.0}/model2data/cli.py +15 -4
  4. {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/project.py +6 -2
  5. {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/tests.py +118 -54
  6. {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/core.py +91 -4
  7. {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/faker.py +40 -0
  8. model2data-0.5.0/model2data/parse/dbml.py +621 -0
  9. {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/PKG-INFO +31 -19
  10. {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/SOURCES.txt +1 -0
  11. {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/requires.txt +3 -3
  12. {model2data-0.4.1 → model2data-0.5.0}/pyproject.toml +4 -4
  13. {model2data-0.4.1 → model2data-0.5.0}/tests/test_cli.py +72 -4
  14. {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbml_parser.py +486 -12
  15. model2data-0.5.0/tests/test_dbt_integration.py +104 -0
  16. {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbt_project.py +18 -1
  17. {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbt_tests.py +174 -11
  18. {model2data-0.4.1 → model2data-0.5.0}/tests/test_generation.py +164 -1
  19. model2data-0.4.1/model2data/parse/dbml.py +0 -393
  20. {model2data-0.4.1 → model2data-0.5.0}/LICENSE +0 -0
  21. {model2data-0.4.1 → model2data-0.5.0}/model2data/__init__.py +0 -0
  22. {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/__init__.py +0 -0
  23. {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  24. {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  25. {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  26. {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/__init__.py +0 -0
  27. {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/relationships.py +0 -0
  28. {model2data-0.4.1 → model2data-0.5.0}/model2data/parse/__init__.py +0 -0
  29. {model2data-0.4.1 → model2data-0.5.0}/model2data/utils.py +0 -0
  30. {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/dependency_links.txt +0 -0
  31. {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/entry_points.txt +0 -0
  32. {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/top_level.txt +0 -0
  33. {model2data-0.4.1 → model2data-0.5.0}/setup.cfg +0 -0
  34. {model2data-0.4.1 → model2data-0.5.0}/tests/test_coverage_gaps.py +0 -0
  35. {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbt_naming.py +0 -0
  36. {model2data-0.4.1 → model2data-0.5.0}/tests/test_faker_name_inference.py +0 -0
@@ -1,18 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
- Requires-Dist: dbt-core>=1.5.0
9
- Requires-Dist: dbt-duckdb>=1.5.0
8
+ Requires-Dist: dbt-core>=1.8.5
9
+ Requires-Dist: dbt-duckdb>=1.8.4
10
10
  Requires-Dist: faker>=37.12.0
11
11
  Requires-Dist: pandas>=2.3.3
12
12
  Requires-Dist: pyyaml>=6.0.3
13
13
  Requires-Dist: typer>=0.20.0
14
14
  Provides-Extra: postgres
15
- Requires-Dist: dbt-postgres>=1.5.0; extra == "postgres"
15
+ Requires-Dist: dbt-postgres>=1.8.0; extra == "postgres"
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-cov; extra == "dev"
@@ -122,7 +122,9 @@ flowchart LR
122
122
  (int, date, timestamp, ...), name-aware inference for everything else (`email`, `phone`,
123
123
  `city`, ...), foreign keys resolved against already-generated parent rows.
124
124
  3. **Scaffold.** Writes a complete dbt project around that data: CSV seeds, staging models with
125
- `source`/`not_null`/`unique`/`relationships` tests, and a profile for DuckDB (zero-config,
125
+ `source`/`not_null`/`unique`/`relationships` tests, `accepted_values` tests for DBML
126
+ `Enum`-typed columns, singular SQL tests for composite primary/unique keys, table and column
127
+ `description:` fields pulled from DBML notes, and a profile for DuckDB (zero-config,
126
128
  file-based) or Postgres.
127
129
 
128
130
  ---
@@ -169,13 +171,19 @@ Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `M
169
171
 
170
172
  After generation, the CLI prints a short summary — tables and rows generated, relationships found in the DBML, and any columns that fell back to generic placeholder text because neither their type nor name could be matched.
171
173
 
172
- Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`tests/unit/test_stg_<table>.yml`) from the actually-generated seed rows:
174
+ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`models/staging/ut_stg_<table>.yml`) from the actually-generated seed rows:
173
175
 
174
176
  ```bash
175
177
  model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
176
178
  ```
177
179
 
178
- This targets dbt-core's native unit testing feature, which requires **dbt-core >= 1.8**. Since this project only requires `dbt-core>=1.5.0` by default, unit test generation is opt-in — leave the flag off if your dbt-core version is older.
180
+ This targets dbt-core's native unit testing feature, which requires dbt-core >= 1.8 — already
181
+ covered by this project's `dbt-core>=1.8.5` floor, so `--unit-tests` works with the base install.
182
+ Note: on dbt-core versions in the 1.8.x line specifically, a DBML column named after a SQL
183
+ reserved word (e.g. `by`, as in `examples/hackernews.dbml`) can fail unit test execution with a
184
+ `syntax error` — a dbt-core-internal identifier-quoting limitation in its unit test fixture
185
+ rendering for that release line, not something under model2data's control. It's fixed in later
186
+ dbt-core versions; every other `--unit-tests` path works fine on 1.8.x.
179
187
 
180
188
  ---
181
189
 
@@ -186,32 +194,36 @@ The generated dbt project includes:
186
194
  ```
187
195
  dbt_{project_name}/
188
196
  ├── seeds/
189
- │ └── {project_name}/
197
+ │ └── raw/
190
198
  │ ├── table1.csv
191
199
  │ └── table2.csv
192
200
  ├── models/
193
- │ └── {project_name}/
194
- │ └── staging/
195
- │ ├── __sources.yml
196
- │ ├── stg_table1.sql
197
- │ ├── stg_table1.yml
198
- │ └── ...
201
+ │ └── staging/
202
+ │ ├── __sources.yml
203
+ │ ├── stg_table1.sql
204
+ │ ├── stg_table1.yml
205
+ │ ├── ut_stg_table1.yml # only with --unit-tests
206
+ │ └── ...
207
+ ├── data-tests/
208
+ │ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
199
209
  ├── macros/
200
210
  │ └── generate_schema_name.sql
201
211
  ├── dbt_project.yml
202
212
  ├── profiles.yml # DuckDB or Postgres config, depending on --adapter
203
- └── {project_name}.duckdb # DuckDB adapter only
213
+ └── {project_name}_profile.duckdb # DuckDB adapter only
204
214
  ```
205
215
 
206
216
  - **Seeds**: CSV files with generated synthetic data.
207
217
  - **Staging Models**: Basic dbt models that load from seeds.
208
218
  - **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
209
219
  `relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
210
- `Note` text from the DBML becomes `description:` fields. Composite primary/unique keys
211
- declared in an `indexes { }` block get a singular SQL test under `tests/`.
220
+ `Note` text from the DBML becomes `description:` fields.
221
+ - **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
222
+ a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
212
223
  - **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
213
- - **Unit tests** (opt-in via `--unit-tests`): `tests/unit/test_stg_<table>.yml` fixtures built
214
- from real generated rows. Requires dbt-core >= 1.8.
224
+ - **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
225
+ from real generated rows, co-located with each staging model so dbt (which only parses unit
226
+ tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
215
227
 
216
228
  ---
217
229
 
@@ -97,7 +97,9 @@ flowchart LR
97
97
  (int, date, timestamp, ...), name-aware inference for everything else (`email`, `phone`,
98
98
  `city`, ...), foreign keys resolved against already-generated parent rows.
99
99
  3. **Scaffold.** Writes a complete dbt project around that data: CSV seeds, staging models with
100
- `source`/`not_null`/`unique`/`relationships` tests, and a profile for DuckDB (zero-config,
100
+ `source`/`not_null`/`unique`/`relationships` tests, `accepted_values` tests for DBML
101
+ `Enum`-typed columns, singular SQL tests for composite primary/unique keys, table and column
102
+ `description:` fields pulled from DBML notes, and a profile for DuckDB (zero-config,
101
103
  file-based) or Postgres.
102
104
 
103
105
  ---
@@ -144,13 +146,19 @@ Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `M
144
146
 
145
147
  After generation, the CLI prints a short summary — tables and rows generated, relationships found in the DBML, and any columns that fell back to generic placeholder text because neither their type nor name could be matched.
146
148
 
147
- Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`tests/unit/test_stg_<table>.yml`) from the actually-generated seed rows:
149
+ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`models/staging/ut_stg_<table>.yml`) from the actually-generated seed rows:
148
150
 
149
151
  ```bash
150
152
  model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
151
153
  ```
152
154
 
153
- This targets dbt-core's native unit testing feature, which requires **dbt-core >= 1.8**. Since this project only requires `dbt-core>=1.5.0` by default, unit test generation is opt-in — leave the flag off if your dbt-core version is older.
155
+ This targets dbt-core's native unit testing feature, which requires dbt-core >= 1.8 — already
156
+ covered by this project's `dbt-core>=1.8.5` floor, so `--unit-tests` works with the base install.
157
+ Note: on dbt-core versions in the 1.8.x line specifically, a DBML column named after a SQL
158
+ reserved word (e.g. `by`, as in `examples/hackernews.dbml`) can fail unit test execution with a
159
+ `syntax error` — a dbt-core-internal identifier-quoting limitation in its unit test fixture
160
+ rendering for that release line, not something under model2data's control. It's fixed in later
161
+ dbt-core versions; every other `--unit-tests` path works fine on 1.8.x.
154
162
 
155
163
  ---
156
164
 
@@ -161,32 +169,36 @@ The generated dbt project includes:
161
169
  ```
162
170
  dbt_{project_name}/
163
171
  ├── seeds/
164
- │ └── {project_name}/
172
+ │ └── raw/
165
173
  │ ├── table1.csv
166
174
  │ └── table2.csv
167
175
  ├── models/
168
- │ └── {project_name}/
169
- │ └── staging/
170
- │ ├── __sources.yml
171
- │ ├── stg_table1.sql
172
- │ ├── stg_table1.yml
173
- │ └── ...
176
+ │ └── staging/
177
+ │ ├── __sources.yml
178
+ │ ├── stg_table1.sql
179
+ │ ├── stg_table1.yml
180
+ │ ├── ut_stg_table1.yml # only with --unit-tests
181
+ │ └── ...
182
+ ├── data-tests/
183
+ │ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
174
184
  ├── macros/
175
185
  │ └── generate_schema_name.sql
176
186
  ├── dbt_project.yml
177
187
  ├── profiles.yml # DuckDB or Postgres config, depending on --adapter
178
- └── {project_name}.duckdb # DuckDB adapter only
188
+ └── {project_name}_profile.duckdb # DuckDB adapter only
179
189
  ```
180
190
 
181
191
  - **Seeds**: CSV files with generated synthetic data.
182
192
  - **Staging Models**: Basic dbt models that load from seeds.
183
193
  - **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
184
194
  `relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
185
- `Note` text from the DBML becomes `description:` fields. Composite primary/unique keys
186
- declared in an `indexes { }` block get a singular SQL test under `tests/`.
195
+ `Note` text from the DBML becomes `description:` fields.
196
+ - **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
197
+ a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
187
198
  - **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
188
- - **Unit tests** (opt-in via `--unit-tests`): `tests/unit/test_stg_<table>.yml` fixtures built
189
- from real generated rows. Requires dbt-core >= 1.8.
199
+ - **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
200
+ from real generated rows, co-located with each staging model so dbt (which only parses unit
201
+ tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
190
202
 
191
203
  ---
192
204
 
@@ -12,9 +12,9 @@ from model2data.dbt.project import (
12
12
  create_staging_models,
13
13
  )
14
14
  from model2data.dbt.tests import generate_dbt_yml, generate_unit_tests
15
- from model2data.generate.core import generate_data_from_dbml
15
+ from model2data.generate.core import generate_data_from_dbml, get_cyclic_tables
16
16
  from model2data.generate.faker import get_unmapped_columns, reset_stats
17
- from model2data.parse.dbml import parse_dbml
17
+ from model2data.parse.dbml import get_parse_warnings, parse_dbml
18
18
  from model2data.utils import normalize_identifier
19
19
 
20
20
  SUPPORTED_ADAPTERS = ("duckdb", "postgres")
@@ -80,8 +80,8 @@ def main(
80
80
  False,
81
81
  "--unit-tests",
82
82
  help=(
83
- "Also generate deterministic dbt unit test fixtures (tests/unit/) from the "
84
- "generated seed rows. Requires dbt-core >= 1.8 to run."
83
+ "Also generate deterministic dbt unit test fixtures (models/staging/ut_stg_*.yml) "
84
+ "from the generated seed rows. Requires dbt-core >= 1.8 to run."
85
85
  ),
86
86
  ),
87
87
  ):
@@ -111,6 +111,7 @@ def main(
111
111
  # Parse DBML (names untouched)
112
112
  # -------------------------
113
113
  tables, refs = parse_dbml(file)
114
+ parse_warnings = get_parse_warnings()
114
115
  if not tables:
115
116
  typer.echo("❌ No tables found in the provided DBML file.")
116
117
  raise typer.Exit(1)
@@ -175,6 +176,7 @@ def main(
175
176
  # -------------------------
176
177
  total_rows = sum(len(df) for df in generated_tables.values())
177
178
  unmapped = get_unmapped_columns()
179
+ cyclic_tables = get_cyclic_tables()
178
180
 
179
181
  typer.echo("\n📊 Summary")
180
182
  typer.echo(f" Tables generated: {len(generated_tables)}")
@@ -186,6 +188,15 @@ def main(
186
188
  typer.echo(f" - {col_name} ({data_type})")
187
189
  else:
188
190
  typer.echo(" Columns using generic fallback text: 0")
191
+ if cyclic_tables:
192
+ typer.echo(
193
+ " ⚠️ Tables in an unresolved FK cycle (data may not respect "
194
+ f"all relationships): {', '.join(cyclic_tables)}"
195
+ )
196
+ if parse_warnings:
197
+ typer.echo(f" ⚠️ DBML lines model2data could not fully parse: {len(parse_warnings)}")
198
+ for warning in parse_warnings:
199
+ typer.echo(f" - {warning}")
189
200
 
190
201
  # -------------------------
191
202
  # Done
@@ -13,7 +13,7 @@ def create_project_scaffold(dest: Path, project_name: str, profile_name: str) ->
13
13
  (dest / "seeds" / "raw").mkdir(parents=True, exist_ok=True)
14
14
  (dest / "analysis").mkdir(exist_ok=True)
15
15
  (dest / "macros").mkdir(exist_ok=True)
16
- (dest / "tests").mkdir(exist_ok=True)
16
+ (dest / "data-tests").mkdir(exist_ok=True)
17
17
  (dest / "snapshots").mkdir(exist_ok=True)
18
18
 
19
19
  # dbt_project.yml
@@ -45,10 +45,14 @@ def create_staging_models(dest: Path, project_name: str) -> None:
45
45
  model_file = models_path / f"stg_{table_name}.sql"
46
46
 
47
47
  if not model_file.exists():
48
+ # table_name is spliced into a single-quoted Jinja string literal;
49
+ # escape any embedded single quote so a DBML identifier containing
50
+ # one can't break the source() call.
51
+ escaped_name = table_name.replace("'", "\\'")
48
52
  sql_content = f"""\
49
53
  -- Auto-generated staging model for {table_name}
50
54
  select *
51
- from {{{{ source('raw', '{table_name}') }}}}
55
+ from {{{{ source('raw', '{escaped_name}') }}}}
52
56
  """
53
57
  model_file.write_text(sql_content)
54
58
 
@@ -6,11 +6,20 @@ from typing import Any, Union
6
6
  import pandas as pd
7
7
  import yaml
8
8
 
9
+ from model2data.generate.faker import is_free_text_type
10
+ from model2data.generate.relationships import classify_refs
9
11
 
10
- def _yaml_field_lines(key: str, value: str, indent: str) -> list[str]:
11
- """Render `key: value` as safely-escaped YAML lines at the given indent."""
12
- dumped = yaml.safe_dump({key: value}, default_flow_style=False, sort_keys=False)
13
- return [f"{indent}{line}" for line in dumped.strip("\n").splitlines()]
12
+
13
+ def _dump_yaml(data: dict) -> str:
14
+ """Dump a plain Python structure to YAML, safely escaping every value.
15
+
16
+ Every table/column/description string that ends up in generated YAML
17
+ goes through this single choke point instead of being hand-interpolated
18
+ into f-string lines, so an arbitrary (but valid) DBML identifier --
19
+ containing a space, colon, quote, etc. -- can never produce invalid or
20
+ silently-misparsed YAML.
21
+ """
22
+ return yaml.safe_dump(data, default_flow_style=False, sort_keys=False)
14
23
 
15
24
 
16
25
  def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: str = "hackernews"):
@@ -28,27 +37,47 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
28
37
  # -------------------------
29
38
  # Build foreign key map
30
39
  # -------------------------
40
+ # Only emit a `relationships` test for refs the generator actually makes
41
+ # FK-aware: direct FK refs (target column is a pk/"id"), plus attribute
42
+ # refs that ride along an existing FK between the same two tables (see
43
+ # generate.core's attribute-mirroring pass). An attribute ref with no
44
+ # accompanying FK is left as unrelated random data by the generator, so
45
+ # testing it against the parent table would be a guaranteed false
46
+ # failure.
47
+ fk_refs_classified, attribute_refs_classified = classify_refs(tables, refs)
48
+ fk_table_pairs = {(fk["source_table"], fk["target_table"]) for fk in fk_refs_classified}
49
+ eligible_refs = list(fk_refs_classified) + [
50
+ ref
51
+ for ref in attribute_refs_classified
52
+ if (ref["source_table"], ref["target_table"]) in fk_table_pairs
53
+ ]
54
+
31
55
  fk_map = defaultdict(list)
32
- for ref in refs:
56
+ for ref in eligible_refs:
33
57
  fk_map[(ref["source_table"], ref["source_column"])].append(ref)
34
58
 
35
59
  # -------------------------
36
60
  # Generate __sources.yml
37
61
  # -------------------------
38
- sources_lines = ["version: 2", "", "sources:"]
39
- sources_lines.append(" - name: raw")
40
- sources_lines.append(" schema: raw")
41
- sources_lines.append(f" description: {source_name.capitalize()} raw seed data")
42
- sources_lines.append(" tables:")
43
-
62
+ source_tables = []
44
63
  for table in tables.values():
45
64
  seed_name = table.name # keep exact name
46
65
  table_desc = getattr(table, "description", None) or f"Table {seed_name}"
47
- sources_lines.append(f" - name: {seed_name}")
48
- sources_lines.extend(_yaml_field_lines("description", table_desc, " "))
49
-
66
+ source_tables.append({"name": seed_name, "description": table_desc})
67
+
68
+ sources_doc = {
69
+ "version": 2,
70
+ "sources": [
71
+ {
72
+ "name": "raw",
73
+ "schema": "raw",
74
+ "description": f"{source_name.capitalize()} raw seed data",
75
+ "tables": source_tables,
76
+ }
77
+ ],
78
+ }
50
79
  sources_file = staging_path / "__sources.yml"
51
- sources_file.write_text("\n".join(sources_lines))
80
+ sources_file.write_text(_dump_yaml(sources_doc))
52
81
 
53
82
  # -------------------------
54
83
  # Generate individual staging model YAMLs
@@ -79,52 +108,77 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
79
108
  }
80
109
  )
81
110
 
82
- model_columns.append(
83
- {
84
- "name": col.name,
85
- "description": getattr(col, "description", None),
86
- "tests": tests if tests else None,
87
- }
88
- )
89
-
90
- # Render model YAML
91
- lines = ["version: 2", "", "models:"]
92
- lines.append(f" - name: {stg_name}")
93
- lines.append(" columns:")
94
- for col in model_columns:
95
- lines.append(f" - name: {col['name']}")
96
- if col["description"]:
97
- lines.extend(_yaml_field_lines("description", col["description"], " "))
98
- if col["tests"]:
99
- lines.append(" tests:")
100
- for test in col["tests"]:
101
- if isinstance(test, str):
102
- lines.append(f" - {test}")
103
- elif "accepted_values" in test:
104
- lines.append(" - accepted_values:")
105
- lines.append(" values:")
106
- for value in test["accepted_values"]["values"]:
107
- lines.append(f" - {value!r}")
108
- else:
109
- # relationships test with arguments
110
- for k, v in test.items():
111
- lines.append(f" - {k}:")
112
- lines.append(" arguments:")
113
- for fk_key, fk_val in v.items():
114
- lines.append(f" {fk_key}: {fk_val}")
111
+ col_doc: dict[str, Any] = {"name": col.name}
112
+ description = getattr(col, "description", None)
113
+ if description:
114
+ col_doc["description"] = description
115
+ if tests:
116
+ col_doc["tests"] = tests
117
+ model_columns.append(col_doc)
118
+
119
+ model_doc = {
120
+ "version": 2,
121
+ "models": [{"name": stg_name, "columns": model_columns}],
122
+ }
115
123
 
116
124
  # Write YAML to same folder as SQL model
117
125
  yml_file = staging_path / f"{stg_name}.yml"
118
- yml_file.write_text("\n".join(lines))
126
+ yml_file.write_text(_dump_yaml(model_doc))
119
127
 
120
128
  # -------------------------
121
129
  # Composite key singular tests
122
130
  # -------------------------
123
131
  _generate_composite_key_tests(dest, tables)
124
132
 
133
+ # -------------------------
134
+ # Seed column-type overrides
135
+ # -------------------------
136
+ _generate_seed_config(dest, tables)
137
+
138
+
139
+ def _generate_seed_config(dest: Path, tables: dict) -> None:
140
+ """
141
+ Force every free-text column (see `is_free_text_type`) to VARCHAR in
142
+ the seed loader config, instead of letting dbt/duckdb sniff the type
143
+ from CSV content. Some generated text is all-digit (EAN13 barcodes,
144
+ zero-padded postcodes, ...) and would otherwise be silently loaded as
145
+ an integer, overflowing or dropping leading zeros.
146
+ """
147
+ seed_entries = []
148
+
149
+ for table in tables.values():
150
+ column_types = {
151
+ col.name: "varchar" for col in table.columns if is_free_text_type(col.data_type)
152
+ }
153
+ if not column_types:
154
+ continue
155
+
156
+ seed_entries.append({"name": table.name, "config": {"column_types": column_types}})
157
+
158
+ if not seed_entries:
159
+ return
160
+
161
+ seeds_doc = {"version": 2, "seeds": seed_entries}
162
+
163
+ seed_raw_path = dest / "seeds" / "raw"
164
+ seed_raw_path.mkdir(parents=True, exist_ok=True)
165
+ (seed_raw_path / "__seed_config.yml").write_text(_dump_yaml(seeds_doc))
166
+
167
+
168
+ def _quote_sql_identifier(name: str) -> str:
169
+ """ANSI double-quote a raw column identifier for use in generated SQL.
170
+
171
+ Both supported adapters (DuckDB and Postgres) accept ANSI double-quoting,
172
+ which is required once a DBML identifier contains a space, colon, or
173
+ other character that would otherwise break an unquoted `select`/`group
174
+ by` clause. A literal `"` inside the identifier is escaped by doubling,
175
+ the standard ANSI SQL convention.
176
+ """
177
+ return '"' + name.replace('"', '""') + '"'
178
+
125
179
 
126
180
  def _generate_composite_key_tests(dest: Path, tables: dict) -> None:
127
- tests_path = dest / "tests"
181
+ tests_path = dest / "data-tests"
128
182
  tests_path.mkdir(parents=True, exist_ok=True)
129
183
 
130
184
  for table in tables.values():
@@ -136,7 +190,8 @@ def _generate_composite_key_tests(dest: Path, tables: dict) -> None:
136
190
  if len(columns) < 2:
137
191
  continue
138
192
 
139
- columns_csv = ", ".join(columns)
193
+ quoted_columns = [_quote_sql_identifier(c) for c in columns]
194
+ columns_csv = ", ".join(quoted_columns)
140
195
  test_name = "unique_combination_" + "_".join([stg_name, *columns])
141
196
  sql = (
142
197
  f"select {columns_csv}, count(*) as n\n"
@@ -158,8 +213,12 @@ def generate_unit_tests(
158
213
 
159
214
  Since staging models are pure `select * from {{ source(...) }}` passthroughs,
160
215
  a handful of already-generated rows can serve as both `given` and `expect`.
216
+
217
+ Written alongside each staging model (under `model-paths`, which is where
218
+ dbt actually parses unit tests from) as `ut_stg_<table>.yml`, parallel to
219
+ the `stg_<table>.yml` schema file generated by `generate_dbt_yml`.
161
220
  """
162
- unit_tests_path = dest / "tests" / "unit"
221
+ unit_tests_path = dest / "models" / "staging"
163
222
  unit_tests_path.mkdir(parents=True, exist_ok=True)
164
223
 
165
224
  for table in tables.values():
@@ -169,6 +228,11 @@ def generate_unit_tests(
169
228
 
170
229
  sample_rows = _rows_as_native_dicts(df.head(sample_size))
171
230
  stg_name = f"stg_{table.name}"
231
+ # table.name is spliced into a single-quoted Jinja string literal (the
232
+ # source() call is evaluated by dbt as an expression, not treated as
233
+ # a literal YAML string); escape any embedded single quote so an
234
+ # unusual DBML identifier can't break that call.
235
+ escaped_name = table.name.replace("'", "\\'")
172
236
 
173
237
  unit_test = {
174
238
  "unit_tests": [
@@ -177,7 +241,7 @@ def generate_unit_tests(
177
241
  "model": stg_name,
178
242
  "given": [
179
243
  {
180
- "input": f"source('raw', '{table.name}')",
244
+ "input": f"source('raw', '{escaped_name}')",
181
245
  "rows": sample_rows,
182
246
  }
183
247
  ],
@@ -188,7 +252,7 @@ def generate_unit_tests(
188
252
  ]
189
253
  }
190
254
 
191
- yml_file = unit_tests_path / f"test_{stg_name}.yml"
255
+ yml_file = unit_tests_path / f"ut_{stg_name}.yml"
192
256
  yml_file.write_text(yaml.safe_dump(unit_test, sort_keys=False, default_flow_style=False))
193
257
 
194
258
 
@@ -16,6 +16,23 @@ from model2data.parse.dbml import TableDef
16
16
 
17
17
  fake = Faker()
18
18
 
19
+ # Tables the most recent generate_data_from_dbml() call found stuck in an
20
+ # unresolved FK cycle (never reached indegree 0 during the topological
21
+ # sort). Exposed out-of-band, mirroring generate.faker's
22
+ # reset_stats()/get_unmapped_columns() pattern, so the CLI can surface a
23
+ # warning without changing this module's existing return signature.
24
+ _cycle_state: dict[str, list[str]] = {"cyclic_tables": []}
25
+
26
+
27
+ def reset_cycle_state() -> None:
28
+ """Clear the record of tables found in an unresolved FK cycle."""
29
+ _cycle_state["cyclic_tables"] = []
30
+
31
+
32
+ def get_cyclic_tables() -> list[str]:
33
+ """Return table names stuck in an unresolved FK cycle by the last run."""
34
+ return list(_cycle_state["cyclic_tables"])
35
+
19
36
 
20
37
  # ---------------------------------------------------------
21
38
  # Public API
@@ -36,6 +53,8 @@ def generate_data_from_dbml(
36
53
  random.seed(seed)
37
54
  Faker.seed(seed)
38
55
 
56
+ reset_cycle_state()
57
+
39
58
  # ---------------------------------------------------------
40
59
  # Classify references
41
60
  # ---------------------------------------------------------
@@ -76,6 +95,7 @@ def generate_data_from_dbml(
76
95
  )
77
96
 
78
97
  df = pd.DataFrame(data)
98
+ df = _resolve_self_referencing_fks(df, table_def, table_name, fk_lookup, row_count)
79
99
  df = _deduplicate_composite_keys(df, table_def)
80
100
 
81
101
  # -----------------------------------------------------
@@ -110,6 +130,7 @@ def generate_data_from_dbml(
110
130
 
111
131
  df[child_column] = df[fk_column].map(lookup)
112
132
 
133
+ df = _coerce_integer_dtypes(df, table_def)
113
134
  generated[table_name] = df
114
135
 
115
136
  return generated
@@ -118,6 +139,25 @@ def generate_data_from_dbml(
118
139
  # ---------------------------------------------------------
119
140
  # Internal helpers
120
141
  # ---------------------------------------------------------
142
+ def _coerce_integer_dtypes(df: pd.DataFrame, table_def: TableDef) -> pd.DataFrame:
143
+ """
144
+ Cast int/bigint/smallint-typed columns to pandas' nullable "Int64" dtype.
145
+
146
+ `generate_column_values` fills "empty" nullable columns with `None`
147
+ (absent an explicit default). Building a plain `pd.DataFrame` from a
148
+ Python list mixing ints and `None` silently upcasts that column to
149
+ float64, so whole numbers round-trip through the CSV seed as "70.0"
150
+ instead of "70" and blanks. Int64 keeps them as integers and renders
151
+ nulls as empty cells, matching the DBML-declared type.
152
+ """
153
+ for column in table_def.columns:
154
+ base_type = column.data_type.lower().split("(")[0].strip()
155
+ if any(key in base_type for key in ["int", "integer", "bigint", "smallint"]):
156
+ df[column.name] = df[column.name].astype("Int64")
157
+
158
+ return df
159
+
160
+
121
161
  def _deduplicate_composite_keys(
122
162
  df: pd.DataFrame,
123
163
  table_def: TableDef,
@@ -158,6 +198,45 @@ def _deduplicate_composite_keys(
158
198
  return df
159
199
 
160
200
 
201
+ def _resolve_self_referencing_fks(
202
+ df: pd.DataFrame,
203
+ table_def: TableDef,
204
+ table_name: str,
205
+ fk_lookup: dict[tuple[str, str], tuple[str, str]],
206
+ row_count: int,
207
+ ) -> pd.DataFrame:
208
+ """
209
+ Re-generate any FK column that references its own table (e.g. a
210
+ `manager_id` on `employees` pointing back at `employees.id`) using the
211
+ table's own just-built parent column as the value pool.
212
+
213
+ These columns can't be resolved during the main per-column generation
214
+ pass above because the table isn't done building itself yet (its own
215
+ df isn't added to `generated` until the whole loop iteration finishes),
216
+ so `fk_series` falls through to None there and the column gets
217
+ unrelated random values instead. Once `df` exists we know the real
218
+ parent-column values and can fix it up here.
219
+ """
220
+ for column in table_def.columns:
221
+ fk_target = fk_lookup.get((table_name, column.name))
222
+ if not fk_target:
223
+ continue
224
+
225
+ parent_table, parent_column = fk_target
226
+ if parent_table != table_name or parent_column not in df.columns:
227
+ continue
228
+
229
+ ensure_unique = "pk" in column.settings
230
+ df[column.name] = generate_column_values(
231
+ column=column,
232
+ row_count=row_count,
233
+ fk_series=df[parent_column],
234
+ ensure_unique=ensure_unique,
235
+ )
236
+
237
+ return df
238
+
239
+
161
240
  def _determine_row_count(table_name: str, base_rows: int) -> int:
162
241
  """
163
242
  Return the base number of rows for all tables.
@@ -199,9 +278,17 @@ def _topological_table_order(
199
278
  if indegree[neighbor] == 0:
200
279
  queue.append(neighbor)
201
280
 
202
- # Safety net for disconnected tables
203
- for name in tables.keys():
204
- if name not in order:
205
- order.append(name)
281
+ # Any table not reached by the Kahn's-algorithm pass above never had its
282
+ # indegree reduced to 0, which (unlike a genuinely disconnected table,
283
+ # which starts at indegree 0 and is processed by the loop above) can only
284
+ # happen if it sits inside -- or depends on -- an unresolved multi-table
285
+ # FK cycle. Append it to the order anyway (still generate *something*
286
+ # rather than crash on an unusual-but-not-invalid schema), but record it
287
+ # so the CLI can warn the user their generated FK data may not respect
288
+ # every relationship.
289
+ leftover = sorted(name for name in tables if name not in order)
290
+ if leftover:
291
+ _cycle_state["cyclic_tables"] = leftover
292
+ order.extend(leftover)
206
293
 
207
294
  return order