model2data 0.4.1__tar.gz → 0.4.3__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.4.1 → model2data-0.4.3}/PKG-INFO +21 -15
  2. {model2data-0.4.1 → model2data-0.4.3}/README.md +20 -14
  3. {model2data-0.4.1 → model2data-0.4.3}/model2data/cli.py +2 -2
  4. {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/project.py +1 -1
  5. {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/tests.py +67 -6
  6. {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/core.py +60 -0
  7. {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/faker.py +40 -0
  8. {model2data-0.4.1 → model2data-0.4.3}/model2data/parse/dbml.py +64 -5
  9. {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/PKG-INFO +21 -15
  10. {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/SOURCES.txt +1 -0
  11. {model2data-0.4.1 → model2data-0.4.3}/pyproject.toml +1 -1
  12. {model2data-0.4.1 → model2data-0.4.3}/tests/test_cli.py +4 -4
  13. {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbml_parser.py +185 -12
  14. model2data-0.4.3/tests/test_dbt_integration.py +104 -0
  15. {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbt_project.py +1 -1
  16. {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbt_tests.py +88 -7
  17. {model2data-0.4.1 → model2data-0.4.3}/tests/test_generation.py +61 -0
  18. {model2data-0.4.1 → model2data-0.4.3}/LICENSE +0 -0
  19. {model2data-0.4.1 → model2data-0.4.3}/model2data/__init__.py +0 -0
  20. {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/__init__.py +0 -0
  21. {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/templates/dbt_project.yml.jinja +0 -0
  22. {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/templates/macros/generate_schema_name.sql +0 -0
  23. {model2data-0.4.1 → model2data-0.4.3}/model2data/dbt/templates/profiles.yml.jinja +0 -0
  24. {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/__init__.py +0 -0
  25. {model2data-0.4.1 → model2data-0.4.3}/model2data/generate/relationships.py +0 -0
  26. {model2data-0.4.1 → model2data-0.4.3}/model2data/parse/__init__.py +0 -0
  27. {model2data-0.4.1 → model2data-0.4.3}/model2data/utils.py +0 -0
  28. {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/dependency_links.txt +0 -0
  29. {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/entry_points.txt +0 -0
  30. {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/requires.txt +0 -0
  31. {model2data-0.4.1 → model2data-0.4.3}/model2data.egg-info/top_level.txt +0 -0
  32. {model2data-0.4.1 → model2data-0.4.3}/setup.cfg +0 -0
  33. {model2data-0.4.1 → model2data-0.4.3}/tests/test_coverage_gaps.py +0 -0
  34. {model2data-0.4.1 → model2data-0.4.3}/tests/test_dbt_naming.py +0 -0
  35. {model2data-0.4.1 → model2data-0.4.3}/tests/test_faker_name_inference.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -122,7 +122,9 @@ flowchart LR
122
122
  (int, date, timestamp, ...), name-aware inference for everything else (`email`, `phone`,
123
123
  `city`, ...), foreign keys resolved against already-generated parent rows.
124
124
  3. **Scaffold.** Writes a complete dbt project around that data: CSV seeds, staging models with
125
- `source`/`not_null`/`unique`/`relationships` tests, and a profile for DuckDB (zero-config,
125
+ `source`/`not_null`/`unique`/`relationships` tests, `accepted_values` tests for DBML
126
+ `Enum`-typed columns, singular SQL tests for composite primary/unique keys, table and column
127
+ `description:` fields pulled from DBML notes, and a profile for DuckDB (zero-config,
126
128
  file-based) or Postgres.
127
129
 
128
130
  ---
@@ -169,7 +171,7 @@ Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `M
169
171
 
170
172
  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.
171
173
 
172
- Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`tests/unit/test_stg_<table>.yml`) from the actually-generated seed rows:
174
+ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`models/staging/ut_stg_<table>.yml`) from the actually-generated seed rows:
173
175
 
174
176
  ```bash
175
177
  model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
@@ -186,32 +188,36 @@ The generated dbt project includes:
186
188
  ```
187
189
  dbt_{project_name}/
188
190
  ├── seeds/
189
- │ └── {project_name}/
191
+ │ └── raw/
190
192
  │ ├── table1.csv
191
193
  │ └── table2.csv
192
194
  ├── models/
193
- │ └── {project_name}/
194
- └── staging/
195
- ├── __sources.yml
196
- ├── stg_table1.sql
197
- ├── stg_table1.yml
198
- └── ...
195
+ │ └── staging/
196
+ ├── __sources.yml
197
+ ├── stg_table1.sql
198
+ ├── stg_table1.yml
199
+ ├── ut_stg_table1.yml # only with --unit-tests
200
+ └── ...
201
+ ├── data-tests/
202
+ │ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
199
203
  ├── macros/
200
204
  │ └── generate_schema_name.sql
201
205
  ├── dbt_project.yml
202
206
  ├── profiles.yml # DuckDB or Postgres config, depending on --adapter
203
- └── {project_name}.duckdb # DuckDB adapter only
207
+ └── {project_name}_profile.duckdb # DuckDB adapter only
204
208
  ```
205
209
 
206
210
  - **Seeds**: CSV files with generated synthetic data.
207
211
  - **Staging Models**: Basic dbt models that load from seeds.
208
212
  - **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
209
213
  `relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
210
- `Note` text from the DBML becomes `description:` fields. Composite primary/unique keys
211
- declared in an `indexes { }` block get a singular SQL test under `tests/`.
214
+ `Note` text from the DBML becomes `description:` fields.
215
+ - **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
216
+ a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
212
217
  - **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
213
- - **Unit tests** (opt-in via `--unit-tests`): `tests/unit/test_stg_<table>.yml` fixtures built
214
- from real generated rows. Requires dbt-core >= 1.8.
218
+ - **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
219
+ from real generated rows, co-located with each staging model so dbt (which only parses unit
220
+ tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
215
221
 
216
222
  ---
217
223
 
@@ -97,7 +97,9 @@ flowchart LR
97
97
  (int, date, timestamp, ...), name-aware inference for everything else (`email`, `phone`,
98
98
  `city`, ...), foreign keys resolved against already-generated parent rows.
99
99
  3. **Scaffold.** Writes a complete dbt project around that data: CSV seeds, staging models with
100
- `source`/`not_null`/`unique`/`relationships` tests, and a profile for DuckDB (zero-config,
100
+ `source`/`not_null`/`unique`/`relationships` tests, `accepted_values` tests for DBML
101
+ `Enum`-typed columns, singular SQL tests for composite primary/unique keys, table and column
102
+ `description:` fields pulled from DBML notes, and a profile for DuckDB (zero-config,
101
103
  file-based) or Postgres.
102
104
 
103
105
  ---
@@ -144,7 +146,7 @@ Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `M
144
146
 
145
147
  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.
146
148
 
147
- Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`tests/unit/test_stg_<table>.yml`) from the actually-generated seed rows:
149
+ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`models/staging/ut_stg_<table>.yml`) from the actually-generated seed rows:
148
150
 
149
151
  ```bash
150
152
  model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
@@ -161,32 +163,36 @@ The generated dbt project includes:
161
163
  ```
162
164
  dbt_{project_name}/
163
165
  ├── seeds/
164
- │ └── {project_name}/
166
+ │ └── raw/
165
167
  │ ├── table1.csv
166
168
  │ └── table2.csv
167
169
  ├── models/
168
- │ └── {project_name}/
169
- └── staging/
170
- ├── __sources.yml
171
- ├── stg_table1.sql
172
- ├── stg_table1.yml
173
- └── ...
170
+ │ └── staging/
171
+ ├── __sources.yml
172
+ ├── stg_table1.sql
173
+ ├── stg_table1.yml
174
+ ├── ut_stg_table1.yml # only with --unit-tests
175
+ └── ...
176
+ ├── data-tests/
177
+ │ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
174
178
  ├── macros/
175
179
  │ └── generate_schema_name.sql
176
180
  ├── dbt_project.yml
177
181
  ├── profiles.yml # DuckDB or Postgres config, depending on --adapter
178
- └── {project_name}.duckdb # DuckDB adapter only
182
+ └── {project_name}_profile.duckdb # DuckDB adapter only
179
183
  ```
180
184
 
181
185
  - **Seeds**: CSV files with generated synthetic data.
182
186
  - **Staging Models**: Basic dbt models that load from seeds.
183
187
  - **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
184
188
  `relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
185
- `Note` text from the DBML becomes `description:` fields. Composite primary/unique keys
186
- declared in an `indexes { }` block get a singular SQL test under `tests/`.
189
+ `Note` text from the DBML becomes `description:` fields.
190
+ - **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
191
+ a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
187
192
  - **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
188
- - **Unit tests** (opt-in via `--unit-tests`): `tests/unit/test_stg_<table>.yml` fixtures built
189
- from real generated rows. Requires dbt-core >= 1.8.
193
+ - **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
194
+ from real generated rows, co-located with each staging model so dbt (which only parses unit
195
+ tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
190
196
 
191
197
  ---
192
198
 
@@ -80,8 +80,8 @@ def main(
80
80
  False,
81
81
  "--unit-tests",
82
82
  help=(
83
- "Also generate deterministic dbt unit test fixtures (tests/unit/) from the "
84
- "generated seed rows. Requires dbt-core >= 1.8 to run."
83
+ "Also generate deterministic dbt unit test fixtures (models/staging/ut_stg_*.yml) "
84
+ "from the generated seed rows. Requires dbt-core >= 1.8 to run."
85
85
  ),
86
86
  ),
87
87
  ):
@@ -13,7 +13,7 @@ def create_project_scaffold(dest: Path, project_name: str, profile_name: str) ->
13
13
  (dest / "seeds" / "raw").mkdir(parents=True, exist_ok=True)
14
14
  (dest / "analysis").mkdir(exist_ok=True)
15
15
  (dest / "macros").mkdir(exist_ok=True)
16
- (dest / "tests").mkdir(exist_ok=True)
16
+ (dest / "data-tests").mkdir(exist_ok=True)
17
17
  (dest / "snapshots").mkdir(exist_ok=True)
18
18
 
19
19
  # dbt_project.yml
@@ -6,6 +6,9 @@ from typing import Any, Union
6
6
  import pandas as pd
7
7
  import yaml
8
8
 
9
+ from model2data.generate.faker import is_free_text_type
10
+ from model2data.generate.relationships import classify_refs
11
+
9
12
 
10
13
  def _yaml_field_lines(key: str, value: str, indent: str) -> list[str]:
11
14
  """Render `key: value` as safely-escaped YAML lines at the given indent."""
@@ -28,8 +31,23 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
28
31
  # -------------------------
29
32
  # Build foreign key map
30
33
  # -------------------------
34
+ # Only emit a `relationships` test for refs the generator actually makes
35
+ # FK-aware: direct FK refs (target column is a pk/"id"), plus attribute
36
+ # refs that ride along an existing FK between the same two tables (see
37
+ # generate.core's attribute-mirroring pass). An attribute ref with no
38
+ # accompanying FK is left as unrelated random data by the generator, so
39
+ # testing it against the parent table would be a guaranteed false
40
+ # failure.
41
+ fk_refs_classified, attribute_refs_classified = classify_refs(tables, refs)
42
+ fk_table_pairs = {(fk["source_table"], fk["target_table"]) for fk in fk_refs_classified}
43
+ eligible_refs = list(fk_refs_classified) + [
44
+ ref
45
+ for ref in attribute_refs_classified
46
+ if (ref["source_table"], ref["target_table"]) in fk_table_pairs
47
+ ]
48
+
31
49
  fk_map = defaultdict(list)
32
- for ref in refs:
50
+ for ref in eligible_refs:
33
51
  fk_map[(ref["source_table"], ref["source_column"])].append(ref)
34
52
 
35
53
  # -------------------------
@@ -102,9 +120,10 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
102
120
  lines.append(f" - {test}")
103
121
  elif "accepted_values" in test:
104
122
  lines.append(" - accepted_values:")
105
- lines.append(" values:")
123
+ lines.append(" arguments:")
124
+ lines.append(" values:")
106
125
  for value in test["accepted_values"]["values"]:
107
- lines.append(f" - {value!r}")
126
+ lines.append(f" - {value!r}")
108
127
  else:
109
128
  # relationships test with arguments
110
129
  for k, v in test.items():
@@ -122,9 +141,47 @@ def generate_dbt_yml(dest: Path, tables: dict, refs: list[dict], source_name: st
122
141
  # -------------------------
123
142
  _generate_composite_key_tests(dest, tables)
124
143
 
144
+ # -------------------------
145
+ # Seed column-type overrides
146
+ # -------------------------
147
+ _generate_seed_config(dest, tables)
148
+
149
+
150
+ def _generate_seed_config(dest: Path, tables: dict) -> None:
151
+ """
152
+ Force every free-text column (see `is_free_text_type`) to VARCHAR in
153
+ the seed loader config, instead of letting dbt/duckdb sniff the type
154
+ from CSV content. Some generated text is all-digit (EAN13 barcodes,
155
+ zero-padded postcodes, ...) and would otherwise be silently loaded as
156
+ an integer, overflowing or dropping leading zeros.
157
+ """
158
+ lines = ["version: 2", "", "seeds:"]
159
+ any_column_types = False
160
+
161
+ for table in tables.values():
162
+ column_types = {
163
+ col.name: "varchar" for col in table.columns if is_free_text_type(col.data_type)
164
+ }
165
+ if not column_types:
166
+ continue
167
+
168
+ any_column_types = True
169
+ lines.append(f" - name: {table.name}")
170
+ lines.append(" config:")
171
+ lines.append(" column_types:")
172
+ for col_name, col_type in column_types.items():
173
+ lines.append(f" {col_name}: {col_type}")
174
+
175
+ if not any_column_types:
176
+ return
177
+
178
+ seed_raw_path = dest / "seeds" / "raw"
179
+ seed_raw_path.mkdir(parents=True, exist_ok=True)
180
+ (seed_raw_path / "__seed_config.yml").write_text("\n".join(lines))
181
+
125
182
 
126
183
  def _generate_composite_key_tests(dest: Path, tables: dict) -> None:
127
- tests_path = dest / "tests"
184
+ tests_path = dest / "data-tests"
128
185
  tests_path.mkdir(parents=True, exist_ok=True)
129
186
 
130
187
  for table in tables.values():
@@ -158,8 +215,12 @@ def generate_unit_tests(
158
215
 
159
216
  Since staging models are pure `select * from {{ source(...) }}` passthroughs,
160
217
  a handful of already-generated rows can serve as both `given` and `expect`.
218
+
219
+ Written alongside each staging model (under `model-paths`, which is where
220
+ dbt actually parses unit tests from) as `ut_stg_<table>.yml`, parallel to
221
+ the `stg_<table>.yml` schema file generated by `generate_dbt_yml`.
161
222
  """
162
- unit_tests_path = dest / "tests" / "unit"
223
+ unit_tests_path = dest / "models" / "staging"
163
224
  unit_tests_path.mkdir(parents=True, exist_ok=True)
164
225
 
165
226
  for table in tables.values():
@@ -188,7 +249,7 @@ def generate_unit_tests(
188
249
  ]
189
250
  }
190
251
 
191
- yml_file = unit_tests_path / f"test_{stg_name}.yml"
252
+ yml_file = unit_tests_path / f"ut_{stg_name}.yml"
192
253
  yml_file.write_text(yaml.safe_dump(unit_test, sort_keys=False, default_flow_style=False))
193
254
 
194
255
 
@@ -76,6 +76,7 @@ def generate_data_from_dbml(
76
76
  )
77
77
 
78
78
  df = pd.DataFrame(data)
79
+ df = _resolve_self_referencing_fks(df, table_def, table_name, fk_lookup, row_count)
79
80
  df = _deduplicate_composite_keys(df, table_def)
80
81
 
81
82
  # -----------------------------------------------------
@@ -110,6 +111,7 @@ def generate_data_from_dbml(
110
111
 
111
112
  df[child_column] = df[fk_column].map(lookup)
112
113
 
114
+ df = _coerce_integer_dtypes(df, table_def)
113
115
  generated[table_name] = df
114
116
 
115
117
  return generated
@@ -118,6 +120,25 @@ def generate_data_from_dbml(
118
120
  # ---------------------------------------------------------
119
121
  # Internal helpers
120
122
  # ---------------------------------------------------------
123
+ def _coerce_integer_dtypes(df: pd.DataFrame, table_def: TableDef) -> pd.DataFrame:
124
+ """
125
+ Cast int/bigint/smallint-typed columns to pandas' nullable "Int64" dtype.
126
+
127
+ `generate_column_values` fills "empty" nullable columns with `None`
128
+ (absent an explicit default). Building a plain `pd.DataFrame` from a
129
+ Python list mixing ints and `None` silently upcasts that column to
130
+ float64, so whole numbers round-trip through the CSV seed as "70.0"
131
+ instead of "70" and blanks. Int64 keeps them as integers and renders
132
+ nulls as empty cells, matching the DBML-declared type.
133
+ """
134
+ for column in table_def.columns:
135
+ base_type = column.data_type.lower().split("(")[0].strip()
136
+ if any(key in base_type for key in ["int", "integer", "bigint", "smallint"]):
137
+ df[column.name] = df[column.name].astype("Int64")
138
+
139
+ return df
140
+
141
+
121
142
  def _deduplicate_composite_keys(
122
143
  df: pd.DataFrame,
123
144
  table_def: TableDef,
@@ -158,6 +179,45 @@ def _deduplicate_composite_keys(
158
179
  return df
159
180
 
160
181
 
182
+ def _resolve_self_referencing_fks(
183
+ df: pd.DataFrame,
184
+ table_def: TableDef,
185
+ table_name: str,
186
+ fk_lookup: dict[tuple[str, str], tuple[str, str]],
187
+ row_count: int,
188
+ ) -> pd.DataFrame:
189
+ """
190
+ Re-generate any FK column that references its own table (e.g. a
191
+ `manager_id` on `employees` pointing back at `employees.id`) using the
192
+ table's own just-built parent column as the value pool.
193
+
194
+ These columns can't be resolved during the main per-column generation
195
+ pass above because the table isn't done building itself yet (its own
196
+ df isn't added to `generated` until the whole loop iteration finishes),
197
+ so `fk_series` falls through to None there and the column gets
198
+ unrelated random values instead. Once `df` exists we know the real
199
+ parent-column values and can fix it up here.
200
+ """
201
+ for column in table_def.columns:
202
+ fk_target = fk_lookup.get((table_name, column.name))
203
+ if not fk_target:
204
+ continue
205
+
206
+ parent_table, parent_column = fk_target
207
+ if parent_table != table_name or parent_column not in df.columns:
208
+ continue
209
+
210
+ ensure_unique = "pk" in column.settings
211
+ df[column.name] = generate_column_values(
212
+ column=column,
213
+ row_count=row_count,
214
+ fk_series=df[parent_column],
215
+ ensure_unique=ensure_unique,
216
+ )
217
+
218
+ return df
219
+
220
+
161
221
  def _determine_row_count(table_name: str, base_rows: int) -> int:
162
222
  """
163
223
  Return the base number of rows for all tables.
@@ -62,6 +62,46 @@ _NAME_PATTERNS: list[tuple[str, Callable[[], object]]] = [
62
62
  ("summary", lambda: fake.text(max_nb_chars=160)),
63
63
  ]
64
64
 
65
+ # DBML type substrings generate_column_values renders as a database-native
66
+ # numeric/boolean/date/uuid value rather than arbitrary text. Shared with
67
+ # `is_free_text_type` below so seed column-type config stays in sync with
68
+ # actual generation.
69
+ _STRUCTURED_TYPE_KEYS = (
70
+ "uuid",
71
+ "hash",
72
+ "int",
73
+ "integer",
74
+ "bigint",
75
+ "smallint",
76
+ "decimal",
77
+ "numeric",
78
+ "float",
79
+ "double",
80
+ "boolean",
81
+ "bool",
82
+ "date",
83
+ "time",
84
+ "timestamp",
85
+ "datetime",
86
+ )
87
+
88
+
89
+ def is_free_text_type(data_type: str) -> bool:
90
+ """
91
+ True for DBML types generate_column_values fills with arbitrary text
92
+ (name-pattern lookups, a literal Faker provider, or the generic
93
+ fallback) rather than a numeric/boolean/date/uuid value.
94
+
95
+ Used to force such seed columns to VARCHAR in the generated dbt
96
+ project: some Faker-produced text (EAN13 barcodes, postcodes with a
97
+ leading zero, ...) is entirely digits, which is enough for dbt's CSV
98
+ seed loader to mis-infer an integer column and either overflow or
99
+ silently strip meaningful leading zeros.
100
+ """
101
+ base_type = data_type.lower().split("(")[0].strip()
102
+ return not any(key in base_type for key in _STRUCTURED_TYPE_KEYS)
103
+
104
+
65
105
  # Column/table introspection helpers used by both generation and the
66
106
  # CLI's post-run summary, so the two stay in sync.
67
107
  _stats_state: dict[str, list[tuple[str, str]]] = {"unmapped": []}
@@ -68,17 +68,38 @@ def _parse_default_value(raw: str) -> Optional[object]:
68
68
  return None
69
69
 
70
70
 
71
+ # Matches the "> table.column" / "< table.column" / "<> table.column" part of
72
+ # an inline `ref: > table.column` column setting. Table/column names may be
73
+ # bare, double-quoted, or backtick-quoted, mirroring the standalone `Ref {}`
74
+ # block parser's identifier handling.
75
+ _INLINE_REF_RE = re.compile(r"^(<>|[<>])\s*(\".*?\"|`.*?`|[\w]+)\.(\".*?\"|`.*?`|[\w]+)$")
76
+
77
+
78
+ def _parse_inline_ref(value: str) -> Optional[dict]:
79
+ """Parse the value of a `ref:` column setting into operator + target table/column."""
80
+ match = _INLINE_REF_RE.match(value.strip())
81
+ if not match:
82
+ return None
83
+ operator, target_table, target_column = match.groups()
84
+ return {
85
+ "operator": operator,
86
+ "target_table": _strip_quotes(target_table),
87
+ "target_column": _strip_quotes(target_column),
88
+ }
89
+
90
+
71
91
  def _parse_column_settings(
72
92
  raw: Optional[str],
73
- ) -> tuple[set[str], Optional[dict], Optional[str], Optional[object]]:
74
- """Parse column settings, extracting note/description and default if present."""
93
+ ) -> tuple[set[str], Optional[dict], Optional[str], Optional[object], Optional[dict]]:
94
+ """Parse column settings, extracting note/description/default/inline ref if present."""
75
95
  if not raw:
76
- return set(), None, None, None
96
+ return set(), None, None, None, None
77
97
 
78
98
  settings = set()
79
99
  note_dict = None
80
100
  description = None
81
101
  default_value = None
102
+ inline_ref = None
82
103
 
83
104
  # Split by comma, but be careful with nested structures
84
105
  parts = []
@@ -119,11 +140,13 @@ def _parse_column_settings(
119
140
  elif part.lower().startswith("default:"):
120
141
  default_str = part[len("default:") :].strip()
121
142
  default_value = _parse_default_value(default_str)
143
+ elif part.lower().startswith("ref:"):
144
+ inline_ref = _parse_inline_ref(part[len("ref:") :])
122
145
  else:
123
146
  # Regular setting (pk, not null, unique, etc.)
124
147
  settings.add(part.strip("'").strip('"').lower())
125
148
 
126
- return settings, note_dict, description, default_value
149
+ return settings, note_dict, description, default_value, inline_ref
127
150
 
128
151
 
129
152
  # Many-to-many (`<>`) refs are captured but not fed into FK-based generation.
@@ -310,7 +333,7 @@ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
310
333
  if len(col_type.split()) > 3:
311
334
  continue
312
335
 
313
- settings, note_dict, description, default_value = _parse_column_settings(
336
+ settings, note_dict, description, default_value, inline_ref = _parse_column_settings(
314
337
  col_match.group(3)
315
338
  )
316
339
 
@@ -325,6 +348,42 @@ def parse_dbml(dbml_path: Path) -> tuple[dict[str, TableDef], list[dict]]:
325
348
  )
326
349
  )
327
350
 
351
+ if inline_ref is not None:
352
+ target_table = inline_ref["target_table"]
353
+ target_column = inline_ref["target_column"]
354
+
355
+ if inline_ref["operator"] == "<>":
356
+ many_to_many_refs.append(
357
+ {
358
+ "source_table": current_table.name,
359
+ "source_column": col_name,
360
+ "target_table": target_table,
361
+ "target_column": target_column,
362
+ }
363
+ )
364
+ elif inline_ref["operator"] == "<":
365
+ # "this column is referenced by target.column": the
366
+ # target is the actual FK-holding (child) side, so it
367
+ # becomes source; this column becomes target, mirroring
368
+ # the standalone `Ref { a < b }` swap below.
369
+ refs.append(
370
+ {
371
+ "source_table": target_table,
372
+ "source_column": target_column,
373
+ "target_table": current_table.name,
374
+ "target_column": col_name,
375
+ }
376
+ )
377
+ else:
378
+ refs.append(
379
+ {
380
+ "source_table": current_table.name,
381
+ "source_column": col_name,
382
+ "target_table": target_table,
383
+ "target_column": target_column,
384
+ }
385
+ )
386
+
328
387
  continue
329
388
 
330
389
  # ----------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model2data
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Generate analytics-ready datasets from DBML models
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -122,7 +122,9 @@ flowchart LR
122
122
  (int, date, timestamp, ...), name-aware inference for everything else (`email`, `phone`,
123
123
  `city`, ...), foreign keys resolved against already-generated parent rows.
124
124
  3. **Scaffold.** Writes a complete dbt project around that data: CSV seeds, staging models with
125
- `source`/`not_null`/`unique`/`relationships` tests, and a profile for DuckDB (zero-config,
125
+ `source`/`not_null`/`unique`/`relationships` tests, `accepted_values` tests for DBML
126
+ `Enum`-typed columns, singular SQL tests for composite primary/unique keys, table and column
127
+ `description:` fields pulled from DBML notes, and a profile for DuckDB (zero-config,
126
128
  file-based) or Postgres.
127
129
 
128
130
  ---
@@ -169,7 +171,7 @@ Connection details are read from environment variables (`MODEL2DATA_PG_HOST`, `M
169
171
 
170
172
  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.
171
173
 
172
- Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`tests/unit/test_stg_<table>.yml`) from the actually-generated seed rows:
174
+ Pass `--unit-tests` to also generate deterministic dbt unit test fixtures (`models/staging/ut_stg_<table>.yml`) from the actually-generated seed rows:
173
175
 
174
176
  ```bash
175
177
  model2data --file examples/hackernews.dbml --rows 200 --seed 42 --unit-tests
@@ -186,32 +188,36 @@ The generated dbt project includes:
186
188
  ```
187
189
  dbt_{project_name}/
188
190
  ├── seeds/
189
- │ └── {project_name}/
191
+ │ └── raw/
190
192
  │ ├── table1.csv
191
193
  │ └── table2.csv
192
194
  ├── models/
193
- │ └── {project_name}/
194
- └── staging/
195
- ├── __sources.yml
196
- ├── stg_table1.sql
197
- ├── stg_table1.yml
198
- └── ...
195
+ │ └── staging/
196
+ ├── __sources.yml
197
+ ├── stg_table1.sql
198
+ ├── stg_table1.yml
199
+ ├── ut_stg_table1.yml # only with --unit-tests
200
+ └── ...
201
+ ├── data-tests/
202
+ │ └── unique_combination_stg_table1_col_a_col_b.sql # only for composite pk/unique keys
199
203
  ├── macros/
200
204
  │ └── generate_schema_name.sql
201
205
  ├── dbt_project.yml
202
206
  ├── profiles.yml # DuckDB or Postgres config, depending on --adapter
203
- └── {project_name}.duckdb # DuckDB adapter only
207
+ └── {project_name}_profile.duckdb # DuckDB adapter only
204
208
  ```
205
209
 
206
210
  - **Seeds**: CSV files with generated synthetic data.
207
211
  - **Staging Models**: Basic dbt models that load from seeds.
208
212
  - **Sources & Tests**: YAML configs defining sources and column tests (`not_null`, `unique`,
209
213
  `relationships`, and `accepted_values` for DBML `Enum`-typed columns). Table and column
210
- `Note` text from the DBML becomes `description:` fields. Composite primary/unique keys
211
- declared in an `indexes { }` block get a singular SQL test under `tests/`.
214
+ `Note` text from the DBML becomes `description:` fields.
215
+ - **Composite key tests**: Composite primary/unique keys declared in an `indexes { }` block get
216
+ a singular SQL test under `data-tests/`, dbt's configured `test-paths`.
212
217
  - **Profiles**: Pre-configured for DuckDB (file-based) or Postgres (via env vars), with schema handling.
213
- - **Unit tests** (opt-in via `--unit-tests`): `tests/unit/test_stg_<table>.yml` fixtures built
214
- from real generated rows. Requires dbt-core >= 1.8.
218
+ - **Unit tests** (opt-in via `--unit-tests`): `models/staging/ut_stg_<table>.yml` fixtures built
219
+ from real generated rows, co-located with each staging model so dbt (which only parses unit
220
+ tests from `model-paths`) picks them up. Requires dbt-core >= 1.8.
215
221
 
216
222
  ---
217
223
 
@@ -25,6 +25,7 @@ model2data/parse/dbml.py
25
25
  tests/test_cli.py
26
26
  tests/test_coverage_gaps.py
27
27
  tests/test_dbml_parser.py
28
+ tests/test_dbt_integration.py
28
29
  tests/test_dbt_naming.py
29
30
  tests/test_dbt_project.py
30
31
  tests/test_dbt_tests.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model2data"
7
- version = "0.4.1"
7
+ version = "0.4.3"
8
8
  description = "Generate analytics-ready datasets from DBML models"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -577,7 +577,7 @@ def test_cli_all_output_messages_present(tmp_path):
577
577
 
578
578
 
579
579
  def test_cli_without_unit_tests_flag_creates_no_unit_test_dir(tmp_path):
580
- """--unit-tests defaults to off: no tests/unit/ directory should appear."""
580
+ """--unit-tests defaults to off: no unit test YAML should appear."""
581
581
  dbml_file = tmp_path / "test.dbml"
582
582
  dbml_file.write_text(
583
583
  """
@@ -596,11 +596,11 @@ def test_cli_without_unit_tests_flag_creates_no_unit_test_dir(tmp_path):
596
596
  os.chdir(original_cwd)
597
597
 
598
598
  assert result.exit_code == 0
599
- assert not (tmp_path / "dbt_test" / "tests" / "unit").exists()
599
+ assert not list((tmp_path / "dbt_test" / "models" / "staging").glob("ut_*.yml"))
600
600
 
601
601
 
602
602
  def test_cli_with_unit_tests_flag_generates_unit_test_yaml(tmp_path):
603
- """--unit-tests writes tests/unit/test_stg_<table>.yml and prints the dbt-core note."""
603
+ """--unit-tests writes models/staging/ut_stg_<table>.yml and prints the dbt-core note."""
604
604
  dbml_file = tmp_path / "test.dbml"
605
605
  dbml_file.write_text(
606
606
  """
@@ -632,7 +632,7 @@ def test_cli_with_unit_tests_flag_generates_unit_test_yaml(tmp_path):
632
632
  assert result.exit_code == 0
633
633
  assert "dbt-core >= 1.8" in result.stdout
634
634
 
635
- unit_yml = tmp_path / "dbt_test" / "tests" / "unit" / "test_stg_users.yml"
635
+ unit_yml = tmp_path / "dbt_test" / "models" / "staging" / "ut_stg_users.yml"
636
636
  assert unit_yml.exists()
637
637
 
638
638
  import yaml