model2data 0.1.0__py3-none-any.whl

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/cli.py ADDED
@@ -0,0 +1,160 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+ import random
4
+ import shutil
5
+
6
+ import typer
7
+ from faker import Faker
8
+
9
+ from model2data.parse.dbml import parse_dbml
10
+ from model2data.generate.core import generate_data_from_dbml
11
+ from model2data.dbt.project import (
12
+ create_project_scaffold,
13
+ create_profiles_yml,
14
+ create_staging_models,
15
+ )
16
+ from model2data.dbt.tests import generate_dbt_yml
17
+ from model2data.utils import normalize_identifier
18
+
19
+ app = typer.Typer(
20
+ help=(
21
+ "model2data: Generate analytics-ready datasets from DBML models.\n\n"
22
+ "Given a DBML file, this tool produces:\n"
23
+ "• Synthetic but realistic data\n"
24
+ "• A runnable dbt project scaffold\n"
25
+ "• dbt seeds, staging models, and profiles\n"
26
+ ),
27
+ add_completion=False,
28
+ )
29
+
30
+
31
+ @app.command(help="Generate synthetic data and a dbt project from a DBML model.")
32
+ def main(
33
+ file: Path = typer.Option(
34
+ ...,
35
+ "--file",
36
+ "-f",
37
+ exists=True,
38
+ file_okay=True,
39
+ dir_okay=False,
40
+ readable=True,
41
+ resolve_path=True,
42
+ help="Path to the DBML file to generate data from.",
43
+ ),
44
+ rows: int = typer.Option(
45
+ 100,
46
+ "--rows",
47
+ "-r",
48
+ min=10,
49
+ help="Number of rows to generate per table.",
50
+ ),
51
+ seed: Optional[int] = typer.Option(
52
+ None,
53
+ "--seed",
54
+ help=(
55
+ "Optional random seed for deterministic generation.\n"
56
+ "Using the same seed will always produce identical datasets."
57
+ ),
58
+ ),
59
+ name: Optional[str] = typer.Option(
60
+ None,
61
+ "--name",
62
+ "-n",
63
+ help="Optional override for the generated dbt project's name.",
64
+ ),
65
+ force: bool = typer.Option(
66
+ False,
67
+ "--force",
68
+ help="Overwrite the destination directory if it already exists.",
69
+ ),
70
+ ):
71
+ """
72
+ Generate synthetic data and a dbt project from a DBML model.
73
+ """
74
+
75
+ # -------------------------
76
+ # Deterministic seed
77
+ # -------------------------
78
+ if seed is not None:
79
+ random.seed(seed)
80
+ Faker.seed(seed)
81
+ typer.echo(f"🔁 Using deterministic seed: {seed}")
82
+
83
+ # -------------------------
84
+ # Parse DBML (names untouched)
85
+ # -------------------------
86
+ tables, refs = parse_dbml(file)
87
+ if not tables:
88
+ typer.echo("❌ No tables found in the provided DBML file.")
89
+ raise typer.Exit(1)
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
+ project_name = normalize_identifier(name or file.stem)
100
+ dest = Path.cwd() / f"dbt_{project_name}"
101
+ profile_name = f"{project_name}_profile"
102
+
103
+ if dest.exists():
104
+ if not force:
105
+ typer.echo(
106
+ f"❌ Destination {dest} already exists.\n"
107
+ "Use --force to overwrite."
108
+ )
109
+ raise typer.Exit(1)
110
+ shutil.rmtree(dest)
111
+
112
+ # -------------------------
113
+ # dbt project scaffold
114
+ # -------------------------
115
+ typer.echo(f"📦 Creating dbt project scaffold at {dest}")
116
+ create_project_scaffold(dest, project_name, profile_name)
117
+
118
+ # -------------------------
119
+ # Generate synthetic data
120
+ # -------------------------
121
+ typer.echo("🧮 Generating synthetic datasets from DBML definitions...")
122
+ generated_tables = generate_data_from_dbml(
123
+ tables=tables,
124
+ refs=refs,
125
+ base_rows=rows,
126
+ seed=seed,
127
+ )
128
+
129
+ # -------------------------
130
+ # Write dbt seeds (normalized names)
131
+ # -------------------------
132
+ seeds_path = dest / "seeds/raw"
133
+ for table_key, df in generated_tables.items():
134
+ csv_path = seeds_path / f"{table_key}.csv"
135
+ df.to_csv(csv_path, index=False)
136
+
137
+ # -------------------------
138
+ # Build dbt assets
139
+ # -------------------------
140
+ typer.echo("🗂️ Building staging models for generated seeds...")
141
+ create_staging_models(dest, project_name)
142
+
143
+ typer.echo("🧪 Generating dbt yml with tests...")
144
+ generate_dbt_yml(dest, tables, refs, project_name)
145
+
146
+ typer.echo("🪪 Ensuring dbt profile exists...")
147
+ create_profiles_yml(dest, profile_name)
148
+
149
+ # Keep original DBML for reference
150
+ shutil.copy(file, dest / file.name)
151
+
152
+ # -------------------------
153
+ # Done
154
+ # -------------------------
155
+ typer.echo("\n🎉 model2data generation complete!\n")
156
+ typer.echo("Next steps:")
157
+ typer.echo(f" cd {dest}")
158
+ typer.echo(" dbt deps")
159
+ typer.echo(" dbt seed")
160
+ typer.echo(" dbt run")
@@ -0,0 +1,74 @@
1
+ from pathlib import Path
2
+ import shutil
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,171 @@
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
+ classify_refs,
13
+ build_fk_lookup,
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
101
+ and r["target_table"] == parent_table
102
+ ),
103
+ None,
104
+ )
105
+
106
+ if not fk_column or fk_column not in df.columns:
107
+ continue
108
+
109
+ lookup = (
110
+ parent_df.groupby("id")[parent_column]
111
+ .first()
112
+ .to_dict()
113
+ )
114
+
115
+ df[child_column] = df[fk_column].map(lookup)
116
+
117
+ generated[table_name] = df
118
+
119
+ return generated
120
+
121
+
122
+ # ---------------------------------------------------------
123
+ # Internal helpers
124
+ # ---------------------------------------------------------
125
+ def _determine_row_count(table_name: str, base_rows: int) -> int:
126
+ """
127
+ Return the base number of rows for all tables.
128
+ """
129
+ return base_rows
130
+
131
+
132
+ def _topological_table_order(
133
+ tables: dict[str, TableDef],
134
+ fk_refs: list[dict],
135
+ ) -> list[str]:
136
+ """
137
+ Order tables so parent tables are generated before children.
138
+ """
139
+ graph: dict[str, set[str]] = defaultdict(set)
140
+ indegree: dict[str, int] = {name: 0 for name in tables.keys()}
141
+
142
+ for ref in fk_refs:
143
+ parent = ref["target_table"]
144
+ child = ref["source_table"]
145
+
146
+ if parent == child:
147
+ continue
148
+ if parent not in tables or child not in tables:
149
+ continue
150
+
151
+ if child not in graph[parent]:
152
+ graph[parent].add(child)
153
+ indegree[child] += 1
154
+
155
+ queue = deque(sorted(name for name, deg in indegree.items() if deg == 0))
156
+ order: list[str] = []
157
+
158
+ while queue:
159
+ node = queue.popleft()
160
+ order.append(node)
161
+ for neighbor in sorted(graph.get(node, [])):
162
+ indegree[neighbor] -= 1
163
+ if indegree[neighbor] == 0:
164
+ queue.append(neighbor)
165
+
166
+ # Safety net for disconnected tables
167
+ for name in tables.keys():
168
+ if name not in order:
169
+ order.append(name)
170
+
171
+ return order
@@ -0,0 +1,113 @@
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
+ # -----------------------------------------------------
38
+ # UUIDs / hashes
39
+ # -----------------------------------------------------
40
+ if "uuid" in base_type or "hash" in base_type:
41
+ values = [str(uuid.uuid4()) for _ in range(row_count)]
42
+
43
+ # -----------------------------------------------------
44
+ # Integers
45
+ # -----------------------------------------------------
46
+ elif any(key in base_type for key in ["int", "integer", "bigint", "smallint"]):
47
+ min_val = 0
48
+ max_val = 100
49
+ if column.note:
50
+ if "min" in column.note:
51
+ min_val = column.note["min"]
52
+ if "max" in column.note:
53
+ max_val = column.note["max"]
54
+ values = [random.randint(min_val, max_val) for _ in range(row_count)]
55
+
56
+ # -----------------------------------------------------
57
+ # Floats / decimals
58
+ # -----------------------------------------------------
59
+ elif any(key in base_type for key in ["decimal", "numeric", "float", "double"]):
60
+ values = [round(random.uniform(0, 10_000), 2) for _ in range(row_count)]
61
+
62
+ # -----------------------------------------------------
63
+ # Booleans
64
+ # -----------------------------------------------------
65
+ elif "boolean" in base_type or "bool" in base_type:
66
+ values = [random.choice([True, False]) for _ in range(row_count)]
67
+
68
+ # -----------------------------------------------------
69
+ # Dates
70
+ # -----------------------------------------------------
71
+ elif "date" in base_type and "time" not in base_type:
72
+ values = [fake.date_between(start_date="-2y", end_date="today") for _ in range(row_count)]
73
+
74
+ elif "time" in base_type and "stamp" not in base_type:
75
+ values = [fake.time() for _ in range(row_count)]
76
+
77
+ elif any(key in base_type for key in ["timestamp", "datetime"]):
78
+ values = [_random_datetime().isoformat(sep=" ") for _ in range(row_count)]
79
+
80
+ # -----------------------------------------------------
81
+ # Fallback to Faker providers
82
+ # -----------------------------------------------------
83
+ else:
84
+ try:
85
+ values = [fake.format(base_type) for _ in range(row_count)]
86
+ except:
87
+ if column.name.lower().endswith("_id") or ensure_unique:
88
+ values = [str(uuid.uuid4()) for _ in range(row_count)]
89
+ else:
90
+ values = [fake.sentence(nb_words=3) for _ in range(row_count)]
91
+
92
+ # -----------------------------------------------------
93
+ # Nullability
94
+ # -----------------------------------------------------
95
+ if "not null" not in column.settings:
96
+ null_fraction = max(0, min(0.2, 1 - (row_count / (row_count + 50))))
97
+ sample_size = int(row_count * null_fraction)
98
+ if sample_size:
99
+ for idx in random.sample(range(row_count), k=sample_size):
100
+ values[idx] = None
101
+
102
+ return values
103
+
104
+
105
+ # ---------------------------------------------------------
106
+ # Internal helpers
107
+ # ---------------------------------------------------------
108
+ def _random_datetime(start_days: int = -365, end_days: int = 0) -> datetime:
109
+ start = datetime.now() + timedelta(days=start_days)
110
+ end = datetime.now() + timedelta(days=end_days)
111
+ delta = end - start
112
+ random_second = random.randint(0, int(delta.total_seconds()))
113
+ return start + timedelta(seconds=random_second)
@@ -0,0 +1,49 @@
1
+ from typing import Tuple, List, Dict
2
+ from model2data.parse.dbml import TableDef
3
+
4
+
5
+ # ---------------------------------------------------------
6
+ # Public API
7
+ # ---------------------------------------------------------
8
+ def classify_refs(
9
+ tables: Dict[str, TableDef],
10
+ refs: List[Dict],
11
+ ) -> Tuple[List[Dict], List[Dict]]:
12
+ """
13
+ Classify references into:
14
+ - fk_refs: Foreign keys (target column looks like a PK)
15
+ - attribute_refs: Non-FK dependencies (mirroring parent attributes)
16
+ """
17
+ fk_refs = []
18
+ attribute_refs = []
19
+
20
+ for ref in refs:
21
+ target_table = tables.get(ref["target_table"])
22
+ target_col = None
23
+ if target_table:
24
+ target_col = next((c for c in target_table.columns if c.name == ref["target_column"]), None)
25
+
26
+ # FK if target column is a primary key or named "id"
27
+ if target_col and ("pk" in target_col.settings or target_col.name.lower() == "id"):
28
+ fk_refs.append(ref)
29
+ else:
30
+ attribute_refs.append(ref)
31
+
32
+ return fk_refs, attribute_refs
33
+
34
+
35
+ def build_fk_lookup(fk_refs: List[Dict]) -> Dict[Tuple[str, str], Tuple[str, str]]:
36
+ """
37
+ Build a lookup dictionary for FK relationships.
38
+
39
+ Returns:
40
+ {
41
+ (child_table, child_column): (parent_table, parent_column)
42
+ }
43
+ """
44
+ lookup: Dict[Tuple[str, str], Tuple[str, str]] = {}
45
+ for ref in fk_refs:
46
+ key = (ref["source_table"], ref["source_column"])
47
+ value = (ref["target_table"], ref["target_column"])
48
+ lookup[key] = value
49
+ return lookup
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from dataclasses import dataclass, field
5
+ import re
6
+ import json
7
+ import uuid
8
+
9
+ # -------------------------------
10
+ # Dataclasses
11
+ # -------------------------------
12
+
13
+ @dataclass
14
+ class ColumnDef:
15
+ name: str
16
+ data_type: str
17
+ settings: set[str] = field(default_factory=set)
18
+ note: Optional[dict] = None
19
+
20
+ @dataclass
21
+ class TableDef:
22
+ name: str
23
+ columns: list[ColumnDef] = field(default_factory=list)
24
+
25
+ # -------------------------------
26
+ # Helpers
27
+ # -------------------------------
28
+
29
+ def _strip_quotes(value: str) -> str:
30
+ return value.strip().strip('"').strip("'")
31
+
32
+ def _parse_column_settings(raw: Optional[str]) -> set[str]:
33
+ if not raw:
34
+ return set()
35
+ parts = [part.strip() for part in raw.split(",")]
36
+ return {part.strip("'").strip('"').lower() for part in parts if part}
37
+
38
+ def normalize_identifier(value: str) -> str:
39
+ cleaned = re.sub(r"[^0-9A-Za-z]+", "_", value).strip("_").lower()
40
+ if not cleaned:
41
+ cleaned = "table"
42
+ if cleaned[0].isdigit():
43
+ cleaned = f"t_{cleaned}"
44
+ return cleaned
45
+
46
+ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
47
+ text = dbml_path.read_text(encoding="utf-8")
48
+ lines = text.splitlines()
49
+ tables: dict[str, TableDef] = {}
50
+ refs: list[dict] = []
51
+
52
+ current_table: Optional[TableDef] = None
53
+ in_indexes_block = False
54
+ note_block_depth = 0
55
+ in_ref_block = False # NEW
56
+
57
+ for raw_line in lines:
58
+ line = raw_line.strip()
59
+ if not line or line.startswith("//"):
60
+ continue
61
+
62
+ cleaned = line.split("//", 1)[0].strip()
63
+ if not cleaned:
64
+ continue
65
+
66
+ triple_quote_count = cleaned.count("'''")
67
+ if triple_quote_count:
68
+ note_block_depth = (note_block_depth + triple_quote_count) % 2
69
+ if cleaned.startswith("Note:"):
70
+ continue
71
+ if note_block_depth:
72
+ continue
73
+
74
+ # ----------------------
75
+ # TABLE PARSING
76
+ # ----------------------
77
+ if cleaned.lower().startswith("table "):
78
+ table_name_section = cleaned[6:].split("{", 1)[0].strip()
79
+ if "[" in table_name_section:
80
+ table_name_section = table_name_section.split("[", 1)[0].strip()
81
+ table_name = _strip_quotes(table_name_section)
82
+ current_table = TableDef(name=table_name)
83
+ continue
84
+
85
+ if current_table:
86
+ if cleaned.startswith("indexes"):
87
+ in_indexes_block = True
88
+ continue
89
+ if in_indexes_block:
90
+ if cleaned.endswith("}"):
91
+ in_indexes_block = False
92
+ continue
93
+ if cleaned.startswith("}"):
94
+ tables[current_table.name] = current_table
95
+ current_table = None
96
+ continue
97
+ if cleaned.startswith("Note:"):
98
+ continue
99
+
100
+ col_match = re.match(
101
+ r'(".*?"|`.*?`|[\w]+)\s+([^\[]+?)(?:\s+\[(.+)\])?$',
102
+ cleaned,
103
+ )
104
+ if not col_match:
105
+ continue
106
+
107
+ col_name = _strip_quotes(col_match.group(1))
108
+ col_type = col_match.group(2).strip()
109
+ settings = _parse_column_settings(col_match.group(3))
110
+
111
+ current_table.columns.append(
112
+ ColumnDef(
113
+ name=col_name,
114
+ data_type=col_type,
115
+ settings=settings,
116
+ note=None,
117
+ )
118
+ )
119
+ continue
120
+
121
+ # ----------------------
122
+ # REF BLOCK START
123
+ # ----------------------
124
+ if cleaned.startswith("Ref"):
125
+ in_ref_block = True
126
+ continue
127
+
128
+ if in_ref_block:
129
+ if cleaned.startswith("}"):
130
+ in_ref_block = False
131
+ continue
132
+
133
+ # Match: "table"."column" > "table"."column"
134
+ ref_match = re.match(
135
+ r'(".*?"|`.*?`|[\w]+)\.(".*?"|`.*?`|[\w]+)\s*([<>])\s*'
136
+ r'(".*?"|`.*?`|[\w]+)\.(".*?"|`.*?`|[\w]+)',
137
+ cleaned,
138
+ )
139
+ if not ref_match:
140
+ continue
141
+
142
+ left_table, left_column, operator, right_table, right_column = ref_match.groups()
143
+
144
+ # Ignore <> and other non-FK relations
145
+ if operator not in (">", "<"):
146
+ continue
147
+
148
+ if operator == "<":
149
+ left_table, right_table = right_table, left_table
150
+ left_column, right_column = right_column, left_column
151
+
152
+ refs.append(
153
+ {
154
+ "source_table": _strip_quotes(left_table),
155
+ "source_column": _strip_quotes(left_column),
156
+ "target_table": _strip_quotes(right_table),
157
+ "target_column": _strip_quotes(right_column),
158
+ }
159
+ )
160
+ continue
161
+
162
+ return tables, refs
model2data/utils.py ADDED
@@ -0,0 +1,9 @@
1
+ import re
2
+
3
+ def normalize_identifier(value: str) -> str:
4
+ cleaned = re.sub(r"[^0-9A-Za-z]+", "_", value).strip("_").lower()
5
+ if not cleaned:
6
+ cleaned = "table"
7
+ if cleaned[0].isdigit():
8
+ cleaned = f"t_{cleaned}"
9
+ return cleaned
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: model2data
3
+ Version: 0.1.0
4
+ Summary: Generate analytics-ready datasets from DBML models
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: dbt-core>=1.5.0
9
+ Requires-Dist: dbt-duckdb>=1.5.0
10
+ Requires-Dist: faker>=37.12.0
11
+ Requires-Dist: pandas>=2.3.3
12
+ Requires-Dist: pyyaml>=6.0.3
13
+ Requires-Dist: typer>=0.20.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # model2data
19
+
20
+ [![PyPI](https://img.shields.io/pypi/v/model2data)](https://pypi.org/project/model2data/)
21
+ [![CI](https://github.com/JB-Analytica/model2data/actions/workflows/ci.yml/badge.svg)](https://github.com/JB-Analytica/model2data/actions/workflows/ci.yml)
22
+ [![codecov](https://codecov.io/gh/JB-Analytica/model2data/branch/main/graph/badge.svg)](https://codecov.io/gh/JB-Analytica/model2data)
23
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
24
+
25
+ `model2data` turns **data models into analytics-ready datasets** in seconds.
26
+
27
+ 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.
28
+
29
+ ---
30
+
31
+ ## What problem does it solve?
32
+
33
+ Building analytics or testing dbt pipelines often requires realistic data, but using real data raises privacy concerns, and creating mock data manually is time-consuming. `model2data` automates this by generating synthetic datasets from your data model definitions, ensuring privacy-safe, deterministic, and relationship-preserving data for development and testing.
34
+
35
+ ---
36
+
37
+ ## How it works (high level)
38
+
39
+ 1. **Parse DBML**: Reads your database schema from a DBML file, extracting tables, columns, types, and relationships.
40
+ 2. **Generate Data**: Uses Faker and custom logic to create realistic synthetic data, respecting foreign keys and constraints.
41
+ 3. **Scaffold dbt Project**: Creates a dbt project with seeds (CSV files), staging models, profiles, and tests, ready to run with DuckDB.
42
+
43
+ ---
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install model2data
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Quick start
54
+
55
+ We provide an example Hacker News dataset in `examples/hackernews.dbml`.
56
+
57
+ Generate a project with synthetic data:
58
+
59
+ ```bash
60
+ model2data generate --file examples/hackernews.dbml --rows 200 --seed 42
61
+ ```
62
+
63
+ This creates a `dbt_hackernews/` folder with your data and dbt setup.
64
+
65
+ Run dbt to load and transform the data:
66
+
67
+ ```bash
68
+ cd dbt_hackernews
69
+ dbt deps
70
+ dbt seed
71
+ dbt run
72
+ ```
73
+
74
+ Your analytics-ready dataset is now in DuckDB!
75
+
76
+ ---
77
+
78
+ ## Generated dbt project structure
79
+
80
+ The generated dbt project includes:
81
+
82
+ ```
83
+ dbt_{project_name}/
84
+ ├── seeds/
85
+ │ └── {project_name}/
86
+ │ ├── table1.csv
87
+ │ └── table2.csv
88
+ ├── models/
89
+ │ └── {project_name}/
90
+ │ └── staging/
91
+ │ ├── __sources.yml
92
+ │ ├── stg_table1.sql
93
+ │ ├── stg_table1.yml
94
+ │ └── ...
95
+ ├── macros/
96
+ │ └── generate_schema_name.sql
97
+ ├── dbt_project.yml
98
+ ├── profiles.yml # DuckDB config
99
+ └── {project_name}.duckdb
100
+ ```
101
+
102
+ - **Seeds**: CSV files with generated synthetic data.
103
+ - **Staging Models**: Basic dbt models that load from seeds.
104
+ - **Sources & Tests**: YAML configs defining sources and basic tests (not_null, unique).
105
+ - **Profiles**: Pre-configured for DuckDB with schema handling.
106
+
107
+ ---
108
+
109
+ ## Design decisions / non-goals
110
+
111
+ - **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.
112
+ - **dbt Integration**: Leverages dbt's transformation capabilities for a familiar workflow in analytics engineering.
113
+ - **Synthetic Data**: Uses deterministic generation for reproducibility; not intended for production use or as a replacement for real data.
114
+ - **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.
115
+
116
+ ---
117
+
118
+ ## Limitations
119
+
120
+ - Supports basic DBML features; complex constraints or advanced SQL types may not be fully handled.
121
+ - Synthetic data generation is heuristic-based and may not perfectly mimic real-world distributions or edge cases.
122
+ - Currently optimized for DuckDB; other databases require manual profile adjustments.
123
+ - No support for incremental models or advanced dbt features in generated projects.
124
+
125
+ ---
126
+
127
+ ## Roadmap
128
+
129
+ - Support for additional database adapters (e.g., Snowflake, BigQuery).
130
+ - Enhanced data type handling and custom generators.
131
+ - Integration with more dbt features like incremental models.
132
+ - Web-based DBML editor and data preview.
133
+
134
+ ---
135
+
136
+ ## Contributing
137
+
138
+ We welcome contributions!
139
+
140
+ - Open issues for bugs or feature requests.
141
+ - Submit PRs to add new DBML examples, custom data generators, or improvements.
142
+ - Ensure all new features include tests if possible.
143
+
144
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
145
+
146
+ ## Code of Conduct
147
+
148
+ Please read our [Code of Conduct](CODE_OF_CONDUCT.md) to understand our community standards.
149
+
150
+ ---
151
+
152
+ ## License
153
+
154
+ MIT License. See LICENSE for details.
@@ -0,0 +1,13 @@
1
+ model2data/cli.py,sha256=tRlpBahqZ7Z7eCaUBppZyu7E8nMVyIR4WL-KJXnOziA,4630
2
+ model2data/utils.py,sha256=ZlzYKUfQosEfrI15hpDVgv8iiIHt8dKZECVUYsc16e4,252
3
+ model2data/dbt/project.py,sha256=cgyA3QcA-6iTl93NvBeuvLQ26HkFwoCxJ_sGkpd6dK0,2648
4
+ model2data/generate/core.py,sha256=I2w6I7heTITNr82L4OWQ7wq1dmfaZ4O1pm_ERlA9YhM,5175
5
+ model2data/generate/faker.py,sha256=5LbqDuas1Q71oSEKX8liiZqbLWilbzECgo1G181NgaM,4288
6
+ model2data/generate/relationships.py,sha256=Ah9zq9UETwb43mk9ALQF59ZRCX-mmjfRBAZtyaF8hCk,1563
7
+ model2data/parse/dbml.py,sha256=ndTQN3AaXdZwV8zWVw8flqb3fO2D2tJFPDx_uPYKKO8,4989
8
+ model2data-0.1.0.dist-info/licenses/LICENSE,sha256=kAFfB-3FDlSxhY18IvmYQmkJfRiIGdtfwOGaxRMkyoE,1069
9
+ model2data-0.1.0.dist-info/METADATA,sha256=vCiwjNfVWeI8a1Reupm64lIfcTOuj-NL2SfWMmLz2oA,5109
10
+ model2data-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ model2data-0.1.0.dist-info/entry_points.txt,sha256=VK1bQ5WD8dga2o5PiwZOmDVp06NA5KiHsJQacuvozao,50
12
+ model2data-0.1.0.dist-info/top_level.txt,sha256=lw7f_aIPwiJ-mFP10Y1fFFzE8DsvY9f5D56XGRNejQc,11
13
+ model2data-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ model2data = model2data.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 JB Analytica
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ model2data