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.
Files changed (35) hide show
  1. {model2data-0.2.2 → model2data-0.3.0}/PKG-INFO +29 -13
  2. {model2data-0.2.2 → model2data-0.3.0}/README.md +26 -12
  3. {model2data-0.2.2 → model2data-0.3.0}/model2data/cli.py +39 -2
  4. {model2data-0.2.2 → model2data-0.3.0}/model2data/dbt/project.py +3 -3
  5. model2data-0.3.0/model2data/dbt/templates/dbt_project.yml.jinja +29 -0
  6. model2data-0.3.0/model2data/dbt/templates/macros/generate_schema_name.sql +9 -0
  7. model2data-0.3.0/model2data/dbt/templates/profiles.yml.jinja +18 -0
  8. model2data-0.3.0/model2data/generate/faker.py +221 -0
  9. {model2data-0.2.2 → model2data-0.3.0}/model2data/parse/dbml.py +9 -0
  10. {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/PKG-INFO +29 -13
  11. {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/SOURCES.txt +4 -0
  12. {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/requires.txt +3 -0
  13. {model2data-0.2.2 → model2data-0.3.0}/pyproject.toml +10 -1
  14. {model2data-0.2.2 → model2data-0.3.0}/tests/test_cli.py +83 -0
  15. {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbml_parser.py +24 -0
  16. {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbt_project.py +55 -0
  17. {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbt_tests.py +1 -0
  18. model2data-0.3.0/tests/test_faker_name_inference.py +79 -0
  19. model2data-0.2.2/model2data/generate/faker.py +0 -122
  20. {model2data-0.2.2 → model2data-0.3.0}/LICENSE +0 -0
  21. {model2data-0.2.2 → model2data-0.3.0}/model2data/__init__.py +0 -0
  22. {model2data-0.2.2 → model2data-0.3.0}/model2data/dbt/__init__.py +0 -0
  23. {model2data-0.2.2 → model2data-0.3.0}/model2data/dbt/tests.py +0 -0
  24. {model2data-0.2.2 → model2data-0.3.0}/model2data/generate/__init__.py +0 -0
  25. {model2data-0.2.2 → model2data-0.3.0}/model2data/generate/core.py +0 -0
  26. {model2data-0.2.2 → model2data-0.3.0}/model2data/generate/relationships.py +0 -0
  27. {model2data-0.2.2 → model2data-0.3.0}/model2data/parse/__init__.py +0 -0
  28. {model2data-0.2.2 → model2data-0.3.0}/model2data/utils.py +0 -0
  29. {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/dependency_links.txt +0 -0
  30. {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/entry_points.txt +0 -0
  31. {model2data-0.2.2 → model2data-0.3.0}/model2data.egg-info/top_level.txt +0 -0
  32. {model2data-0.2.2 → model2data-0.3.0}/setup.cfg +0 -0
  33. {model2data-0.2.2 → model2data-0.3.0}/tests/test_coverage_gaps.py +0 -0
  34. {model2data-0.2.2 → model2data-0.3.0}/tests/test_dbt_naming.py +0 -0
  35. {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.2.2
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 generate --file examples/hackernews.dbml --rows 200 --seed 42
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. Other adapters can be configured manually.
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
- - Currently optimized for DuckDB; other databases require manual profile adjustments.
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
- - Support for additional database adapters (e.g., Snowflake, BigQuery).
137
- - Enhanced data type handling and custom generators.
138
- - Integration with more dbt features like incremental models.
139
- - Web-based DBML editor and data preview.
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 generate --file examples/hackernews.dbml --rows 200 --seed 42
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. Other adapters can be configured manually.
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
- - Currently optimized for DuckDB; other databases require manual profile adjustments.
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
- - Support for additional database adapters (e.g., Snowflake, BigQuery).
113
- - Enhanced data type handling and custom generators.
114
- - Integration with more dbt features like incremental models.
115
- - Web-based DBML editor and data preview.
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 = Path("model2data/dbt/templates/macros")
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,9 @@
1
+ {% macro generate_schema_name(custom_schema_name, node) %}
2
+
3
+ {%- set default_schema = target.schema -%}
4
+ {%- if custom_schema_name is none -%}
5
+ {{ default_schema }}
6
+ {%- else -%}
7
+ {{ custom_schema_name | trim }}
8
+ {%- endif -%}
9
+ {%- endmacro %}
@@ -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.2.2
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 generate --file examples/hackernews.dbml --rows 200 --seed 42
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. Other adapters can be configured manually.
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
- - Currently optimized for DuckDB; other databases require manual profile adjustments.
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
- - Support for additional database adapters (e.g., Snowflake, BigQuery).
137
- - Enhanced data type handling and custom generators.
138
- - Integration with more dbt features like incremental models.
139
- - Web-based DBML editor and data preview.
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
@@ -14,3 +14,6 @@ black>=23.0.0
14
14
  ty>=0.0.4
15
15
  types-pyyaml
16
16
  poethepoet>=0.38.0
17
+
18
+ [postgres]
19
+ dbt-postgres>=1.5.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model2data"
7
- version = "0.2.2"
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
@@ -33,6 +33,7 @@ def test_dbt_tests_generation(tmp_path, monkeypatch):
33
33
  seed=42,
34
34
  name="test_project",
35
35
  force=True,
36
+ adapter="duckdb",
36
37
  )
37
38
 
38
39
  project_dir = tmp_path / "dbt_test_project"
@@ -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