model2data 0.4.1__tar.gz → 0.4.3__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.4.3}/PKG-INFO +21 -15
- {model2data-0.4.1 → model2data-0.4.3}/README.md +20 -14
- {model2data-0.4.1 → model2data-0.4.3}/model2data/cli.py +2 -2
- {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/project.py +1 -1
- {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/tests.py +67 -6
- {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/core.py +60 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/faker.py +40 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/parse/dbml.py +64 -5
- {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/PKG-INFO +21 -15
- {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/SOURCES.txt +1 -0
- {model2data-0.4.1 → model2data-0.4.3}/pyproject.toml +1 -1
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_cli.py +4 -4
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbml_parser.py +185 -12
- model2data-0.4.3/tests/test_dbt_integration.py +104 -0
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbt_project.py +1 -1
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbt_tests.py +88 -7
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_generation.py +61 -0
- {model2data-0.4.1 → model2data-0.4.3}/LICENSE +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/templates/profiles.yml.jinja +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/relationships.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/parse/__init__.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data/utils.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/requires.txt +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/top_level.txt +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/setup.cfg +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_coverage_gaps.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbt_naming.py +0 -0
- {model2data-0.4.1 → model2data-0.4.3}/tests/test_faker_name_inference.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Generate analytics-ready datasets from DBML models
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -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,7 +171,7 @@ 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
|
|
@@ -186,32 +188,36 @@ The generated dbt project includes:
|
|
|
186
188
|
```
|
|
187
189
|
dbt_{project_name}/
|
|
188
190
|
├── seeds/
|
|
189
|
-
│ └──
|
|
191
|
+
│ └── raw/
|
|
190
192
|
│ ├── table1.csv
|
|
191
193
|
│ └── table2.csv
|
|
192
194
|
├── models/
|
|
193
|
-
│ └──
|
|
194
|
-
│
|
|
195
|
-
│
|
|
196
|
-
│
|
|
197
|
-
│
|
|
198
|
-
│
|
|
195
|
+
│ └── staging/
|
|
196
|
+
│ ├── __sources.yml
|
|
197
|
+
│ ├── stg_table1.sql
|
|
198
|
+
│ ├── stg_table1.yml
|
|
199
|
+
│ ├── ut_stg_table1.yml # only with --unit-tests
|
|
200
|
+
│ └── ...
|
|
201
|
+
├── data-tests/
|
|
202
|
+
│ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
|
|
199
203
|
├── macros/
|
|
200
204
|
│ └── generate_schema_name.sql
|
|
201
205
|
├── dbt_project.yml
|
|
202
206
|
├── profiles.yml # DuckDB or Postgres config, depending on --adapter
|
|
203
|
-
└── {project_name}.duckdb # DuckDB adapter only
|
|
207
|
+
└── {project_name}_profile.duckdb # DuckDB adapter only
|
|
204
208
|
```
|
|
205
209
|
|
|
206
210
|
- **Seeds**: CSV files with generated synthetic data.
|
|
207
211
|
- **Staging Models**: Basic dbt models that load from seeds.
|
|
208
212
|
- **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
|
|
209
213
|
`relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
|
|
210
|
-
`Note` text from the DBML becomes `description:` fields.
|
|
211
|
-
|
|
214
|
+
`Note` text from the DBML becomes `description:` fields.
|
|
215
|
+
- **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
|
|
216
|
+
a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
|
|
212
217
|
- **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
|
|
218
|
+
- **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
|
|
219
|
+
from real generated rows, co-located with each staging model so dbt (which only parses unit
|
|
220
|
+
tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
|
|
215
221
|
|
|
216
222
|
---
|
|
217
223
|
|
|
@@ -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,7 +146,7 @@ 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
|
|
@@ -161,32 +163,36 @@ The generated dbt project includes:
|
|
|
161
163
|
```
|
|
162
164
|
dbt_{project_name}/
|
|
163
165
|
├── seeds/
|
|
164
|
-
│ └──
|
|
166
|
+
│ └── raw/
|
|
165
167
|
│ ├── table1.csv
|
|
166
168
|
│ └── table2.csv
|
|
167
169
|
├── models/
|
|
168
|
-
│ └──
|
|
169
|
-
│
|
|
170
|
-
│
|
|
171
|
-
│
|
|
172
|
-
│
|
|
173
|
-
│
|
|
170
|
+
│ └── staging/
|
|
171
|
+
│ ├── __sources.yml
|
|
172
|
+
│ ├── stg_table1.sql
|
|
173
|
+
│ ├── stg_table1.yml
|
|
174
|
+
│ ├── ut_stg_table1.yml # only with --unit-tests
|
|
175
|
+
│ └── ...
|
|
176
|
+
├── data-tests/
|
|
177
|
+
│ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
|
|
174
178
|
├── macros/
|
|
175
179
|
│ └── generate_schema_name.sql
|
|
176
180
|
├── dbt_project.yml
|
|
177
181
|
├── profiles.yml # DuckDB or Postgres config, depending on --adapter
|
|
178
|
-
└── {project_name}.duckdb # DuckDB adapter only
|
|
182
|
+
└── {project_name}_profile.duckdb # DuckDB adapter only
|
|
179
183
|
```
|
|
180
184
|
|
|
181
185
|
- **Seeds**: CSV files with generated synthetic data.
|
|
182
186
|
- **Staging Models**: Basic dbt models that load from seeds.
|
|
183
187
|
- **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
|
|
184
188
|
`relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
|
|
185
|
-
`Note` text from the DBML becomes `description:` fields.
|
|
186
|
-
|
|
189
|
+
`Note` text from the DBML becomes `description:` fields.
|
|
190
|
+
- **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
|
|
191
|
+
a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
|
|
187
192
|
- **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
|
|
193
|
+
- **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
|
|
194
|
+
from real generated rows, co-located with each staging model so dbt (which only parses unit
|
|
195
|
+
tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
|
|
190
196
|
|
|
191
197
|
---
|
|
192
198
|
|
|
@@ -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
|
):
|
|
@@ -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
|
|
@@ -6,6 +6,9 @@ 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
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
def _yaml_field_lines(key: str, value: str, indent: str) -> list[str]:
|
|
11
14
|
"""Render `key: value` as safely-escaped YAML lines at the given indent."""
|
|
@@ -28,8 +31,23 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
|
|
|
28
31
|
# -------------------------
|
|
29
32
|
# Build foreign key map
|
|
30
33
|
# -------------------------
|
|
34
|
+
# Only emit a `relationships` test for refs the generator actually makes
|
|
35
|
+
# FK-aware: direct FK refs (target column is a pk/"id"), plus attribute
|
|
36
|
+
# refs that ride along an existing FK between the same two tables (see
|
|
37
|
+
# generate.core's attribute-mirroring pass). An attribute ref with no
|
|
38
|
+
# accompanying FK is left as unrelated random data by the generator, so
|
|
39
|
+
# testing it against the parent table would be a guaranteed false
|
|
40
|
+
# failure.
|
|
41
|
+
fk_refs_classified, attribute_refs_classified = classify_refs(tables, refs)
|
|
42
|
+
fk_table_pairs = {(fk["source_table"], fk["target_table"]) for fk in fk_refs_classified}
|
|
43
|
+
eligible_refs = list(fk_refs_classified) + [
|
|
44
|
+
ref
|
|
45
|
+
for ref in attribute_refs_classified
|
|
46
|
+
if (ref["source_table"], ref["target_table"]) in fk_table_pairs
|
|
47
|
+
]
|
|
48
|
+
|
|
31
49
|
fk_map = defaultdict(list)
|
|
32
|
-
for ref in
|
|
50
|
+
for ref in eligible_refs:
|
|
33
51
|
fk_map[(ref["source_table"], ref["source_column"])].append(ref)
|
|
34
52
|
|
|
35
53
|
# -------------------------
|
|
@@ -102,9 +120,10 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
|
|
|
102
120
|
lines.append(f" - {test}")
|
|
103
121
|
elif "accepted_values" in test:
|
|
104
122
|
lines.append(" - accepted_values:")
|
|
105
|
-
lines.append("
|
|
123
|
+
lines.append(" arguments:")
|
|
124
|
+
lines.append(" values:")
|
|
106
125
|
for value in test["accepted_values"]["values"]:
|
|
107
|
-
lines.append(f"
|
|
126
|
+
lines.append(f" - {value!r}")
|
|
108
127
|
else:
|
|
109
128
|
# relationships test with arguments
|
|
110
129
|
for k, v in test.items():
|
|
@@ -122,9 +141,47 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
|
|
|
122
141
|
# -------------------------
|
|
123
142
|
_generate_composite_key_tests(dest, tables)
|
|
124
143
|
|
|
144
|
+
# -------------------------
|
|
145
|
+
# Seed column-type overrides
|
|
146
|
+
# -------------------------
|
|
147
|
+
_generate_seed_config(dest, tables)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _generate_seed_config(dest: Path, tables: dict) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Force every free-text column (see `is_free_text_type`) to VARCHAR in
|
|
153
|
+
the seed loader config, instead of letting dbt/duckdb sniff the type
|
|
154
|
+
from CSV content. Some generated text is all-digit (EAN13 barcodes,
|
|
155
|
+
zero-padded postcodes, ...) and would otherwise be silently loaded as
|
|
156
|
+
an integer, overflowing or dropping leading zeros.
|
|
157
|
+
"""
|
|
158
|
+
lines = ["version: 2", "", "seeds:"]
|
|
159
|
+
any_column_types = False
|
|
160
|
+
|
|
161
|
+
for table in tables.values():
|
|
162
|
+
column_types = {
|
|
163
|
+
col.name: "varchar" for col in table.columns if is_free_text_type(col.data_type)
|
|
164
|
+
}
|
|
165
|
+
if not column_types:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
any_column_types = True
|
|
169
|
+
lines.append(f" - name: {table.name}")
|
|
170
|
+
lines.append(" config:")
|
|
171
|
+
lines.append(" column_types:")
|
|
172
|
+
for col_name, col_type in column_types.items():
|
|
173
|
+
lines.append(f" {col_name}: {col_type}")
|
|
174
|
+
|
|
175
|
+
if not any_column_types:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
seed_raw_path = dest / "seeds" / "raw"
|
|
179
|
+
seed_raw_path.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
(seed_raw_path / "__seed_config.yml").write_text("\n".join(lines))
|
|
181
|
+
|
|
125
182
|
|
|
126
183
|
def _generate_composite_key_tests(dest: Path, tables: dict) -> None:
|
|
127
|
-
tests_path = dest / "tests"
|
|
184
|
+
tests_path = dest / "data-tests"
|
|
128
185
|
tests_path.mkdir(parents=True, exist_ok=True)
|
|
129
186
|
|
|
130
187
|
for table in tables.values():
|
|
@@ -158,8 +215,12 @@ def generate_unit_tests(
|
|
|
158
215
|
|
|
159
216
|
Since staging models are pure `select * from {{ source(...) }}` passthroughs,
|
|
160
217
|
a handful of already-generated rows can serve as both `given` and `expect`.
|
|
218
|
+
|
|
219
|
+
Written alongside each staging model (under `model-paths`, which is where
|
|
220
|
+
dbt actually parses unit tests from) as `ut_stg_<table>.yml`, parallel to
|
|
221
|
+
the `stg_<table>.yml` schema file generated by `generate_dbt_yml`.
|
|
161
222
|
"""
|
|
162
|
-
unit_tests_path = dest / "
|
|
223
|
+
unit_tests_path = dest / "models" / "staging"
|
|
163
224
|
unit_tests_path.mkdir(parents=True, exist_ok=True)
|
|
164
225
|
|
|
165
226
|
for table in tables.values():
|
|
@@ -188,7 +249,7 @@ def generate_unit_tests(
|
|
|
188
249
|
]
|
|
189
250
|
}
|
|
190
251
|
|
|
191
|
-
yml_file = unit_tests_path / f"
|
|
252
|
+
yml_file = unit_tests_path / f"ut_{stg_name}.yml"
|
|
192
253
|
yml_file.write_text(yaml.safe_dump(unit_test, sort_keys=False, default_flow_style=False))
|
|
193
254
|
|
|
194
255
|
|
|
@@ -76,6 +76,7 @@ def generate_data_from_dbml(
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
df = pd.DataFrame(data)
|
|
79
|
+
df = _resolve_self_referencing_fks(df, table_def, table_name, fk_lookup, row_count)
|
|
79
80
|
df = _deduplicate_composite_keys(df, table_def)
|
|
80
81
|
|
|
81
82
|
# -----------------------------------------------------
|
|
@@ -110,6 +111,7 @@ def generate_data_from_dbml(
|
|
|
110
111
|
|
|
111
112
|
df[child_column] = df[fk_column].map(lookup)
|
|
112
113
|
|
|
114
|
+
df = _coerce_integer_dtypes(df, table_def)
|
|
113
115
|
generated[table_name] = df
|
|
114
116
|
|
|
115
117
|
return generated
|
|
@@ -118,6 +120,25 @@ def generate_data_from_dbml(
|
|
|
118
120
|
# ---------------------------------------------------------
|
|
119
121
|
# Internal helpers
|
|
120
122
|
# ---------------------------------------------------------
|
|
123
|
+
def _coerce_integer_dtypes(df: pd.DataFrame, table_def: TableDef) -> pd.DataFrame:
|
|
124
|
+
"""
|
|
125
|
+
Cast int/bigint/smallint-typed columns to pandas' nullable "Int64" dtype.
|
|
126
|
+
|
|
127
|
+
`generate_column_values` fills "empty" nullable columns with `None`
|
|
128
|
+
(absent an explicit default). Building a plain `pd.DataFrame` from a
|
|
129
|
+
Python list mixing ints and `None` silently upcasts that column to
|
|
130
|
+
float64, so whole numbers round-trip through the CSV seed as "70.0"
|
|
131
|
+
instead of "70" and blanks. Int64 keeps them as integers and renders
|
|
132
|
+
nulls as empty cells, matching the DBML-declared type.
|
|
133
|
+
"""
|
|
134
|
+
for column in table_def.columns:
|
|
135
|
+
base_type = column.data_type.lower().split("(")[0].strip()
|
|
136
|
+
if any(key in base_type for key in ["int", "integer", "bigint", "smallint"]):
|
|
137
|
+
df[column.name] = df[column.name].astype("Int64")
|
|
138
|
+
|
|
139
|
+
return df
|
|
140
|
+
|
|
141
|
+
|
|
121
142
|
def _deduplicate_composite_keys(
|
|
122
143
|
df: pd.DataFrame,
|
|
123
144
|
table_def: TableDef,
|
|
@@ -158,6 +179,45 @@ def _deduplicate_composite_keys(
|
|
|
158
179
|
return df
|
|
159
180
|
|
|
160
181
|
|
|
182
|
+
def _resolve_self_referencing_fks(
|
|
183
|
+
df: pd.DataFrame,
|
|
184
|
+
table_def: TableDef,
|
|
185
|
+
table_name: str,
|
|
186
|
+
fk_lookup: dict[tuple[str, str], tuple[str, str]],
|
|
187
|
+
row_count: int,
|
|
188
|
+
) -> pd.DataFrame:
|
|
189
|
+
"""
|
|
190
|
+
Re-generate any FK column that references its own table (e.g. a
|
|
191
|
+
`manager_id` on `employees` pointing back at `employees.id`) using the
|
|
192
|
+
table's own just-built parent column as the value pool.
|
|
193
|
+
|
|
194
|
+
These columns can't be resolved during the main per-column generation
|
|
195
|
+
pass above because the table isn't done building itself yet (its own
|
|
196
|
+
df isn't added to `generated` until the whole loop iteration finishes),
|
|
197
|
+
so `fk_series` falls through to None there and the column gets
|
|
198
|
+
unrelated random values instead. Once `df` exists we know the real
|
|
199
|
+
parent-column values and can fix it up here.
|
|
200
|
+
"""
|
|
201
|
+
for column in table_def.columns:
|
|
202
|
+
fk_target = fk_lookup.get((table_name, column.name))
|
|
203
|
+
if not fk_target:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
parent_table, parent_column = fk_target
|
|
207
|
+
if parent_table != table_name or parent_column not in df.columns:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
ensure_unique = "pk" in column.settings
|
|
211
|
+
df[column.name] = generate_column_values(
|
|
212
|
+
column=column,
|
|
213
|
+
row_count=row_count,
|
|
214
|
+
fk_series=df[parent_column],
|
|
215
|
+
ensure_unique=ensure_unique,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return df
|
|
219
|
+
|
|
220
|
+
|
|
161
221
|
def _determine_row_count(table_name: str, base_rows: int) -> int:
|
|
162
222
|
"""
|
|
163
223
|
Return the base number of rows for all tables.
|
|
@@ -62,6 +62,46 @@ _NAME_PATTERNS: list[tuple[str, Callable[[], object]]] = [
|
|
|
62
62
|
("summary", lambda: fake.text(max_nb_chars=160)),
|
|
63
63
|
]
|
|
64
64
|
|
|
65
|
+
# DBML type substrings generate_column_values renders as a database-native
|
|
66
|
+
# numeric/boolean/date/uuid value rather than arbitrary text. Shared with
|
|
67
|
+
# `is_free_text_type` below so seed column-type config stays in sync with
|
|
68
|
+
# actual generation.
|
|
69
|
+
_STRUCTURED_TYPE_KEYS = (
|
|
70
|
+
"uuid",
|
|
71
|
+
"hash",
|
|
72
|
+
"int",
|
|
73
|
+
"integer",
|
|
74
|
+
"bigint",
|
|
75
|
+
"smallint",
|
|
76
|
+
"decimal",
|
|
77
|
+
"numeric",
|
|
78
|
+
"float",
|
|
79
|
+
"double",
|
|
80
|
+
"boolean",
|
|
81
|
+
"bool",
|
|
82
|
+
"date",
|
|
83
|
+
"time",
|
|
84
|
+
"timestamp",
|
|
85
|
+
"datetime",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_free_text_type(data_type: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
True for DBML types generate_column_values fills with arbitrary text
|
|
92
|
+
(name-pattern lookups, a literal Faker provider, or the generic
|
|
93
|
+
fallback) rather than a numeric/boolean/date/uuid value.
|
|
94
|
+
|
|
95
|
+
Used to force such seed columns to VARCHAR in the generated dbt
|
|
96
|
+
project: some Faker-produced text (EAN13 barcodes, postcodes with a
|
|
97
|
+
leading zero, ...) is entirely digits, which is enough for dbt's CSV
|
|
98
|
+
seed loader to mis-infer an integer column and either overflow or
|
|
99
|
+
silently strip meaningful leading zeros.
|
|
100
|
+
"""
|
|
101
|
+
base_type = data_type.lower().split("(")[0].strip()
|
|
102
|
+
return not any(key in base_type for key in _STRUCTURED_TYPE_KEYS)
|
|
103
|
+
|
|
104
|
+
|
|
65
105
|
# Column/table introspection helpers used by both generation and the
|
|
66
106
|
# CLI's post-run summary, so the two stay in sync.
|
|
67
107
|
_stats_state: dict[str, list[tuple[str, str]]] = {"unmapped": []}
|
|
@@ -68,17 +68,38 @@ def _parse_default_value(raw: str) -> Optional[object]:
|
|
|
68
68
|
return None
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
# Matches the "> table.column" / "< table.column" / "<> table.column" part of
|
|
72
|
+
# an inline `ref: > table.column` column setting. Table/column names may be
|
|
73
|
+
# bare, double-quoted, or backtick-quoted, mirroring the standalone `Ref {}`
|
|
74
|
+
# block parser's identifier handling.
|
|
75
|
+
_INLINE_REF_RE = re.compile(r"^(<>|[<>])\s*(\".*?\"|`.*?`|[\w]+)\.(\".*?\"|`.*?`|[\w]+)$")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_inline_ref(value: str) -> Optional[dict]:
|
|
79
|
+
"""Parse the value of a `ref:` column setting into operator + target table/column."""
|
|
80
|
+
match = _INLINE_REF_RE.match(value.strip())
|
|
81
|
+
if not match:
|
|
82
|
+
return None
|
|
83
|
+
operator, target_table, target_column = match.groups()
|
|
84
|
+
return {
|
|
85
|
+
"operator": operator,
|
|
86
|
+
"target_table": _strip_quotes(target_table),
|
|
87
|
+
"target_column": _strip_quotes(target_column),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
71
91
|
def _parse_column_settings(
|
|
72
92
|
raw: Optional[str],
|
|
73
|
-
) -> tuple[set[str], Optional[dict], Optional[str], Optional[object]]:
|
|
74
|
-
"""Parse column settings, extracting note/description
|
|
93
|
+
) -> tuple[set[str], Optional[dict], Optional[str], Optional[object], Optional[dict]]:
|
|
94
|
+
"""Parse column settings, extracting note/description/default/inline ref if present."""
|
|
75
95
|
if not raw:
|
|
76
|
-
return set(), None, None, None
|
|
96
|
+
return set(), None, None, None, None
|
|
77
97
|
|
|
78
98
|
settings = set()
|
|
79
99
|
note_dict = None
|
|
80
100
|
description = None
|
|
81
101
|
default_value = None
|
|
102
|
+
inline_ref = None
|
|
82
103
|
|
|
83
104
|
# Split by comma, but be careful with nested structures
|
|
84
105
|
parts = []
|
|
@@ -119,11 +140,13 @@ def _parse_column_settings(
|
|
|
119
140
|
elif part.lower().startswith("default:"):
|
|
120
141
|
default_str = part[len("default:") :].strip()
|
|
121
142
|
default_value = _parse_default_value(default_str)
|
|
143
|
+
elif part.lower().startswith("ref:"):
|
|
144
|
+
inline_ref = _parse_inline_ref(part[len("ref:") :])
|
|
122
145
|
else:
|
|
123
146
|
# Regular setting (pk, not null, unique, etc.)
|
|
124
147
|
settings.add(part.strip("'").strip('"').lower())
|
|
125
148
|
|
|
126
|
-
return settings, note_dict, description, default_value
|
|
149
|
+
return settings, note_dict, description, default_value, inline_ref
|
|
127
150
|
|
|
128
151
|
|
|
129
152
|
# Many-to-many (`<>`) refs are captured but not fed into FK-based generation.
|
|
@@ -310,7 +333,7 @@ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
|
|
|
310
333
|
if len(col_type.split()) > 3:
|
|
311
334
|
continue
|
|
312
335
|
|
|
313
|
-
settings, note_dict, description, default_value = _parse_column_settings(
|
|
336
|
+
settings, note_dict, description, default_value, inline_ref = _parse_column_settings(
|
|
314
337
|
col_match.group(3)
|
|
315
338
|
)
|
|
316
339
|
|
|
@@ -325,6 +348,42 @@ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
|
|
|
325
348
|
)
|
|
326
349
|
)
|
|
327
350
|
|
|
351
|
+
if inline_ref is not None:
|
|
352
|
+
target_table = inline_ref["target_table"]
|
|
353
|
+
target_column = inline_ref["target_column"]
|
|
354
|
+
|
|
355
|
+
if inline_ref["operator"] == "<>":
|
|
356
|
+
many_to_many_refs.append(
|
|
357
|
+
{
|
|
358
|
+
"source_table": current_table.name,
|
|
359
|
+
"source_column": col_name,
|
|
360
|
+
"target_table": target_table,
|
|
361
|
+
"target_column": target_column,
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
elif inline_ref["operator"] == "<":
|
|
365
|
+
# "this column is referenced by target.column": the
|
|
366
|
+
# target is the actual FK-holding (child) side, so it
|
|
367
|
+
# becomes source; this column becomes target, mirroring
|
|
368
|
+
# the standalone `Ref { a < b }` swap below.
|
|
369
|
+
refs.append(
|
|
370
|
+
{
|
|
371
|
+
"source_table": target_table,
|
|
372
|
+
"source_column": target_column,
|
|
373
|
+
"target_table": current_table.name,
|
|
374
|
+
"target_column": col_name,
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
refs.append(
|
|
379
|
+
{
|
|
380
|
+
"source_table": current_table.name,
|
|
381
|
+
"source_column": col_name,
|
|
382
|
+
"target_table": target_table,
|
|
383
|
+
"target_column": target_column,
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
|
|
328
387
|
continue
|
|
329
388
|
|
|
330
389
|
# ----------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Generate analytics-ready datasets from DBML models
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -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,7 +171,7 @@ 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
|
|
@@ -186,32 +188,36 @@ The generated dbt project includes:
|
|
|
186
188
|
```
|
|
187
189
|
dbt_{project_name}/
|
|
188
190
|
├── seeds/
|
|
189
|
-
│ └──
|
|
191
|
+
│ └── raw/
|
|
190
192
|
│ ├── table1.csv
|
|
191
193
|
│ └── table2.csv
|
|
192
194
|
├── models/
|
|
193
|
-
│ └──
|
|
194
|
-
│
|
|
195
|
-
│
|
|
196
|
-
│
|
|
197
|
-
│
|
|
198
|
-
│
|
|
195
|
+
│ └── staging/
|
|
196
|
+
│ ├── __sources.yml
|
|
197
|
+
│ ├── stg_table1.sql
|
|
198
|
+
│ ├── stg_table1.yml
|
|
199
|
+
│ ├── ut_stg_table1.yml # only with --unit-tests
|
|
200
|
+
│ └── ...
|
|
201
|
+
├── data-tests/
|
|
202
|
+
│ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
|
|
199
203
|
├── macros/
|
|
200
204
|
│ └── generate_schema_name.sql
|
|
201
205
|
├── dbt_project.yml
|
|
202
206
|
├── profiles.yml # DuckDB or Postgres config, depending on --adapter
|
|
203
|
-
└── {project_name}.duckdb # DuckDB adapter only
|
|
207
|
+
└── {project_name}_profile.duckdb # DuckDB adapter only
|
|
204
208
|
```
|
|
205
209
|
|
|
206
210
|
- **Seeds**: CSV files with generated synthetic data.
|
|
207
211
|
- **Staging Models**: Basic dbt models that load from seeds.
|
|
208
212
|
- **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
|
|
209
213
|
`relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
|
|
210
|
-
`Note` text from the DBML becomes `description:` fields.
|
|
211
|
-
|
|
214
|
+
`Note` text from the DBML becomes `description:` fields.
|
|
215
|
+
- **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
|
|
216
|
+
a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
|
|
212
217
|
- **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
|
|
218
|
+
- **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
|
|
219
|
+
from real generated rows, co-located with each staging model so dbt (which only parses unit
|
|
220
|
+
tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
|
|
215
221
|
|
|
216
222
|
---
|
|
217
223
|
|
|
@@ -577,7 +577,7 @@ def test_cli_all_output_messages_present(tmp_path):
|
|
|
577
577
|
|
|
578
578
|
|
|
579
579
|
def test_cli_without_unit_tests_flag_creates_no_unit_test_dir(tmp_path):
|
|
580
|
-
"""--unit-tests defaults to off: no
|
|
580
|
+
"""--unit-tests defaults to off: no unit test YAML should appear."""
|
|
581
581
|
dbml_file = tmp_path / "test.dbml"
|
|
582
582
|
dbml_file.write_text(
|
|
583
583
|
"""
|
|
@@ -596,11 +596,11 @@ def test_cli_without_unit_tests_flag_creates_no_unit_test_dir(tmp_path):
|
|
|
596
596
|
os.chdir(original_cwd)
|
|
597
597
|
|
|
598
598
|
assert result.exit_code == 0
|
|
599
|
-
assert not (tmp_path / "dbt_test" / "
|
|
599
|
+
assert not list((tmp_path / "dbt_test" / "models" / "staging").glob("ut_*.yml"))
|
|
600
600
|
|
|
601
601
|
|
|
602
602
|
def test_cli_with_unit_tests_flag_generates_unit_test_yaml(tmp_path):
|
|
603
|
-
"""--unit-tests writes
|
|
603
|
+
"""--unit-tests writes models/staging/ut_stg_<table>.yml and prints the dbt-core note."""
|
|
604
604
|
dbml_file = tmp_path / "test.dbml"
|
|
605
605
|
dbml_file.write_text(
|
|
606
606
|
"""
|
|
@@ -632,7 +632,7 @@ def test_cli_with_unit_tests_flag_generates_unit_test_yaml(tmp_path):
|
|
|
632
632
|
assert result.exit_code == 0
|
|
633
633
|
assert "dbt-core >= 1.8" in result.stdout
|
|
634
634
|
|
|
635
|
-
unit_yml = tmp_path / "dbt_test" / "
|
|
635
|
+
unit_yml = tmp_path / "dbt_test" / "models" / "staging" / "ut_stg_users.yml"
|
|
636
636
|
assert unit_yml.exists()
|
|
637
637
|
|
|
638
638
|
import yaml
|