model2data 0.2.2__tar.gz → 0.3.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.2.2 → model2data-0.3.0}/PKG-INFO +29 -13
- {model2data-0.2.2 → model2data-0.3.0}/README.md +26 -12
- {model2data-0.2.2 → model2data-0.3.0}/model2data/cli.py +39 -2
- {model2data-0.2.2 → model2data-0.3.0}/model2data/dbt/project.py +3 -3
- model2data-0.3.0/model2data/dbt/templates/dbt_project.yml.jinja +29 -0
- model2data-0.3.0/model2data/dbt/templates/macros/generate_schema_name.sql +9 -0
- model2data-0.3.0/model2data/dbt/templates/profiles.yml.jinja +18 -0
- model2data-0.3.0/model2data/generate/faker.py +221 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/parse/dbml.py +9 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/PKG-INFO +29 -13
- {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/SOURCES.txt +4 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/requires.txt +3 -0
- {model2data-0.2.2 → model2data-0.3.0}/pyproject.toml +10 -1
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_cli.py +83 -0
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbml_parser.py +24 -0
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbt_project.py +55 -0
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbt_tests.py +1 -0
- model2data-0.3.0/tests/test_faker_name_inference.py +79 -0
- model2data-0.2.2/model2data/generate/faker.py +0 -122
- {model2data-0.2.2 → model2data-0.3.0}/LICENSE +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/__init__.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/dbt/__init__.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/dbt/tests.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/generate/__init__.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/generate/core.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/generate/relationships.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/parse/__init__.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data/utils.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/top_level.txt +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/setup.cfg +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_coverage_gaps.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbt_naming.py +0 -0
- {model2data-0.2.2 → model2data-0.3.0}/tests/test_generation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Generate analytics-ready datasets from DBML models
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -11,6 +11,8 @@ 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
|
+
Provides-Extra: postgres
|
|
15
|
+
Requires-Dist: dbt-postgres>=1.5.0; extra == "postgres"
|
|
14
16
|
Provides-Extra: dev
|
|
15
17
|
Requires-Dist: pytest; extra == "dev"
|
|
16
18
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -44,8 +46,8 @@ Building analytics or testing dbt pipelines often requires realistic data, but u
|
|
|
44
46
|
## How it works (high level)
|
|
45
47
|
|
|
46
48
|
1. **Parse DBML**: Reads your database schema from a DBML file, extracting tables, columns, types, and relationships.
|
|
47
|
-
2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints.
|
|
48
|
-
3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB.
|
|
49
|
+
2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints. Column names are matched against common patterns (`email`, `first_name`, `city`, `phone`, `company`, ...) so a column called `email` gets real-looking emails instead of generic text.
|
|
50
|
+
3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB or Postgres.
|
|
49
51
|
|
|
50
52
|
---
|
|
51
53
|
|
|
@@ -64,7 +66,7 @@ We provide an example Hacker News dataset in `examples/hackernews.dbml`.
|
|
|
64
66
|
Generate a project with synthetic data:
|
|
65
67
|
|
|
66
68
|
```bash
|
|
67
|
-
model2data
|
|
69
|
+
model2data --file examples/hackernews.dbml --rows 200 --seed 42
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
This creates a `dbt_hackernews/` folder with your data and dbt setup.
|
|
@@ -80,6 +82,17 @@ dbt run
|
|
|
80
82
|
|
|
81
83
|
Your analytics-ready dataset is now in DuckDB!
|
|
82
84
|
|
|
85
|
+
To target Postgres instead, install the extra and pass `--adapter postgres`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install "model2data[postgres]"
|
|
89
|
+
model2data --file examples/hackernews.dbml --rows 200 --seed 42 --adapter postgres
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `MODEL2DATA_PG_PORT`, `MODEL2DATA_PG_USER`, `MODEL2DATA_PG_PASSWORD`, `MODEL2DATA_PG_DATABASE`), defaulting to `localhost:5432` with a `postgres`/`postgres` user for local development.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
83
96
|
---
|
|
84
97
|
|
|
85
98
|
## Generated dbt project structure
|
|
@@ -102,20 +115,20 @@ dbt_{project_name}/
|
|
|
102
115
|
├── macros/
|
|
103
116
|
│ └── generate_schema_name.sql
|
|
104
117
|
├── dbt_project.yml
|
|
105
|
-
├── profiles.yml # DuckDB config
|
|
106
|
-
└── {project_name}.duckdb
|
|
118
|
+
├── profiles.yml # DuckDB or Postgres config, depending on --adapter
|
|
119
|
+
└── {project_name}.duckdb # DuckDB adapter only
|
|
107
120
|
```
|
|
108
121
|
|
|
109
122
|
- **Seeds**: CSV files with generated synthetic data.
|
|
110
123
|
- **Staging Models**: Basic dbt models that load from seeds.
|
|
111
124
|
- **Sources & Tests**: YAML configs defining sources and basic tests (not_null, unique).
|
|
112
|
-
- **Profiles**: Pre-configured for DuckDB with schema handling.
|
|
125
|
+
- **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
|
|
113
126
|
|
|
114
127
|
---
|
|
115
128
|
|
|
116
129
|
## Design decisions / non-goals
|
|
117
130
|
|
|
118
|
-
- **DuckDB Default**: Chosen for its zero-config, file-based nature, making it easy to get started without database setup.
|
|
131
|
+
- **DuckDB Default**: Chosen for its zero-config, file-based nature, making it easy to get started without database setup. Postgres is supported via `--adapter postgres`; other adapters can be configured manually.
|
|
119
132
|
- **dbt Integration**: Leverages dbt's transformation capabilities for a familiar workflow in analytics engineering.
|
|
120
133
|
- **Synthetic Data**: Uses deterministic generation for reproducibility; not intended for production use or as a replacement for real data.
|
|
121
134
|
- **Non-goals**: This is not a data migration tool, ETL pipeline, or real-time data generator. It focuses on static, synthetic datasets for testing and prototyping.
|
|
@@ -126,17 +139,20 @@ dbt_{project_name}/
|
|
|
126
139
|
|
|
127
140
|
- Supports basic DBML features; complex constraints or advanced SQL types may not be fully handled.
|
|
128
141
|
- Synthetic data generation is heuristic-based and may not perfectly mimic real-world distributions or edge cases.
|
|
129
|
-
-
|
|
142
|
+
- DuckDB and Postgres are supported today; other databases require manual profile adjustments.
|
|
130
143
|
- No support for incremental models or advanced dbt features in generated projects.
|
|
131
144
|
|
|
132
145
|
---
|
|
133
146
|
|
|
134
147
|
## Roadmap
|
|
135
148
|
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
149
|
+
- [x] Postgres adapter support (`--adapter postgres`)
|
|
150
|
+
- [x] Name-aware synthetic data (email, name, address, phone, etc. instead of generic text)
|
|
151
|
+
- [x] Post-run generation summary (tables, rows, relationships, unmapped columns)
|
|
152
|
+
- [ ] Additional database adapters (e.g., Snowflake, BigQuery).
|
|
153
|
+
- [ ] Enhanced data type handling and custom generators.
|
|
154
|
+
- [ ] Integration with more dbt features like incremental models.
|
|
155
|
+
- [ ] Web-based DBML editor and data preview.
|
|
140
156
|
|
|
141
157
|
---
|
|
142
158
|
|
|
@@ -20,8 +20,8 @@ Building analytics or testing dbt pipelines often requires realistic data, but u
|
|
|
20
20
|
## How it works (high level)
|
|
21
21
|
|
|
22
22
|
1. **Parse DBML**: Reads your database schema from a DBML file, extracting tables, columns, types, and relationships.
|
|
23
|
-
2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints.
|
|
24
|
-
3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB.
|
|
23
|
+
2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints. Column names are matched against common patterns (`email`, `first_name`, `city`, `phone`, `company`, ...) so a column called `email` gets real-looking emails instead of generic text.
|
|
24
|
+
3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB or Postgres.
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -40,7 +40,7 @@ We provide an example Hacker News dataset in `examples/hackernews.dbml`.
|
|
|
40
40
|
Generate a project with synthetic data:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
model2data
|
|
43
|
+
model2data --file examples/hackernews.dbml --rows 200 --seed 42
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
This creates a `dbt_hackernews/` folder with your data and dbt setup.
|
|
@@ -56,6 +56,17 @@ dbt run
|
|
|
56
56
|
|
|
57
57
|
Your analytics-ready dataset is now in DuckDB!
|
|
58
58
|
|
|
59
|
+
To target Postgres instead, install the extra and pass `--adapter postgres`:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install "model2data[postgres]"
|
|
63
|
+
model2data --file examples/hackernews.dbml --rows 200 --seed 42 --adapter postgres
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `MODEL2DATA_PG_PORT`, `MODEL2DATA_PG_USER`, `MODEL2DATA_PG_PASSWORD`, `MODEL2DATA_PG_DATABASE`), defaulting to `localhost:5432` with a `postgres`/`postgres` user for local development.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
59
70
|
---
|
|
60
71
|
|
|
61
72
|
## Generated dbt project structure
|
|
@@ -78,20 +89,20 @@ dbt_{project_name}/
|
|
|
78
89
|
├── macros/
|
|
79
90
|
│ └── generate_schema_name.sql
|
|
80
91
|
├── dbt_project.yml
|
|
81
|
-
├── profiles.yml # DuckDB config
|
|
82
|
-
└── {project_name}.duckdb
|
|
92
|
+
├── profiles.yml # DuckDB or Postgres config, depending on --adapter
|
|
93
|
+
└── {project_name}.duckdb # DuckDB adapter only
|
|
83
94
|
```
|
|
84
95
|
|
|
85
96
|
- **Seeds**: CSV files with generated synthetic data.
|
|
86
97
|
- **Staging Models**: Basic dbt models that load from seeds.
|
|
87
98
|
- **Sources & Tests**: YAML configs defining sources and basic tests (not_null, unique).
|
|
88
|
-
- **Profiles**: Pre-configured for DuckDB with schema handling.
|
|
99
|
+
- **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
|
|
89
100
|
|
|
90
101
|
---
|
|
91
102
|
|
|
92
103
|
## Design decisions / non-goals
|
|
93
104
|
|
|
94
|
-
- **DuckDB Default**: Chosen for its zero-config, file-based nature, making it easy to get started without database setup.
|
|
105
|
+
- **DuckDB Default**: Chosen for its zero-config, file-based nature, making it easy to get started without database setup. Postgres is supported via `--adapter postgres`; other adapters can be configured manually.
|
|
95
106
|
- **dbt Integration**: Leverages dbt's transformation capabilities for a familiar workflow in analytics engineering.
|
|
96
107
|
- **Synthetic Data**: Uses deterministic generation for reproducibility; not intended for production use or as a replacement for real data.
|
|
97
108
|
- **Non-goals**: This is not a data migration tool, ETL pipeline, or real-time data generator. It focuses on static, synthetic datasets for testing and prototyping.
|
|
@@ -102,17 +113,20 @@ dbt_{project_name}/
|
|
|
102
113
|
|
|
103
114
|
- Supports basic DBML features; complex constraints or advanced SQL types may not be fully handled.
|
|
104
115
|
- Synthetic data generation is heuristic-based and may not perfectly mimic real-world distributions or edge cases.
|
|
105
|
-
-
|
|
116
|
+
- DuckDB and Postgres are supported today; other databases require manual profile adjustments.
|
|
106
117
|
- No support for incremental models or advanced dbt features in generated projects.
|
|
107
118
|
|
|
108
119
|
---
|
|
109
120
|
|
|
110
121
|
## Roadmap
|
|
111
122
|
|
|
112
|
-
-
|
|
113
|
-
-
|
|
114
|
-
-
|
|
115
|
-
-
|
|
123
|
+
- [x] Postgres adapter support (`--adapter postgres`)
|
|
124
|
+
- [x] Name-aware synthetic data (email, name, address, phone, etc. instead of generic text)
|
|
125
|
+
- [x] Post-run generation summary (tables, rows, relationships, unmapped columns)
|
|
126
|
+
- [ ] Additional database adapters (e.g., Snowflake, BigQuery).
|
|
127
|
+
- [ ] Enhanced data type handling and custom generators.
|
|
128
|
+
- [ ] Integration with more dbt features like incremental models.
|
|
129
|
+
- [ ] Web-based DBML editor and data preview.
|
|
116
130
|
|
|
117
131
|
---
|
|
118
132
|
|
|
@@ -13,9 +13,12 @@ from model2data.dbt.project import (
|
|
|
13
13
|
)
|
|
14
14
|
from model2data.dbt.tests import generate_dbt_yml
|
|
15
15
|
from model2data.generate.core import generate_data_from_dbml
|
|
16
|
+
from model2data.generate.faker import get_unmapped_columns, reset_stats
|
|
16
17
|
from model2data.parse.dbml import parse_dbml
|
|
17
18
|
from model2data.utils import normalize_identifier
|
|
18
19
|
|
|
20
|
+
SUPPORTED_ADAPTERS = ("duckdb", "postgres")
|
|
21
|
+
|
|
19
22
|
app = typer.Typer(
|
|
20
23
|
help=(
|
|
21
24
|
"model2data: Generate analytics-ready datasets from DBML models.\n\n"
|
|
@@ -67,11 +70,27 @@ def main(
|
|
|
67
70
|
"--force",
|
|
68
71
|
help="Overwrite the destination directory if it already exists.",
|
|
69
72
|
),
|
|
73
|
+
adapter: str = typer.Option(
|
|
74
|
+
"duckdb",
|
|
75
|
+
"--adapter",
|
|
76
|
+
"-a",
|
|
77
|
+
help=f"dbt warehouse adapter to target. One of: {', '.join(SUPPORTED_ADAPTERS)}.",
|
|
78
|
+
),
|
|
70
79
|
):
|
|
71
80
|
"""
|
|
72
81
|
Generate synthetic data and a dbt project from a DBML model.
|
|
73
82
|
"""
|
|
74
83
|
|
|
84
|
+
# -------------------------
|
|
85
|
+
# Validate adapter
|
|
86
|
+
# -------------------------
|
|
87
|
+
adapter = adapter.lower()
|
|
88
|
+
if adapter not in SUPPORTED_ADAPTERS:
|
|
89
|
+
typer.echo(
|
|
90
|
+
f"❌ Unsupported adapter '{adapter}'. Choose one of: {', '.join(SUPPORTED_ADAPTERS)}."
|
|
91
|
+
)
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
75
94
|
# -------------------------
|
|
76
95
|
# Deterministic seed
|
|
77
96
|
# -------------------------
|
|
@@ -108,6 +127,7 @@ def main(
|
|
|
108
127
|
# Generate synthetic data
|
|
109
128
|
# -------------------------
|
|
110
129
|
typer.echo("🧮 Generating synthetic datasets from DBML definitions...")
|
|
130
|
+
reset_stats()
|
|
111
131
|
generated_tables = generate_data_from_dbml(
|
|
112
132
|
tables=tables,
|
|
113
133
|
refs=refs,
|
|
@@ -132,12 +152,29 @@ def main(
|
|
|
132
152
|
typer.echo("🧪 Generating dbt yml with tests...")
|
|
133
153
|
generate_dbt_yml(dest, tables, refs, project_name)
|
|
134
154
|
|
|
135
|
-
typer.echo("🪪 Ensuring dbt profile exists...")
|
|
136
|
-
create_profiles_yml(dest, profile_name)
|
|
155
|
+
typer.echo(f"🪪 Ensuring dbt profile exists ({adapter})...")
|
|
156
|
+
create_profiles_yml(dest, profile_name, adapter=adapter)
|
|
137
157
|
|
|
138
158
|
# Keep original DBML for reference
|
|
139
159
|
shutil.copy(file, dest / file.name)
|
|
140
160
|
|
|
161
|
+
# -------------------------
|
|
162
|
+
# Summary
|
|
163
|
+
# -------------------------
|
|
164
|
+
total_rows = sum(len(df) for df in generated_tables.values())
|
|
165
|
+
unmapped = get_unmapped_columns()
|
|
166
|
+
|
|
167
|
+
typer.echo("\n📊 Summary")
|
|
168
|
+
typer.echo(f" Tables generated: {len(generated_tables)}")
|
|
169
|
+
typer.echo(f" Rows generated: {total_rows}")
|
|
170
|
+
typer.echo(f" Relationships in DBML: {len(refs)}")
|
|
171
|
+
if unmapped:
|
|
172
|
+
typer.echo(f" Columns using generic fallback text: {len(unmapped)}")
|
|
173
|
+
for col_name, data_type in unmapped:
|
|
174
|
+
typer.echo(f" - {col_name} ({data_type})")
|
|
175
|
+
else:
|
|
176
|
+
typer.echo(" Columns using generic fallback text: 0")
|
|
177
|
+
|
|
141
178
|
# -------------------------
|
|
142
179
|
# Done
|
|
143
180
|
# -------------------------
|
|
@@ -24,7 +24,7 @@ def create_project_scaffold(dest: Path, project_name: str, profile_name: str) ->
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
# Copy over any macros from templates
|
|
27
|
-
template_macros_dir =
|
|
27
|
+
template_macros_dir = TEMPLATES_DIR / "macros"
|
|
28
28
|
if template_macros_dir.exists():
|
|
29
29
|
for macro_file in template_macros_dir.glob("*.sql"):
|
|
30
30
|
target_file = dest / "macros" / macro_file.name
|
|
@@ -53,7 +53,7 @@ from {{{{ source('raw', '{table_name}') }}}}
|
|
|
53
53
|
model_file.write_text(sql_content)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def create_profiles_yml(dest: Path, profile_name: str) -> None:
|
|
56
|
+
def create_profiles_yml(dest: Path, profile_name: str, adapter: str = "duckdb") -> None:
|
|
57
57
|
profiles_file = dest / "profiles.yml"
|
|
58
58
|
if profiles_file.exists():
|
|
59
59
|
content = profiles_file.read_text()
|
|
@@ -62,7 +62,7 @@ def create_profiles_yml(dest: Path, profile_name: str) -> None:
|
|
|
62
62
|
_render_template(
|
|
63
63
|
template_name="profiles.yml.jinja",
|
|
64
64
|
output_path=profiles_file,
|
|
65
|
-
context={"profile_name": profile_name},
|
|
65
|
+
context={"profile_name": profile_name, "adapter": adapter},
|
|
66
66
|
)
|
|
67
67
|
|
|
68
68
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: '{{ project_name }}'
|
|
2
|
+
version: '1.0'
|
|
3
|
+
config-version: 2
|
|
4
|
+
|
|
5
|
+
profile: '{{ profile_name }}'
|
|
6
|
+
|
|
7
|
+
model-paths: ["models"]
|
|
8
|
+
analysis-paths: ["analyses"]
|
|
9
|
+
test-paths: ["data-tests"]
|
|
10
|
+
seed-paths: ["seeds"]
|
|
11
|
+
macro-paths: ["macros"]
|
|
12
|
+
snapshot-paths: ["snapshots"]
|
|
13
|
+
|
|
14
|
+
target-path: "target"
|
|
15
|
+
clean-targets:
|
|
16
|
+
- "target"
|
|
17
|
+
- "dbt_packages"
|
|
18
|
+
|
|
19
|
+
models:
|
|
20
|
+
{{ project_name }}:
|
|
21
|
+
staging:
|
|
22
|
+
+schema: staging
|
|
23
|
+
+materialized: view
|
|
24
|
+
marts:
|
|
25
|
+
+schema: marts
|
|
26
|
+
+materialized: table
|
|
27
|
+
|
|
28
|
+
seeds:
|
|
29
|
+
+schema: raw
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{{ profile_name }}:
|
|
2
|
+
target: dev
|
|
3
|
+
outputs:
|
|
4
|
+
dev:
|
|
5
|
+
{% if adapter == "postgres" %}
|
|
6
|
+
type: postgres
|
|
7
|
+
threads: 4
|
|
8
|
+
host: "{% raw %}{{ env_var('MODEL2DATA_PG_HOST', 'localhost') }}{% endraw %}"
|
|
9
|
+
port: "{% raw %}{{ env_var('MODEL2DATA_PG_PORT', '5432') | as_number }}{% endraw %}"
|
|
10
|
+
user: "{% raw %}{{ env_var('MODEL2DATA_PG_USER', 'postgres') }}{% endraw %}"
|
|
11
|
+
pass: "{% raw %}{{ env_var('MODEL2DATA_PG_PASSWORD', 'postgres') }}{% endraw %}"
|
|
12
|
+
dbname: "{% raw %}{{ env_var('MODEL2DATA_PG_DATABASE', 'postgres') }}{% endraw %}"
|
|
13
|
+
schema: "{{ profile_name }}"
|
|
14
|
+
{% else %}
|
|
15
|
+
type: duckdb
|
|
16
|
+
threads: 1
|
|
17
|
+
path: "{{ profile_name }}.duckdb"
|
|
18
|
+
{% endif %}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from faker import Faker
|
|
11
|
+
|
|
12
|
+
from model2data.parse.dbml import ColumnDef
|
|
13
|
+
|
|
14
|
+
fake = Faker()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------
|
|
18
|
+
# Column-name -> Faker provider inference
|
|
19
|
+
# ---------------------------------------------------------
|
|
20
|
+
# Ordered most-specific first: a column like "first_name" must match
|
|
21
|
+
# "first_name" before any looser pattern gets a chance. There is
|
|
22
|
+
# deliberately no generic "name" pattern, since "product_name" or
|
|
23
|
+
# "company_name" would otherwise be filled with a person's name.
|
|
24
|
+
_NAME_PATTERNS: list[tuple[str, Callable[[], object]]] = [
|
|
25
|
+
("first_name", lambda: fake.first_name()),
|
|
26
|
+
("last_name", lambda: fake.last_name()),
|
|
27
|
+
("full_name", lambda: fake.name()),
|
|
28
|
+
("user_name", lambda: fake.user_name()),
|
|
29
|
+
("username", lambda: fake.user_name()),
|
|
30
|
+
("password", lambda: fake.password()),
|
|
31
|
+
("email", lambda: fake.email()),
|
|
32
|
+
("phone", lambda: fake.phone_number()),
|
|
33
|
+
("mobile", lambda: fake.phone_number()),
|
|
34
|
+
("fax", lambda: fake.phone_number()),
|
|
35
|
+
("street", lambda: fake.street_address()),
|
|
36
|
+
("address", lambda: fake.address().replace("\n", ", ")),
|
|
37
|
+
("city", lambda: fake.city()),
|
|
38
|
+
("province", lambda: fake.state()),
|
|
39
|
+
("state", lambda: fake.state()),
|
|
40
|
+
("country", lambda: fake.country()),
|
|
41
|
+
("zip", lambda: fake.postcode()),
|
|
42
|
+
("postal", lambda: fake.postcode()),
|
|
43
|
+
("homepage", lambda: fake.url()),
|
|
44
|
+
("website", lambda: fake.url()),
|
|
45
|
+
("url", lambda: fake.url()),
|
|
46
|
+
("domain", lambda: fake.domain_name()),
|
|
47
|
+
("employer", lambda: fake.company()),
|
|
48
|
+
("company", lambda: fake.company()),
|
|
49
|
+
("job_title", lambda: fake.job()),
|
|
50
|
+
("ip_address", lambda: fake.ipv4()),
|
|
51
|
+
("colour", lambda: fake.color_name()),
|
|
52
|
+
("color", lambda: fake.color_name()),
|
|
53
|
+
("currency", lambda: fake.currency_code()),
|
|
54
|
+
("latitude", lambda: fake.latitude()),
|
|
55
|
+
("longitude", lambda: fake.longitude()),
|
|
56
|
+
("slug", lambda: fake.slug()),
|
|
57
|
+
("avatar", lambda: fake.image_url()),
|
|
58
|
+
("image", lambda: fake.image_url()),
|
|
59
|
+
("bio", lambda: fake.text(max_nb_chars=160)),
|
|
60
|
+
("description", lambda: fake.text(max_nb_chars=160)),
|
|
61
|
+
("comment", lambda: fake.text(max_nb_chars=160)),
|
|
62
|
+
("summary", lambda: fake.text(max_nb_chars=160)),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# Column/table introspection helpers used by both generation and the
|
|
66
|
+
# CLI's post-run summary, so the two stay in sync.
|
|
67
|
+
_stats_state: dict[str, list[tuple[str, str]]] = {"unmapped": []}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def reset_stats() -> None:
|
|
71
|
+
"""Clear the record of columns that fell back to generic text."""
|
|
72
|
+
_stats_state["unmapped"] = []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_unmapped_columns() -> list[tuple[str, str]]:
|
|
76
|
+
"""Return (column_name, data_type) pairs generated with a generic fallback."""
|
|
77
|
+
return list(_stats_state["unmapped"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _infer_by_name(column_name: str) -> Optional[Callable[[], object]]:
|
|
81
|
+
normalized = re.sub(r"[^a-z0-9]+", "_", column_name.lower())
|
|
82
|
+
padded = f"_{normalized}_"
|
|
83
|
+
for pattern, generator in _NAME_PATTERNS:
|
|
84
|
+
if f"_{pattern}_" in padded:
|
|
85
|
+
return generator
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------
|
|
90
|
+
# Public API
|
|
91
|
+
# ---------------------------------------------------------
|
|
92
|
+
def generate_column_values(
|
|
93
|
+
column: ColumnDef,
|
|
94
|
+
row_count: int,
|
|
95
|
+
fk_series: Optional[pd.Series] = None,
|
|
96
|
+
ensure_unique: bool = False,
|
|
97
|
+
) -> list:
|
|
98
|
+
"""
|
|
99
|
+
Generate synthetic values for a single column.
|
|
100
|
+
Respects FKs, uniqueness, and optional min/max hints in column notes.
|
|
101
|
+
"""
|
|
102
|
+
if fk_series is not None and not fk_series.empty:
|
|
103
|
+
fk_values = fk_series.tolist()
|
|
104
|
+
return [random.choice(fk_values) for _ in range(row_count)]
|
|
105
|
+
|
|
106
|
+
dtype = column.data_type.lower()
|
|
107
|
+
base_type = dtype.split("(")[0].strip()
|
|
108
|
+
values: list = []
|
|
109
|
+
|
|
110
|
+
# Extract min/max from note if present
|
|
111
|
+
min_val = None
|
|
112
|
+
max_val = None
|
|
113
|
+
if column.note:
|
|
114
|
+
min_val = column.note.get("min")
|
|
115
|
+
max_val = column.note.get("max")
|
|
116
|
+
|
|
117
|
+
# -----------------------------------------------------
|
|
118
|
+
# UUIDs / hashes
|
|
119
|
+
# -----------------------------------------------------
|
|
120
|
+
if "uuid" in base_type or "hash" in base_type:
|
|
121
|
+
values = [str(uuid.uuid4()) for _ in range(row_count)]
|
|
122
|
+
|
|
123
|
+
# -----------------------------------------------------
|
|
124
|
+
# Integers
|
|
125
|
+
# -----------------------------------------------------
|
|
126
|
+
elif any(key in base_type for key in ["int", "integer", "bigint", "smallint"]):
|
|
127
|
+
# Use note values if present, otherwise defaults
|
|
128
|
+
if min_val is None:
|
|
129
|
+
min_val = 0
|
|
130
|
+
if max_val is None:
|
|
131
|
+
max_val = 100
|
|
132
|
+
values = [random.randint(min_val, max_val) for _ in range(row_count)]
|
|
133
|
+
|
|
134
|
+
# -----------------------------------------------------
|
|
135
|
+
# Floats / decimals
|
|
136
|
+
# -----------------------------------------------------
|
|
137
|
+
elif any(key in base_type for key in ["decimal", "numeric", "float", "double"]):
|
|
138
|
+
if min_val is None:
|
|
139
|
+
min_val = 0
|
|
140
|
+
if max_val is None:
|
|
141
|
+
max_val = 10_000
|
|
142
|
+
values = [round(random.uniform(min_val, max_val), 2) for _ in range(row_count)]
|
|
143
|
+
|
|
144
|
+
# -----------------------------------------------------
|
|
145
|
+
# Booleans
|
|
146
|
+
# -----------------------------------------------------
|
|
147
|
+
elif "boolean" in base_type or "bool" in base_type:
|
|
148
|
+
values = [random.choice([True, False]) for _ in range(row_count)]
|
|
149
|
+
|
|
150
|
+
# -----------------------------------------------------
|
|
151
|
+
# Dates
|
|
152
|
+
# -----------------------------------------------------
|
|
153
|
+
elif "date" in base_type and "time" not in base_type:
|
|
154
|
+
values = [fake.date_between(start_date="-2y", end_date="today") for _ in range(row_count)]
|
|
155
|
+
|
|
156
|
+
elif "time" in base_type and "stamp" not in base_type:
|
|
157
|
+
values = [fake.time() for _ in range(row_count)]
|
|
158
|
+
|
|
159
|
+
elif any(key in base_type for key in ["timestamp", "datetime"]):
|
|
160
|
+
values = [_random_datetime().isoformat(sep=" ") for _ in range(row_count)]
|
|
161
|
+
|
|
162
|
+
# -----------------------------------------------------
|
|
163
|
+
# Untyped / generic string columns: infer intent from the
|
|
164
|
+
# column name first (email, city, phone...), then fall back
|
|
165
|
+
# to a literal Faker provider name, then to a generic value.
|
|
166
|
+
# -----------------------------------------------------
|
|
167
|
+
else:
|
|
168
|
+
name_generator = _infer_by_name(column.name)
|
|
169
|
+
if name_generator is not None:
|
|
170
|
+
values = [name_generator() for _ in range(row_count)]
|
|
171
|
+
values = _deduplicate(values, name_generator) if ensure_unique else values
|
|
172
|
+
else:
|
|
173
|
+
try:
|
|
174
|
+
values = [fake.format(base_type) for _ in range(row_count)]
|
|
175
|
+
except (AttributeError, TypeError):
|
|
176
|
+
if column.name.lower().endswith("_id") or ensure_unique:
|
|
177
|
+
values = [str(uuid.uuid4()) for _ in range(row_count)]
|
|
178
|
+
else:
|
|
179
|
+
_stats_state["unmapped"].append((column.name, column.data_type))
|
|
180
|
+
values = [fake.sentence(nb_words=3) for _ in range(row_count)]
|
|
181
|
+
|
|
182
|
+
# -----------------------------------------------------
|
|
183
|
+
# Nullability
|
|
184
|
+
# -----------------------------------------------------
|
|
185
|
+
if "not null" not in column.settings:
|
|
186
|
+
null_fraction = max(0, min(0.2, 1 - (row_count / (row_count + 50))))
|
|
187
|
+
sample_size = int(row_count * null_fraction)
|
|
188
|
+
if sample_size:
|
|
189
|
+
for idx in random.sample(range(row_count), k=sample_size):
|
|
190
|
+
values[idx] = None
|
|
191
|
+
|
|
192
|
+
return values
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------
|
|
196
|
+
# Internal helpers
|
|
197
|
+
# ---------------------------------------------------------
|
|
198
|
+
def _deduplicate(values: list, generator: Callable[[], object], max_attempts: int = 20) -> list:
|
|
199
|
+
"""
|
|
200
|
+
Best-effort de-duplication for name-inferred values (e.g. unique emails).
|
|
201
|
+
Retries collisions a bounded number of times, then accepts remaining
|
|
202
|
+
duplicates rather than looping forever on a small value space.
|
|
203
|
+
"""
|
|
204
|
+
seen: set = set()
|
|
205
|
+
result = []
|
|
206
|
+
for value in values:
|
|
207
|
+
attempts = 0
|
|
208
|
+
while value in seen and attempts < max_attempts:
|
|
209
|
+
value = generator()
|
|
210
|
+
attempts += 1
|
|
211
|
+
seen.add(value)
|
|
212
|
+
result.append(value)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _random_datetime(start_days: int = -365, end_days: int = 0) -> datetime:
|
|
217
|
+
start = datetime.now() + timedelta(days=start_days)
|
|
218
|
+
end = datetime.now() + timedelta(days=end_days)
|
|
219
|
+
delta = end - start
|
|
220
|
+
random_second = random.randint(0, int(delta.total_seconds()))
|
|
221
|
+
return start + timedelta(seconds=random_second)
|
|
@@ -101,6 +101,7 @@ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
|
|
|
101
101
|
|
|
102
102
|
current_table: Optional[TableDef] = None
|
|
103
103
|
in_indexes_block = False
|
|
104
|
+
in_note_block = False
|
|
104
105
|
note_block_depth = 0
|
|
105
106
|
in_ref_block = False
|
|
106
107
|
|
|
@@ -140,12 +141,20 @@ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
|
|
|
140
141
|
if cleaned.endswith("}"):
|
|
141
142
|
in_indexes_block = False
|
|
142
143
|
continue
|
|
144
|
+
if in_note_block:
|
|
145
|
+
if cleaned.endswith("}"):
|
|
146
|
+
in_note_block = False
|
|
147
|
+
continue
|
|
143
148
|
if cleaned.startswith("}"):
|
|
144
149
|
tables[current_table.name] = current_table
|
|
145
150
|
current_table = None
|
|
146
151
|
continue
|
|
147
152
|
if cleaned.startswith("Note:"):
|
|
148
153
|
continue
|
|
154
|
+
# Multi-line table note: `Note {` ... `}` (not a column definition)
|
|
155
|
+
if cleaned.startswith("Note") and cleaned.rstrip().endswith("{"):
|
|
156
|
+
in_note_block = True
|
|
157
|
+
continue
|
|
149
158
|
|
|
150
159
|
col_match = re.match(
|
|
151
160
|
r'^(".*?"|`.*?`|[A-Za-z_][\w]*)\s+(.+?)(?:\s+\[(.+)\])?$',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Generate analytics-ready datasets from DBML models
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -11,6 +11,8 @@ 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
|
+
Provides-Extra: postgres
|
|
15
|
+
Requires-Dist: dbt-postgres>=1.5.0; extra == "postgres"
|
|
14
16
|
Provides-Extra: dev
|
|
15
17
|
Requires-Dist: pytest; extra == "dev"
|
|
16
18
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -44,8 +46,8 @@ Building analytics or testing dbt pipelines often requires realistic data, but u
|
|
|
44
46
|
## How it works (high level)
|
|
45
47
|
|
|
46
48
|
1. **Parse DBML**: Reads your database schema from a DBML file, extracting tables, columns, types, and relationships.
|
|
47
|
-
2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints.
|
|
48
|
-
3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB.
|
|
49
|
+
2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints. Column names are matched against common patterns (`email`, `first_name`, `city`, `phone`, `company`, ...) so a column called `email` gets real-looking emails instead of generic text.
|
|
50
|
+
3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB or Postgres.
|
|
49
51
|
|
|
50
52
|
---
|
|
51
53
|
|
|
@@ -64,7 +66,7 @@ We provide an example Hacker News dataset in `examples/hackernews.dbml`.
|
|
|
64
66
|
Generate a project with synthetic data:
|
|
65
67
|
|
|
66
68
|
```bash
|
|
67
|
-
model2data
|
|
69
|
+
model2data --file examples/hackernews.dbml --rows 200 --seed 42
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
This creates a `dbt_hackernews/` folder with your data and dbt setup.
|
|
@@ -80,6 +82,17 @@ dbt run
|
|
|
80
82
|
|
|
81
83
|
Your analytics-ready dataset is now in DuckDB!
|
|
82
84
|
|
|
85
|
+
To target Postgres instead, install the extra and pass `--adapter postgres`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install "model2data[postgres]"
|
|
89
|
+
model2data --file examples/hackernews.dbml --rows 200 --seed 42 --adapter postgres
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `MODEL2DATA_PG_PORT`, `MODEL2DATA_PG_USER`, `MODEL2DATA_PG_PASSWORD`, `MODEL2DATA_PG_DATABASE`), defaulting to `localhost:5432` with a `postgres`/`postgres` user for local development.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
83
96
|
---
|
|
84
97
|
|
|
85
98
|
## Generated dbt project structure
|
|
@@ -102,20 +115,20 @@ dbt_{project_name}/
|
|
|
102
115
|
├── macros/
|
|
103
116
|
│ └── generate_schema_name.sql
|
|
104
117
|
├── dbt_project.yml
|
|
105
|
-
├── profiles.yml # DuckDB config
|
|
106
|
-
└── {project_name}.duckdb
|
|
118
|
+
├── profiles.yml # DuckDB or Postgres config, depending on --adapter
|
|
119
|
+
└── {project_name}.duckdb # DuckDB adapter only
|
|
107
120
|
```
|
|
108
121
|
|
|
109
122
|
- **Seeds**: CSV files with generated synthetic data.
|
|
110
123
|
- **Staging Models**: Basic dbt models that load from seeds.
|
|
111
124
|
- **Sources & Tests**: YAML configs defining sources and basic tests (not_null, unique).
|
|
112
|
-
- **Profiles**: Pre-configured for DuckDB with schema handling.
|
|
125
|
+
- **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
|
|
113
126
|
|
|
114
127
|
---
|
|
115
128
|
|
|
116
129
|
## Design decisions / non-goals
|
|
117
130
|
|
|
118
|
-
- **DuckDB Default**: Chosen for its zero-config, file-based nature, making it easy to get started without database setup.
|
|
131
|
+
- **DuckDB Default**: Chosen for its zero-config, file-based nature, making it easy to get started without database setup. Postgres is supported via `--adapter postgres`; other adapters can be configured manually.
|
|
119
132
|
- **dbt Integration**: Leverages dbt's transformation capabilities for a familiar workflow in analytics engineering.
|
|
120
133
|
- **Synthetic Data**: Uses deterministic generation for reproducibility; not intended for production use or as a replacement for real data.
|
|
121
134
|
- **Non-goals**: This is not a data migration tool, ETL pipeline, or real-time data generator. It focuses on static, synthetic datasets for testing and prototyping.
|
|
@@ -126,17 +139,20 @@ dbt_{project_name}/
|
|
|
126
139
|
|
|
127
140
|
- Supports basic DBML features; complex constraints or advanced SQL types may not be fully handled.
|
|
128
141
|
- Synthetic data generation is heuristic-based and may not perfectly mimic real-world distributions or edge cases.
|
|
129
|
-
-
|
|
142
|
+
- DuckDB and Postgres are supported today; other databases require manual profile adjustments.
|
|
130
143
|
- No support for incremental models or advanced dbt features in generated projects.
|
|
131
144
|
|
|
132
145
|
---
|
|
133
146
|
|
|
134
147
|
## Roadmap
|
|
135
148
|
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
149
|
+
- [x] Postgres adapter support (`--adapter postgres`)
|
|
150
|
+
- [x] Name-aware synthetic data (email, name, address, phone, etc. instead of generic text)
|
|
151
|
+
- [x] Post-run generation summary (tables, rows, relationships, unmapped columns)
|
|
152
|
+
- [ ] Additional database adapters (e.g., Snowflake, BigQuery).
|
|
153
|
+
- [ ] Enhanced data type handling and custom generators.
|
|
154
|
+
- [ ] Integration with more dbt features like incremental models.
|
|
155
|
+
- [ ] Web-based DBML editor and data preview.
|
|
140
156
|
|
|
141
157
|
---
|
|
142
158
|
|
|
@@ -13,6 +13,9 @@ model2data.egg-info/top_level.txt
|
|
|
13
13
|
model2data/dbt/__init__.py
|
|
14
14
|
model2data/dbt/project.py
|
|
15
15
|
model2data/dbt/tests.py
|
|
16
|
+
model2data/dbt/templates/dbt_project.yml.jinja
|
|
17
|
+
model2data/dbt/templates/profiles.yml.jinja
|
|
18
|
+
model2data/dbt/templates/macros/generate_schema_name.sql
|
|
16
19
|
model2data/generate/__init__.py
|
|
17
20
|
model2data/generate/core.py
|
|
18
21
|
model2data/generate/faker.py
|
|
@@ -25,4 +28,5 @@ tests/test_dbml_parser.py
|
|
|
25
28
|
tests/test_dbt_naming.py
|
|
26
29
|
tests/test_dbt_project.py
|
|
27
30
|
tests/test_dbt_tests.py
|
|
31
|
+
tests/test_faker_name_inference.py
|
|
28
32
|
tests/test_generation.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "model2data"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Generate analytics-ready datasets from DBML models"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -18,6 +18,9 @@ dependencies = [
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
|
+
postgres = [
|
|
22
|
+
"dbt-postgres>=1.5.0",
|
|
23
|
+
]
|
|
21
24
|
dev = [
|
|
22
25
|
"pytest",
|
|
23
26
|
"pytest-cov",
|
|
@@ -36,6 +39,12 @@ model2data = "model2data.cli:app"
|
|
|
36
39
|
where = ["."]
|
|
37
40
|
include = ["model2data*"]
|
|
38
41
|
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
model2data = [
|
|
44
|
+
"dbt/templates/**/*",
|
|
45
|
+
"examples/**/*",
|
|
46
|
+
]
|
|
47
|
+
|
|
39
48
|
# Ruff configuration (fast Python linter)
|
|
40
49
|
[tool.ruff]
|
|
41
50
|
target-version = "py39"
|
|
@@ -108,6 +108,89 @@ def test_cli_no_tables_found(tmp_path):
|
|
|
108
108
|
assert "❌ No tables found in the provided DBML file." in result.stdout
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
def test_cli_rejects_unsupported_adapter(tmp_path):
|
|
112
|
+
"""Test CLI exits cleanly on an unknown --adapter value."""
|
|
113
|
+
dbml_file = tmp_path / "test.dbml"
|
|
114
|
+
dbml_file.write_text(
|
|
115
|
+
"""
|
|
116
|
+
Table users {
|
|
117
|
+
id int [pk]
|
|
118
|
+
}
|
|
119
|
+
"""
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
original_cwd = os.getcwd()
|
|
123
|
+
try:
|
|
124
|
+
os.chdir(tmp_path)
|
|
125
|
+
result = runner.invoke(
|
|
126
|
+
app,
|
|
127
|
+
["--file", str(dbml_file), "--adapter", "snowflake"],
|
|
128
|
+
)
|
|
129
|
+
finally:
|
|
130
|
+
os.chdir(original_cwd)
|
|
131
|
+
|
|
132
|
+
assert result.exit_code == 1
|
|
133
|
+
assert "Unsupported adapter" in result.stdout
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_cli_postgres_adapter_generates_postgres_profile(tmp_path):
|
|
137
|
+
"""Test that --adapter postgres produces a postgres profiles.yml."""
|
|
138
|
+
dbml_file = tmp_path / "test.dbml"
|
|
139
|
+
dbml_file.write_text(
|
|
140
|
+
"""
|
|
141
|
+
Table users {
|
|
142
|
+
id int [pk]
|
|
143
|
+
email varchar
|
|
144
|
+
}
|
|
145
|
+
"""
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
original_cwd = os.getcwd()
|
|
149
|
+
try:
|
|
150
|
+
os.chdir(tmp_path)
|
|
151
|
+
result = runner.invoke(
|
|
152
|
+
app,
|
|
153
|
+
["--file", str(dbml_file), "--rows", "10", "--adapter", "postgres"],
|
|
154
|
+
)
|
|
155
|
+
finally:
|
|
156
|
+
os.chdir(original_cwd)
|
|
157
|
+
|
|
158
|
+
assert result.exit_code == 0
|
|
159
|
+
profiles_yml = tmp_path / "dbt_test" / "profiles.yml"
|
|
160
|
+
content = profiles_yml.read_text()
|
|
161
|
+
assert "type: postgres" in content
|
|
162
|
+
assert "env_var('MODEL2DATA_PG_HOST'" in content
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_cli_prints_generation_summary(tmp_path):
|
|
166
|
+
"""Test that the post-run summary reports table/row/relationship counts."""
|
|
167
|
+
dbml_file = tmp_path / "test.dbml"
|
|
168
|
+
dbml_file.write_text(
|
|
169
|
+
"""
|
|
170
|
+
Table users {
|
|
171
|
+
id int [pk]
|
|
172
|
+
weird_field custom_unmapped_type
|
|
173
|
+
}
|
|
174
|
+
"""
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
original_cwd = os.getcwd()
|
|
178
|
+
try:
|
|
179
|
+
os.chdir(tmp_path)
|
|
180
|
+
result = runner.invoke(
|
|
181
|
+
app,
|
|
182
|
+
["--file", str(dbml_file), "--rows", "10"],
|
|
183
|
+
)
|
|
184
|
+
finally:
|
|
185
|
+
os.chdir(original_cwd)
|
|
186
|
+
|
|
187
|
+
assert result.exit_code == 0
|
|
188
|
+
assert "📊 Summary" in result.stdout
|
|
189
|
+
assert "Tables generated: 1" in result.stdout
|
|
190
|
+
assert "Rows generated: 10" in result.stdout
|
|
191
|
+
assert "weird_field" in result.stdout
|
|
192
|
+
|
|
193
|
+
|
|
111
194
|
def test_cli_with_custom_name(tmp_path):
|
|
112
195
|
"""Test CLI with custom project name."""
|
|
113
196
|
dbml_file = tmp_path / "test.dbml"
|
|
@@ -628,6 +628,30 @@ name varchar
|
|
|
628
628
|
assert len(tables["t"].columns) == 2
|
|
629
629
|
|
|
630
630
|
|
|
631
|
+
def test_note_curly_brace_block_inside_table(tmp_path):
|
|
632
|
+
"""Test the `Note { ... }` block form (distinct from `Note: '...'`).
|
|
633
|
+
|
|
634
|
+
Regression test: this form used to fall through to column parsing and
|
|
635
|
+
produce a spurious column named "Note" with data_type "{".
|
|
636
|
+
"""
|
|
637
|
+
dbml_file = tmp_path / "note_curly.dbml"
|
|
638
|
+
dbml_file.write_text(
|
|
639
|
+
"""Table t {
|
|
640
|
+
id int
|
|
641
|
+
name varchar
|
|
642
|
+
Note {
|
|
643
|
+
'Describes this table across multiple lines'
|
|
644
|
+
}
|
|
645
|
+
email varchar
|
|
646
|
+
}
|
|
647
|
+
"""
|
|
648
|
+
)
|
|
649
|
+
tables, refs = parse_dbml(dbml_file)
|
|
650
|
+
columns = tables["t"].columns
|
|
651
|
+
assert len(columns) == 3
|
|
652
|
+
assert all(col.name != "Note" for col in columns)
|
|
653
|
+
|
|
654
|
+
|
|
631
655
|
def test_indexes_block(tmp_path):
|
|
632
656
|
"""Test indexes block is properly ignored."""
|
|
633
657
|
dbml_file = tmp_path / "with_indexes.dbml"
|
|
@@ -3,6 +3,7 @@ import tempfile
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
import pytest
|
|
6
|
+
import yaml
|
|
6
7
|
|
|
7
8
|
from model2data.dbt.project import (
|
|
8
9
|
TEMPLATES_DIR,
|
|
@@ -37,6 +38,21 @@ def test_create_project_scaffold_creates_directories(temp_dir):
|
|
|
37
38
|
assert (temp_dir / "snapshots").exists()
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
def test_create_project_scaffold_copies_schema_macro_regardless_of_cwd(temp_dir, monkeypatch):
|
|
42
|
+
"""Regression test: macro copy used to rely on a CWD-relative path, so it
|
|
43
|
+
silently copied nothing when model2data was run from anywhere outside the
|
|
44
|
+
repo checkout (e.g. after a normal `pip install`). Without the macro,
|
|
45
|
+
generated projects fail at `dbt run` with a schema-not-found error.
|
|
46
|
+
"""
|
|
47
|
+
monkeypatch.chdir(temp_dir.parent)
|
|
48
|
+
|
|
49
|
+
create_project_scaffold(temp_dir, "test_project", "test_profile")
|
|
50
|
+
|
|
51
|
+
macro_file = temp_dir / "macros" / "generate_schema_name.sql"
|
|
52
|
+
assert macro_file.exists()
|
|
53
|
+
assert "generate_schema_name" in macro_file.read_text()
|
|
54
|
+
|
|
55
|
+
|
|
40
56
|
def test_create_project_scaffold_creates_dbt_project_yml(temp_dir):
|
|
41
57
|
"""Test that dbt_project.yml is created with correct content."""
|
|
42
58
|
project_name = "my_analytics"
|
|
@@ -180,6 +196,45 @@ def test_create_profiles_yml_appends_if_different_profile(temp_dir):
|
|
|
180
196
|
assert profiles_file.exists()
|
|
181
197
|
|
|
182
198
|
|
|
199
|
+
def test_create_profiles_yml_defaults_to_duckdb(temp_dir):
|
|
200
|
+
"""Test that omitting adapter still produces a duckdb profile."""
|
|
201
|
+
profile_name = "duck_profile"
|
|
202
|
+
|
|
203
|
+
create_profiles_yml(temp_dir, profile_name)
|
|
204
|
+
|
|
205
|
+
content = (temp_dir / "profiles.yml").read_text()
|
|
206
|
+
parsed = yaml.safe_load(content)
|
|
207
|
+
assert parsed[profile_name]["outputs"]["dev"]["type"] == "duckdb"
|
|
208
|
+
assert parsed[profile_name]["outputs"]["dev"]["path"] == f"{profile_name}.duckdb"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_create_profiles_yml_postgres_adapter(temp_dir):
|
|
212
|
+
"""Test that the postgres adapter renders a valid, env-var-driven profile."""
|
|
213
|
+
profile_name = "pg_profile"
|
|
214
|
+
|
|
215
|
+
create_profiles_yml(temp_dir, profile_name, adapter="postgres")
|
|
216
|
+
|
|
217
|
+
content = (temp_dir / "profiles.yml").read_text()
|
|
218
|
+
parsed = yaml.safe_load(content)
|
|
219
|
+
dev_output = parsed[profile_name]["outputs"]["dev"]
|
|
220
|
+
|
|
221
|
+
assert dev_output["type"] == "postgres"
|
|
222
|
+
assert dev_output["schema"] == profile_name
|
|
223
|
+
# Connection details are sourced from env vars, not hardcoded secrets.
|
|
224
|
+
assert "env_var('MODEL2DATA_PG_HOST'" in content
|
|
225
|
+
assert "env_var('MODEL2DATA_PG_PORT'" in content
|
|
226
|
+
assert "env_var('MODEL2DATA_PG_USER'" in content
|
|
227
|
+
assert "env_var('MODEL2DATA_PG_PASSWORD'" in content
|
|
228
|
+
assert "env_var('MODEL2DATA_PG_DATABASE'" in content
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_create_profiles_yml_rejects_no_hardcoded_duckdb_type_for_postgres(temp_dir):
|
|
232
|
+
"""Guard against the postgres branch accidentally falling through to duckdb."""
|
|
233
|
+
create_profiles_yml(temp_dir, "mixed_profile", adapter="postgres")
|
|
234
|
+
content = (temp_dir / "profiles.yml").read_text()
|
|
235
|
+
assert "type: duckdb" not in content
|
|
236
|
+
|
|
237
|
+
|
|
183
238
|
def test_render_template_creates_output(temp_dir):
|
|
184
239
|
"""Test that _render_template creates output file with rendered content."""
|
|
185
240
|
# This test assumes templates exist in the templates directory
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for column-name-based Faker inference in generate.faker."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from model2data.generate.faker import (
|
|
6
|
+
_deduplicate,
|
|
7
|
+
generate_column_values,
|
|
8
|
+
get_unmapped_columns,
|
|
9
|
+
reset_stats,
|
|
10
|
+
)
|
|
11
|
+
from model2data.parse.dbml import ColumnDef
|
|
12
|
+
|
|
13
|
+
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestNameInference:
|
|
17
|
+
def test_email_column_gets_real_emails(self):
|
|
18
|
+
col = ColumnDef(name="email", data_type="varchar", settings={"not null"})
|
|
19
|
+
values = generate_column_values(col, row_count=20)
|
|
20
|
+
assert all(EMAIL_RE.match(v) for v in values)
|
|
21
|
+
|
|
22
|
+
def test_first_and_last_name_are_distinct_providers(self):
|
|
23
|
+
first = generate_column_values(
|
|
24
|
+
ColumnDef(name="first_name", data_type="varchar", settings={"not null"}),
|
|
25
|
+
row_count=10,
|
|
26
|
+
)
|
|
27
|
+
last = generate_column_values(
|
|
28
|
+
ColumnDef(name="last_name", data_type="varchar", settings={"not null"}),
|
|
29
|
+
row_count=10,
|
|
30
|
+
)
|
|
31
|
+
# Different providers should not consistently produce identical output.
|
|
32
|
+
assert first != last
|
|
33
|
+
|
|
34
|
+
def test_generic_product_name_is_not_treated_as_a_person_name(self):
|
|
35
|
+
# "name" alone is intentionally unmapped so "product_name" style
|
|
36
|
+
# columns don't get filled with people's names.
|
|
37
|
+
col = ColumnDef(name="product_name", data_type="varchar", settings={"not null"})
|
|
38
|
+
values = generate_column_values(col, row_count=5)
|
|
39
|
+
assert len(values) == 5
|
|
40
|
+
assert all(isinstance(v, str) for v in values)
|
|
41
|
+
|
|
42
|
+
def test_city_and_country_columns(self):
|
|
43
|
+
city_values = generate_column_values(
|
|
44
|
+
ColumnDef(name="city", data_type="varchar", settings={"not null"}), row_count=5
|
|
45
|
+
)
|
|
46
|
+
country_values = generate_column_values(
|
|
47
|
+
ColumnDef(name="country", data_type="varchar", settings={"not null"}), row_count=5
|
|
48
|
+
)
|
|
49
|
+
assert all(isinstance(v, str) and v for v in city_values)
|
|
50
|
+
assert all(isinstance(v, str) and v for v in country_values)
|
|
51
|
+
|
|
52
|
+
def test_unmatched_type_and_name_is_tracked_in_stats(self):
|
|
53
|
+
reset_stats()
|
|
54
|
+
col = ColumnDef(name="misc_notes_field", data_type="weird_custom_type")
|
|
55
|
+
generate_column_values(col, row_count=3)
|
|
56
|
+
unmapped = get_unmapped_columns()
|
|
57
|
+
assert ("misc_notes_field", "weird_custom_type") in unmapped
|
|
58
|
+
|
|
59
|
+
def test_matched_name_is_not_tracked_as_unmapped(self):
|
|
60
|
+
reset_stats()
|
|
61
|
+
col = ColumnDef(name="email", data_type="varchar", settings={"not null"})
|
|
62
|
+
generate_column_values(col, row_count=3)
|
|
63
|
+
assert get_unmapped_columns() == []
|
|
64
|
+
|
|
65
|
+
def test_ensure_unique_deduplicates_name_inferred_values(self):
|
|
66
|
+
col = ColumnDef(name="email", data_type="varchar", settings={"not null", "pk"})
|
|
67
|
+
values = generate_column_values(col, row_count=50, ensure_unique=True)
|
|
68
|
+
assert len(values) == len(set(values)) == 50
|
|
69
|
+
|
|
70
|
+
def test_deduplicate_retries_on_collision(self):
|
|
71
|
+
# Value space of 2 with 3 requested values forces at least one retry.
|
|
72
|
+
pool = iter(["a", "a", "b", "c", "a"])
|
|
73
|
+
result = _deduplicate(["a", "a", "b"], generator=lambda: next(pool))
|
|
74
|
+
assert len(result) == len(set(result)) == 3
|
|
75
|
+
|
|
76
|
+
def test_deduplicate_gives_up_after_max_attempts_on_tiny_value_space(self):
|
|
77
|
+
# Only one possible value: dedup can't succeed, but must terminate.
|
|
78
|
+
result = _deduplicate(["x", "x", "x"], generator=lambda: "x", max_attempts=3)
|
|
79
|
+
assert result == ["x", "x", "x"]
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import random
|
|
4
|
-
import uuid
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
from typing import Optional
|
|
7
|
-
|
|
8
|
-
import pandas as pd
|
|
9
|
-
from faker import Faker
|
|
10
|
-
|
|
11
|
-
from model2data.parse.dbml import ColumnDef
|
|
12
|
-
|
|
13
|
-
fake = Faker()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
# ---------------------------------------------------------
|
|
17
|
-
# Public API
|
|
18
|
-
# ---------------------------------------------------------
|
|
19
|
-
def generate_column_values(
|
|
20
|
-
column: ColumnDef,
|
|
21
|
-
row_count: int,
|
|
22
|
-
fk_series: Optional[pd.Series] = None,
|
|
23
|
-
ensure_unique: bool = False,
|
|
24
|
-
) -> list:
|
|
25
|
-
"""
|
|
26
|
-
Generate synthetic values for a single column.
|
|
27
|
-
Respects FKs, uniqueness, and optional min/max hints in column notes.
|
|
28
|
-
"""
|
|
29
|
-
if fk_series is not None and not fk_series.empty:
|
|
30
|
-
fk_values = fk_series.tolist()
|
|
31
|
-
return [random.choice(fk_values) for _ in range(row_count)]
|
|
32
|
-
|
|
33
|
-
dtype = column.data_type.lower()
|
|
34
|
-
base_type = dtype.split("(")[0].strip()
|
|
35
|
-
values: list = []
|
|
36
|
-
|
|
37
|
-
# Extract min/max from note if present
|
|
38
|
-
min_val = None
|
|
39
|
-
max_val = None
|
|
40
|
-
if column.note:
|
|
41
|
-
min_val = column.note.get("min")
|
|
42
|
-
max_val = column.note.get("max")
|
|
43
|
-
|
|
44
|
-
# -----------------------------------------------------
|
|
45
|
-
# UUIDs / hashes
|
|
46
|
-
# -----------------------------------------------------
|
|
47
|
-
if "uuid" in base_type or "hash" in base_type:
|
|
48
|
-
values = [str(uuid.uuid4()) for _ in range(row_count)]
|
|
49
|
-
|
|
50
|
-
# -----------------------------------------------------
|
|
51
|
-
# Integers
|
|
52
|
-
# -----------------------------------------------------
|
|
53
|
-
elif any(key in base_type for key in ["int", "integer", "bigint", "smallint"]):
|
|
54
|
-
# Use note values if present, otherwise defaults
|
|
55
|
-
if min_val is None:
|
|
56
|
-
min_val = 0
|
|
57
|
-
if max_val is None:
|
|
58
|
-
max_val = 100
|
|
59
|
-
values = [random.randint(min_val, max_val) for _ in range(row_count)]
|
|
60
|
-
|
|
61
|
-
# -----------------------------------------------------
|
|
62
|
-
# Floats / decimals
|
|
63
|
-
# -----------------------------------------------------
|
|
64
|
-
elif any(key in base_type for key in ["decimal", "numeric", "float", "double"]):
|
|
65
|
-
if min_val is None:
|
|
66
|
-
min_val = 0
|
|
67
|
-
if max_val is None:
|
|
68
|
-
max_val = 10_000
|
|
69
|
-
values = [round(random.uniform(min_val, max_val), 2) for _ in range(row_count)]
|
|
70
|
-
|
|
71
|
-
# -----------------------------------------------------
|
|
72
|
-
# Booleans
|
|
73
|
-
# -----------------------------------------------------
|
|
74
|
-
elif "boolean" in base_type or "bool" in base_type:
|
|
75
|
-
values = [random.choice([True, False]) for _ in range(row_count)]
|
|
76
|
-
|
|
77
|
-
# -----------------------------------------------------
|
|
78
|
-
# Dates
|
|
79
|
-
# -----------------------------------------------------
|
|
80
|
-
elif "date" in base_type and "time" not in base_type:
|
|
81
|
-
values = [fake.date_between(start_date="-2y", end_date="today") for _ in range(row_count)]
|
|
82
|
-
|
|
83
|
-
elif "time" in base_type and "stamp" not in base_type:
|
|
84
|
-
values = [fake.time() for _ in range(row_count)]
|
|
85
|
-
|
|
86
|
-
elif any(key in base_type for key in ["timestamp", "datetime"]):
|
|
87
|
-
values = [_random_datetime().isoformat(sep=" ") for _ in range(row_count)]
|
|
88
|
-
|
|
89
|
-
# -----------------------------------------------------
|
|
90
|
-
# Fallback to Faker providers
|
|
91
|
-
# -----------------------------------------------------
|
|
92
|
-
else:
|
|
93
|
-
try:
|
|
94
|
-
values = [fake.format(base_type) for _ in range(row_count)]
|
|
95
|
-
except (AttributeError, TypeError):
|
|
96
|
-
if column.name.lower().endswith("_id") or ensure_unique:
|
|
97
|
-
values = [str(uuid.uuid4()) for _ in range(row_count)]
|
|
98
|
-
else:
|
|
99
|
-
values = [fake.sentence(nb_words=3) for _ in range(row_count)]
|
|
100
|
-
|
|
101
|
-
# -----------------------------------------------------
|
|
102
|
-
# Nullability
|
|
103
|
-
# -----------------------------------------------------
|
|
104
|
-
if "not null" not in column.settings:
|
|
105
|
-
null_fraction = max(0, min(0.2, 1 - (row_count / (row_count + 50))))
|
|
106
|
-
sample_size = int(row_count * null_fraction)
|
|
107
|
-
if sample_size:
|
|
108
|
-
for idx in random.sample(range(row_count), k=sample_size):
|
|
109
|
-
values[idx] = None
|
|
110
|
-
|
|
111
|
-
return values
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
# ---------------------------------------------------------
|
|
115
|
-
# Internal helpers
|
|
116
|
-
# ---------------------------------------------------------
|
|
117
|
-
def _random_datetime(start_days: int = -365, end_days: int = 0) -> datetime:
|
|
118
|
-
start = datetime.now() + timedelta(days=start_days)
|
|
119
|
-
end = datetime.now() + timedelta(days=end_days)
|
|
120
|
-
delta = end - start
|
|
121
|
-
random_second = random.randint(0, int(delta.total_seconds()))
|
|
122
|
-
return start + timedelta(seconds=random_second)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|