model2data 0.1.1__tar.gz → 0.2.2__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.1.1 → model2data-0.2.2}/PKG-INFO +9 -3
- {model2data-0.1.1 → model2data-0.2.2}/README.md +2 -2
- model2data-0.2.2/model2data/__init__.py +3 -0
- {model2data-0.1.1 → model2data-0.2.2}/model2data/cli.py +7 -18
- model2data-0.2.2/model2data/dbt/__init__.py +1 -0
- model2data-0.2.2/model2data/dbt/project.py +74 -0
- model2data-0.2.2/model2data/dbt/tests.py +92 -0
- model2data-0.2.2/model2data/generate/__init__.py +1 -0
- model2data-0.2.2/model2data/generate/core.py +166 -0
- model2data-0.2.2/model2data/generate/faker.py +122 -0
- model2data-0.2.2/model2data/generate/relationships.py +52 -0
- model2data-0.2.2/model2data/parse/__init__.py +1 -0
- model2data-0.2.2/model2data/parse/dbml.py +218 -0
- {model2data-0.1.1 → model2data-0.2.2}/model2data/utils.py +2 -1
- {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/PKG-INFO +9 -3
- model2data-0.2.2/model2data.egg-info/SOURCES.txt +28 -0
- {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/requires.txt +6 -0
- model2data-0.2.2/pyproject.toml +75 -0
- model2data-0.2.2/tests/test_cli.py +493 -0
- model2data-0.2.2/tests/test_coverage_gaps.py +233 -0
- model2data-0.2.2/tests/test_dbml_parser.py +929 -0
- model2data-0.2.2/tests/test_dbt_project.py +276 -0
- model2data-0.2.2/tests/test_dbt_tests.py +63 -0
- model2data-0.2.2/tests/test_generation.py +367 -0
- model2data-0.1.1/model2data.egg-info/SOURCES.txt +0 -16
- model2data-0.1.1/pyproject.toml +0 -32
- model2data-0.1.1/tests/test_cli_smoke.py +0 -41
- model2data-0.1.1/tests/test_dbml_parser.py +0 -29
- model2data-0.1.1/tests/test_dbt_tests.py +0 -31
- model2data-0.1.1/tests/test_generation.py +0 -47
- {model2data-0.1.1 → model2data-0.2.2}/LICENSE +0 -0
- {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/dependency_links.txt +0 -0
- {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/entry_points.txt +0 -0
- {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/top_level.txt +0 -0
- {model2data-0.1.1 → model2data-0.2.2}/setup.cfg +0 -0
- {model2data-0.1.1 → model2data-0.2.2}/tests/test_dbt_naming.py +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: model2data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Generate analytics-ready datasets from DBML models
|
|
5
|
-
Requires-Python: >=3.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Dist: dbt-core>=1.5.0
|
|
@@ -14,6 +14,12 @@ Requires-Dist: typer>=0.20.0
|
|
|
14
14
|
Provides-Extra: dev
|
|
15
15
|
Requires-Dist: pytest; extra == "dev"
|
|
16
16
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
17
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
18
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
19
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
20
|
+
Requires-Dist: ty>=0.0.4; extra == "dev"
|
|
21
|
+
Requires-Dist: types-pyyaml; extra == "dev"
|
|
22
|
+
Requires-Dist: poethepoet>=0.38.0; extra == "dev"
|
|
17
23
|
Dynamic: license-file
|
|
18
24
|
|
|
19
25
|
# model2data
|
|
@@ -23,7 +29,7 @@ Dynamic: license-file
|
|
|
23
29
|
[](https://codecov.io/gh/JB-Analytica/model2data)
|
|
24
30
|
[](LICENSE)
|
|
25
31
|
|
|
26
|
-
`model2data` turns **data models into analytics-ready datasets** in seconds.
|
|
32
|
+
`model2data` turns **data models into analytics-ready datasets** in seconds.
|
|
27
33
|
|
|
28
34
|
Given a **DBML file**, it generates synthetic but realistic data, a complete dbt project scaffold, and everything you need to start analyzing or testing data pipelines.
|
|
29
35
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://codecov.io/gh/JB-Analytica/model2data)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
`model2data` turns **data models into analytics-ready datasets** in seconds.
|
|
8
|
+
`model2data` turns **data models into analytics-ready datasets** in seconds.
|
|
9
9
|
|
|
10
10
|
Given a **DBML file**, it generates synthetic but realistic data, a complete dbt project scaffold, and everything you need to start analyzing or testing data pipelines.
|
|
11
11
|
|
|
@@ -134,4 +134,4 @@ Please read our [Code of Conduct](CODE_OF_CONDUCT.md) to understand our communit
|
|
|
134
134
|
|
|
135
135
|
## License
|
|
136
136
|
|
|
137
|
-
MIT License. See LICENSE for details.
|
|
137
|
+
MIT License. See LICENSE for details.
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
from typing import Optional
|
|
3
1
|
import random
|
|
4
2
|
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
from faker import Faker
|
|
8
8
|
|
|
9
|
-
from model2data.parse.dbml import parse_dbml
|
|
10
|
-
from model2data.generate.core import generate_data_from_dbml
|
|
11
9
|
from model2data.dbt.project import (
|
|
12
|
-
create_project_scaffold,
|
|
13
10
|
create_profiles_yml,
|
|
11
|
+
create_project_scaffold,
|
|
14
12
|
create_staging_models,
|
|
15
13
|
)
|
|
16
14
|
from model2data.dbt.tests import generate_dbt_yml
|
|
15
|
+
from model2data.generate.core import generate_data_from_dbml
|
|
16
|
+
from model2data.parse.dbml import parse_dbml
|
|
17
17
|
from model2data.utils import normalize_identifier
|
|
18
18
|
|
|
19
19
|
app = typer.Typer(
|
|
@@ -30,7 +30,7 @@ app = typer.Typer(
|
|
|
30
30
|
|
|
31
31
|
@app.command(help="Generate synthetic data and a dbt project from a DBML model.")
|
|
32
32
|
def main(
|
|
33
|
-
file: Path = typer.Option(
|
|
33
|
+
file: Path = typer.Option( # noqa: B008
|
|
34
34
|
...,
|
|
35
35
|
"--file",
|
|
36
36
|
"-f",
|
|
@@ -88,24 +88,13 @@ def main(
|
|
|
88
88
|
typer.echo("❌ No tables found in the provided DBML file.")
|
|
89
89
|
raise typer.Exit(1)
|
|
90
90
|
|
|
91
|
-
# -------------------------
|
|
92
|
-
# DBML → dbt name mapping
|
|
93
|
-
# -------------------------
|
|
94
|
-
dbt_name_map = {
|
|
95
|
-
table_name: normalize_identifier(table_name)
|
|
96
|
-
for table_name in tables.keys()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
91
|
project_name = normalize_identifier(name or file.stem)
|
|
100
92
|
dest = Path.cwd() / f"dbt_{project_name}"
|
|
101
93
|
profile_name = f"{project_name}_profile"
|
|
102
94
|
|
|
103
95
|
if dest.exists():
|
|
104
96
|
if not force:
|
|
105
|
-
typer.echo(
|
|
106
|
-
f"❌ Destination {dest} already exists.\n"
|
|
107
|
-
"Use --force to overwrite."
|
|
108
|
-
)
|
|
97
|
+
typer.echo(f"❌ Destination {dest} already exists.\nUse --force to overwrite.")
|
|
109
98
|
raise typer.Exit(1)
|
|
110
99
|
shutil.rmtree(dest)
|
|
111
100
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""dbt project generation utilities."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import jinja2
|
|
4
|
+
|
|
5
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_project_scaffold(dest: Path, project_name: str, profile_name: str) -> None:
|
|
9
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
# dbt folders
|
|
12
|
+
(dest / "models" / "staging").mkdir(parents=True, exist_ok=True)
|
|
13
|
+
(dest / "seeds" / "raw").mkdir(parents=True, exist_ok=True)
|
|
14
|
+
(dest / "analysis").mkdir(exist_ok=True)
|
|
15
|
+
(dest / "macros").mkdir(exist_ok=True)
|
|
16
|
+
(dest / "tests").mkdir(exist_ok=True)
|
|
17
|
+
(dest / "snapshots").mkdir(exist_ok=True)
|
|
18
|
+
|
|
19
|
+
# dbt_project.yml
|
|
20
|
+
_render_template(
|
|
21
|
+
template_name="dbt_project.yml.jinja",
|
|
22
|
+
output_path=dest / "dbt_project.yml",
|
|
23
|
+
context={"project_name": project_name, "profile_name": profile_name},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Copy over any macros from templates
|
|
27
|
+
template_macros_dir = Path("model2data/dbt/templates/macros")
|
|
28
|
+
if template_macros_dir.exists():
|
|
29
|
+
for macro_file in template_macros_dir.glob("*.sql"):
|
|
30
|
+
target_file = dest / "macros" / macro_file.name
|
|
31
|
+
if not target_file.exists():
|
|
32
|
+
target_file.write_text(macro_file.read_text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_staging_models(dest: Path, project_name: str) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Creates staging models in models/staging/ folder that reference raw seed tables as sources.
|
|
38
|
+
"""
|
|
39
|
+
seeds_path = dest / "seeds" / "raw"
|
|
40
|
+
models_path = dest / "models" / "staging"
|
|
41
|
+
models_path.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
for csv_file in seeds_path.glob("*.csv"):
|
|
44
|
+
table_name = csv_file.stem # keep full seed name, e.g., raw_stories
|
|
45
|
+
model_file = models_path / f"stg_{table_name}.sql"
|
|
46
|
+
|
|
47
|
+
if not model_file.exists():
|
|
48
|
+
sql_content = f"""\
|
|
49
|
+
-- Auto-generated staging model for {table_name}
|
|
50
|
+
select *
|
|
51
|
+
from {{{{ source('raw', '{table_name}') }}}}
|
|
52
|
+
"""
|
|
53
|
+
model_file.write_text(sql_content)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_profiles_yml(dest: Path, profile_name: str) -> None:
|
|
57
|
+
profiles_file = dest / "profiles.yml"
|
|
58
|
+
if profiles_file.exists():
|
|
59
|
+
content = profiles_file.read_text()
|
|
60
|
+
if profile_name in content:
|
|
61
|
+
return
|
|
62
|
+
_render_template(
|
|
63
|
+
template_name="profiles.yml.jinja",
|
|
64
|
+
output_path=profiles_file,
|
|
65
|
+
context={"profile_name": profile_name},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render_template(template_name: str, output_path: Path, context: dict) -> None:
|
|
70
|
+
template_path = TEMPLATES_DIR / template_name
|
|
71
|
+
if not template_path.exists():
|
|
72
|
+
raise FileNotFoundError(f"Template not found: {template_path}")
|
|
73
|
+
template = jinja2.Template(template_path.read_text())
|
|
74
|
+
output_path.write_text(template.render(**context))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: str = "hackernews"):
|
|
7
|
+
"""
|
|
8
|
+
Generate:
|
|
9
|
+
1) __sources.yml with all raw_* seeds (no tests)
|
|
10
|
+
2) One .yml per staging model (stg_*) with tests
|
|
11
|
+
Table and column names are used exactly as in DBML.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
staging_path = dest / "models" / "staging"
|
|
15
|
+
staging_path.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
# -------------------------
|
|
18
|
+
# Build foreign key map
|
|
19
|
+
# -------------------------
|
|
20
|
+
fk_map = defaultdict(list)
|
|
21
|
+
for ref in refs:
|
|
22
|
+
fk_map[(ref["source_table"], ref["source_column"])].append(ref)
|
|
23
|
+
|
|
24
|
+
# -------------------------
|
|
25
|
+
# Generate __sources.yml
|
|
26
|
+
# -------------------------
|
|
27
|
+
sources_lines = ["version: 2", "", "sources:"]
|
|
28
|
+
sources_lines.append(" - name: raw")
|
|
29
|
+
sources_lines.append(" schema: raw")
|
|
30
|
+
sources_lines.append(f" description: {source_name.capitalize()} raw seed data")
|
|
31
|
+
sources_lines.append(" tables:")
|
|
32
|
+
|
|
33
|
+
for table in tables.values():
|
|
34
|
+
seed_name = table.name # keep exact name
|
|
35
|
+
table_desc = getattr(table, "description", None) or f"Table {seed_name}"
|
|
36
|
+
sources_lines.append(f" - name: {seed_name}")
|
|
37
|
+
sources_lines.append(f" description: {table_desc}")
|
|
38
|
+
|
|
39
|
+
sources_file = staging_path / "__sources.yml"
|
|
40
|
+
sources_file.write_text("\n".join(sources_lines))
|
|
41
|
+
|
|
42
|
+
# -------------------------
|
|
43
|
+
# Generate individual staging model YAMLs
|
|
44
|
+
# -------------------------
|
|
45
|
+
for table in tables.values():
|
|
46
|
+
stg_name = f"stg_{table.name}" # staging model names are prefixed, columns unchanged
|
|
47
|
+
model_columns = []
|
|
48
|
+
|
|
49
|
+
for col in table.columns:
|
|
50
|
+
tests: list[Union[str, dict[str, dict[str, Any]]]] = []
|
|
51
|
+
settings = col.settings or set()
|
|
52
|
+
|
|
53
|
+
if "not null" in settings or "pk" in settings:
|
|
54
|
+
tests.append("not_null")
|
|
55
|
+
if "unique" in settings or "pk" in settings:
|
|
56
|
+
tests.append("unique")
|
|
57
|
+
|
|
58
|
+
fk_refs = fk_map.get((table.name, col.name), [])
|
|
59
|
+
for fk in fk_refs:
|
|
60
|
+
tests.append(
|
|
61
|
+
{
|
|
62
|
+
"relationships": {
|
|
63
|
+
"to": f"ref('stg_{fk['target_table']}')",
|
|
64
|
+
"field": fk["target_column"],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
model_columns.append({"name": col.name, "tests": tests if tests else None})
|
|
70
|
+
|
|
71
|
+
# Render model YAML
|
|
72
|
+
lines = ["version: 2", "", "models:"]
|
|
73
|
+
lines.append(f" - name: {stg_name}")
|
|
74
|
+
lines.append(" columns:")
|
|
75
|
+
for col in model_columns:
|
|
76
|
+
lines.append(f" - name: {col['name']}")
|
|
77
|
+
if col["tests"]:
|
|
78
|
+
lines.append(" tests:")
|
|
79
|
+
for test in col["tests"]:
|
|
80
|
+
if isinstance(test, str):
|
|
81
|
+
lines.append(f" - {test}")
|
|
82
|
+
else:
|
|
83
|
+
# relationships test with arguments
|
|
84
|
+
for k, v in test.items():
|
|
85
|
+
lines.append(f" - {k}:")
|
|
86
|
+
lines.append(" arguments:")
|
|
87
|
+
for fk_key, fk_val in v.items():
|
|
88
|
+
lines.append(f" {fk_key}: {fk_val}")
|
|
89
|
+
|
|
90
|
+
# Write YAML to same folder as SQL model
|
|
91
|
+
yml_file = staging_path / f"{stg_name}.yml"
|
|
92
|
+
yml_file.write_text("\n".join(lines))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data generation utilities."""
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from faker import Faker
|
|
9
|
+
|
|
10
|
+
from model2data.generate.faker import generate_column_values
|
|
11
|
+
from model2data.generate.relationships import (
|
|
12
|
+
build_fk_lookup,
|
|
13
|
+
classify_refs,
|
|
14
|
+
)
|
|
15
|
+
from model2data.parse.dbml import TableDef
|
|
16
|
+
|
|
17
|
+
fake = Faker()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------
|
|
21
|
+
# Public API
|
|
22
|
+
# ---------------------------------------------------------
|
|
23
|
+
def generate_data_from_dbml(
|
|
24
|
+
tables: dict[str, TableDef],
|
|
25
|
+
refs: list[dict],
|
|
26
|
+
base_rows: int = 100,
|
|
27
|
+
seed: Optional[int] = None,
|
|
28
|
+
) -> dict[str, pd.DataFrame]:
|
|
29
|
+
"""
|
|
30
|
+
Generate synthetic datasets from parsed DBML definitions.
|
|
31
|
+
|
|
32
|
+
This function is deterministic if a seed is provided.
|
|
33
|
+
It performs no filesystem I/O and returns pandas DataFrames.
|
|
34
|
+
"""
|
|
35
|
+
if seed is not None:
|
|
36
|
+
random.seed(seed)
|
|
37
|
+
Faker.seed(seed)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------
|
|
40
|
+
# Classify references
|
|
41
|
+
# ---------------------------------------------------------
|
|
42
|
+
fk_refs, attribute_refs = classify_refs(tables, refs)
|
|
43
|
+
fk_lookup = build_fk_lookup(fk_refs)
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------
|
|
46
|
+
# Generate tables in dependency order
|
|
47
|
+
# ---------------------------------------------------------
|
|
48
|
+
ordered_tables = _topological_table_order(tables, fk_refs)
|
|
49
|
+
generated: dict[str, pd.DataFrame] = {}
|
|
50
|
+
|
|
51
|
+
for table_name in ordered_tables:
|
|
52
|
+
table_def = tables[table_name]
|
|
53
|
+
row_count = _determine_row_count(table_def.name, base_rows)
|
|
54
|
+
|
|
55
|
+
data: dict[str, list] = {}
|
|
56
|
+
|
|
57
|
+
# -----------------------
|
|
58
|
+
# First pass: columns + FKs
|
|
59
|
+
# -----------------------
|
|
60
|
+
for column in table_def.columns:
|
|
61
|
+
fk_series = None
|
|
62
|
+
fk_target = fk_lookup.get((table_name, column.name))
|
|
63
|
+
|
|
64
|
+
if fk_target:
|
|
65
|
+
parent_table, parent_column = fk_target
|
|
66
|
+
parent_df = generated.get(parent_table)
|
|
67
|
+
if parent_df is not None and parent_column in parent_df.columns:
|
|
68
|
+
fk_series = parent_df[parent_column]
|
|
69
|
+
|
|
70
|
+
ensure_unique = "pk" in column.settings
|
|
71
|
+
data[column.name] = generate_column_values(
|
|
72
|
+
column=column,
|
|
73
|
+
row_count=row_count,
|
|
74
|
+
fk_series=fk_series,
|
|
75
|
+
ensure_unique=ensure_unique,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
df = pd.DataFrame(data)
|
|
79
|
+
|
|
80
|
+
# -----------------------------------------------------
|
|
81
|
+
# Second pass: attribute mirroring (non-FK refs)
|
|
82
|
+
# -----------------------------------------------------
|
|
83
|
+
for ref in attribute_refs:
|
|
84
|
+
if ref["source_table"] != table_name:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
parent_table = ref["target_table"]
|
|
88
|
+
parent_column = ref["target_column"]
|
|
89
|
+
child_column = ref["source_column"]
|
|
90
|
+
|
|
91
|
+
parent_df = generated.get(parent_table)
|
|
92
|
+
if parent_df is None:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# find FK linking child → parent
|
|
96
|
+
fk_column = next(
|
|
97
|
+
(
|
|
98
|
+
r["source_column"]
|
|
99
|
+
for r in fk_refs
|
|
100
|
+
if r["source_table"] == table_name and r["target_table"] == parent_table
|
|
101
|
+
),
|
|
102
|
+
None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not fk_column or fk_column not in df.columns:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
lookup = parent_df.groupby("id")[parent_column].first().to_dict()
|
|
109
|
+
|
|
110
|
+
df[child_column] = df[fk_column].map(lookup)
|
|
111
|
+
|
|
112
|
+
generated[table_name] = df
|
|
113
|
+
|
|
114
|
+
return generated
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------
|
|
118
|
+
# Internal helpers
|
|
119
|
+
# ---------------------------------------------------------
|
|
120
|
+
def _determine_row_count(table_name: str, base_rows: int) -> int:
|
|
121
|
+
"""
|
|
122
|
+
Return the base number of rows for all tables.
|
|
123
|
+
"""
|
|
124
|
+
return base_rows
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _topological_table_order(
|
|
128
|
+
tables: dict[str, TableDef],
|
|
129
|
+
fk_refs: list[dict],
|
|
130
|
+
) -> list[str]:
|
|
131
|
+
"""
|
|
132
|
+
Order tables so parent tables are generated before children.
|
|
133
|
+
"""
|
|
134
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
135
|
+
indegree: dict[str, int] = dict.fromkeys(tables.keys(), 0)
|
|
136
|
+
|
|
137
|
+
for ref in fk_refs:
|
|
138
|
+
parent = ref["target_table"]
|
|
139
|
+
child = ref["source_table"]
|
|
140
|
+
|
|
141
|
+
if parent == child:
|
|
142
|
+
continue
|
|
143
|
+
if parent not in tables or child not in tables:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if child not in graph[parent]:
|
|
147
|
+
graph[parent].add(child)
|
|
148
|
+
indegree[child] += 1
|
|
149
|
+
|
|
150
|
+
queue = deque(sorted(name for name, deg in indegree.items() if deg == 0))
|
|
151
|
+
order: list[str] = []
|
|
152
|
+
|
|
153
|
+
while queue:
|
|
154
|
+
node = queue.popleft()
|
|
155
|
+
order.append(node)
|
|
156
|
+
for neighbor in sorted(graph.get(node, [])):
|
|
157
|
+
indegree[neighbor] -= 1
|
|
158
|
+
if indegree[neighbor] == 0:
|
|
159
|
+
queue.append(neighbor)
|
|
160
|
+
|
|
161
|
+
# Safety net for disconnected tables
|
|
162
|
+
for name in tables.keys():
|
|
163
|
+
if name not in order:
|
|
164
|
+
order.append(name)
|
|
165
|
+
|
|
166
|
+
return order
|
|
@@ -0,0 +1,122 @@
|
|
|
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)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import Dict, List, Tuple
|
|
2
|
+
|
|
3
|
+
from model2data.parse.dbml import TableDef
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# ---------------------------------------------------------
|
|
7
|
+
# Public API
|
|
8
|
+
# ---------------------------------------------------------
|
|
9
|
+
def classify_refs(
|
|
10
|
+
tables: Dict[str, TableDef],
|
|
11
|
+
refs: List[Dict],
|
|
12
|
+
) -> Tuple[List[Dict], List[Dict]]:
|
|
13
|
+
"""
|
|
14
|
+
Classify references into:
|
|
15
|
+
- fk_refs: Foreign keys (target column looks like a PK)
|
|
16
|
+
- attribute_refs: Non-FK dependencies (mirroring parent attributes)
|
|
17
|
+
"""
|
|
18
|
+
fk_refs = []
|
|
19
|
+
attribute_refs = []
|
|
20
|
+
|
|
21
|
+
for ref in refs:
|
|
22
|
+
target_table = tables.get(ref["target_table"])
|
|
23
|
+
target_col = None
|
|
24
|
+
if target_table:
|
|
25
|
+
target_col = next(
|
|
26
|
+
(c for c in target_table.columns if c.name == ref["target_column"]), None
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# FK if target column is a primary key or named "id"
|
|
30
|
+
if target_col and ("pk" in target_col.settings or target_col.name.lower() == "id"):
|
|
31
|
+
fk_refs.append(ref)
|
|
32
|
+
else:
|
|
33
|
+
attribute_refs.append(ref)
|
|
34
|
+
|
|
35
|
+
return fk_refs, attribute_refs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_fk_lookup(fk_refs: List[Dict]) -> Dict[Tuple[str, str], Tuple[str, str]]:
|
|
39
|
+
"""
|
|
40
|
+
Build a lookup dictionary for FK relationships.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
{
|
|
44
|
+
(child_table, child_column): (parent_table, parent_column)
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
lookup: Dict[Tuple[str, str], Tuple[str, str]] = {}
|
|
48
|
+
for ref in fk_refs:
|
|
49
|
+
key = (ref["source_table"], ref["source_column"])
|
|
50
|
+
value = (ref["target_table"], ref["target_column"])
|
|
51
|
+
lookup[key] = value
|
|
52
|
+
return lookup
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DBML parsing utilities."""
|