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.
- {model2data-0.4.1 → model2data-0.5.0}/PKG-INFO +31 -19
- {model2data-0.4.1 → model2data-0.5.0}/README.md +27 -15
- {model2data-0.4.1 → model2data-0.5.0}/model2data/cli.py +15 -4
- {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/project.py +6 -2
- {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/tests.py +118 -54
- {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/core.py +91 -4
- {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/faker.py +40 -0
- model2data-0.5.0/model2data/parse/dbml.py +621 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/PKG-INFO +31 -19
- {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/SOURCES.txt +1 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/requires.txt +3 -3
- {model2data-0.4.1 → model2data-0.5.0}/pyproject.toml +4 -4
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_cli.py +72 -4
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbml_parser.py +486 -12
- model2data-0.5.0/tests/test_dbt_integration.py +104 -0
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbt_project.py +18 -1
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbt_tests.py +174 -11
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_generation.py +164 -1
- model2data-0.4.1/model2data/parse/dbml.py +0 -393
- {model2data-0.4.1 → model2data-0.5.0}/LICENSE +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/dbt/templates/profiles.yml.jinja +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/generate/relationships.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/parse/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data/utils.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/model2data.egg-info/top_level.txt +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/setup.cfg +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_coverage_gaps.py +0 -0
- {model2data-0.4.1 → model2data-0.5.0}/tests/test_dbt_naming.py +0 -0
- {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.
|
|
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
|
|
9
|
-
Requires-Dist: dbt-duckdb>=1.
|
|
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.
|
|
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,
|
|
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 (`
|
|
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
|
|
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
|
-
│ └──
|
|
197
|
+
│ └── raw/
|
|
190
198
|
│ ├── table1.csv
|
|
191
199
|
│ └── table2.csv
|
|
192
200
|
├── models/
|
|
193
|
-
│ └──
|
|
194
|
-
│
|
|
195
|
-
│
|
|
196
|
-
│
|
|
197
|
-
│
|
|
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.
|
|
211
|
-
|
|
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`): `
|
|
214
|
-
from real generated rows
|
|
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,
|
|
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 (`
|
|
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
|
|
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
|
-
│ └──
|
|
172
|
+
│ └── raw/
|
|
165
173
|
│ ├── table1.csv
|
|
166
174
|
│ └── table2.csv
|
|
167
175
|
├── models/
|
|
168
|
-
│ └──
|
|
169
|
-
│
|
|
170
|
-
│
|
|
171
|
-
│
|
|
172
|
-
│
|
|
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.
|
|
186
|
-
|
|
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`): `
|
|
189
|
-
from real generated rows
|
|
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 (
|
|
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', '{
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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
|
-
|
|
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 / "
|
|
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', '{
|
|
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"
|
|
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
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|