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.
Files changed (36) hide show
  1. {model2data-0.1.1 → model2data-0.2.2}/PKG-INFO +9 -3
  2. {model2data-0.1.1 → model2data-0.2.2}/README.md +2 -2
  3. model2data-0.2.2/model2data/__init__.py +3 -0
  4. {model2data-0.1.1 → model2data-0.2.2}/model2data/cli.py +7 -18
  5. model2data-0.2.2/model2data/dbt/__init__.py +1 -0
  6. model2data-0.2.2/model2data/dbt/project.py +74 -0
  7. model2data-0.2.2/model2data/dbt/tests.py +92 -0
  8. model2data-0.2.2/model2data/generate/__init__.py +1 -0
  9. model2data-0.2.2/model2data/generate/core.py +166 -0
  10. model2data-0.2.2/model2data/generate/faker.py +122 -0
  11. model2data-0.2.2/model2data/generate/relationships.py +52 -0
  12. model2data-0.2.2/model2data/parse/__init__.py +1 -0
  13. model2data-0.2.2/model2data/parse/dbml.py +218 -0
  14. {model2data-0.1.1 → model2data-0.2.2}/model2data/utils.py +2 -1
  15. {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/PKG-INFO +9 -3
  16. model2data-0.2.2/model2data.egg-info/SOURCES.txt +28 -0
  17. {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/requires.txt +6 -0
  18. model2data-0.2.2/pyproject.toml +75 -0
  19. model2data-0.2.2/tests/test_cli.py +493 -0
  20. model2data-0.2.2/tests/test_coverage_gaps.py +233 -0
  21. model2data-0.2.2/tests/test_dbml_parser.py +929 -0
  22. model2data-0.2.2/tests/test_dbt_project.py +276 -0
  23. model2data-0.2.2/tests/test_dbt_tests.py +63 -0
  24. model2data-0.2.2/tests/test_generation.py +367 -0
  25. model2data-0.1.1/model2data.egg-info/SOURCES.txt +0 -16
  26. model2data-0.1.1/pyproject.toml +0 -32
  27. model2data-0.1.1/tests/test_cli_smoke.py +0 -41
  28. model2data-0.1.1/tests/test_dbml_parser.py +0 -29
  29. model2data-0.1.1/tests/test_dbt_tests.py +0 -31
  30. model2data-0.1.1/tests/test_generation.py +0 -47
  31. {model2data-0.1.1 → model2data-0.2.2}/LICENSE +0 -0
  32. {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/dependency_links.txt +0 -0
  33. {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/entry_points.txt +0 -0
  34. {model2data-0.1.1 → model2data-0.2.2}/model2data.egg-info/top_level.txt +0 -0
  35. {model2data-0.1.1 → model2data-0.2.2}/setup.cfg +0 -0
  36. {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.1.1
3
+ Version: 0.2.2
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
- Requires-Python: >=3.9
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
  [![codecov](https://codecov.io/gh/JB-Analytica/model2data/branch/main/graph/badge.svg)](https://codecov.io/gh/JB-Analytica/model2data)
24
30
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
  [![codecov](https://codecov.io/gh/JB-Analytica/model2data/branch/main/graph/badge.svg)](https://codecov.io/gh/JB-Analytica/model2data)
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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.
@@ -0,0 +1,3 @@
1
+ """Model2Data: Generate analytics-ready datasets from DBML models."""
2
+
3
+ __version__ = "0.1.1"
@@ -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."""