mirror of
https://github.com/Canner/WrenAI.git
synced 2026-08-30 18:00:36 +08:00
fix(context): normalise model columns in load_models (#2614)
Co-authored-by: Jax Liu <liugs963@gmail.com>
This commit is contained in:
+144
-19
@@ -526,6 +526,26 @@ def require_schema_version(project_path: Path) -> int:
|
||||
# ── Loaders (all return snake_case dicts) ─────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_model_columns(model: dict) -> dict:
|
||||
"""Normalise YAML-sourced ``columns`` to ``list[dict]``.
|
||||
|
||||
Hand-edited project YAML can set ``columns:`` to a scalar or mix bare
|
||||
strings into the list. Loaders drop malformed entries (matching views/
|
||||
relationships); ``validate_project`` re-reads the raw file to report them.
|
||||
|
||||
Omit the key when absent so ``target/mdl.json`` does not gain an empty
|
||||
``columns: []`` solely from loader defaults.
|
||||
"""
|
||||
if "columns" not in model:
|
||||
return model
|
||||
raw = model.get("columns")
|
||||
if not isinstance(raw, list):
|
||||
model["columns"] = []
|
||||
return model
|
||||
model["columns"] = [c for c in raw if isinstance(c, dict)]
|
||||
return model
|
||||
|
||||
|
||||
def load_models(project_path: Path) -> list[dict]:
|
||||
"""Load models — dispatches on schema_version.
|
||||
|
||||
@@ -549,7 +569,7 @@ def _load_models_v1(project_path: Path) -> list[dict]:
|
||||
data = yaml.safe_load(f.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
data["_source_dir"] = f.stem
|
||||
models.append(data)
|
||||
models.append(_normalize_model_columns(data))
|
||||
return models
|
||||
|
||||
|
||||
@@ -582,7 +602,7 @@ def _load_models_v2(project_path: Path) -> list[dict]:
|
||||
if sql_content:
|
||||
model["ref_sql"] = sql_content
|
||||
|
||||
models.append(model)
|
||||
models.append(_normalize_model_columns(model))
|
||||
return models
|
||||
|
||||
|
||||
@@ -960,10 +980,76 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
model_names: set[str] = set()
|
||||
view_names: set[str] = set()
|
||||
|
||||
# Hand-edited model YAML may set columns: to a non-list (e.g. a bare
|
||||
# string). load_models() normalises to list[dict], but validate_project
|
||||
# must still report the mistake rather than let it vanish quietly.
|
||||
def _iter_raw_model_files() -> list[tuple[str, dict]]:
|
||||
models_dir = project_path / "models"
|
||||
if not models_dir.is_dir():
|
||||
return []
|
||||
out: list[tuple[str, dict]] = []
|
||||
if sv == 1:
|
||||
for f in sorted(models_dir.glob("*.yml")):
|
||||
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict):
|
||||
out.append((f"models/{f.name}", data))
|
||||
else:
|
||||
for d in sorted(models_dir.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
meta = d / "metadata.yml"
|
||||
if not meta.exists():
|
||||
continue
|
||||
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(data, dict):
|
||||
out.append((f"models/{d.name}/metadata.yml", data))
|
||||
return out
|
||||
|
||||
# source key (_source_dir) -> raw columns list (file order) for
|
||||
# index-stable diagnostics. Key by source identity, not model name —
|
||||
# duplicate names are reachable and must not cross-wire column lists.
|
||||
raw_model_columns: dict[str, list] = {}
|
||||
for src_path, raw_model in _iter_raw_model_files():
|
||||
if "columns" not in raw_model:
|
||||
continue
|
||||
raw_cols = raw_model.get("columns")
|
||||
# v2 path is models/<dir>/metadata.yml — prefer directory name over
|
||||
# stem "metadata" when the YAML omits `name`.
|
||||
if src_path.endswith("/metadata.yml"):
|
||||
source_key = Path(src_path).parent.name
|
||||
mname = raw_model.get("name") or source_key
|
||||
else:
|
||||
source_key = Path(src_path).stem
|
||||
mname = raw_model.get("name") or source_key
|
||||
if not isinstance(raw_cols, list):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error",
|
||||
f"{src_path} > {mname} > columns",
|
||||
f"must be a list, got {type(raw_cols).__name__}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
raw_model_columns[source_key] = raw_cols
|
||||
for j, col in enumerate(raw_cols):
|
||||
if not isinstance(col, dict):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error",
|
||||
f"{src_path} > {mname} > columns[{j}]",
|
||||
"column entry must be an object",
|
||||
)
|
||||
)
|
||||
|
||||
# Check models
|
||||
for i, model in enumerate(models):
|
||||
src = model.get("_source_dir", f"models[{i}]")
|
||||
src_path = f"models/{src}/metadata.yml"
|
||||
# v1 is flat models/<stem>.yml; v2+ is models/<dir>/metadata.yml.
|
||||
# Keep labels consistent with the raw re-read paths above.
|
||||
if sv == 1:
|
||||
src_path = f"models/{src}.yml"
|
||||
else:
|
||||
src_path = f"models/{src}/metadata.yml"
|
||||
name = model.get("name")
|
||||
if not name:
|
||||
errors.append(ValidationError("error", src_path, "model missing 'name'"))
|
||||
@@ -1005,14 +1091,8 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
)
|
||||
)
|
||||
|
||||
# columns shape is owned by load_models + the raw re-read above.
|
||||
columns = model.get("columns", [])
|
||||
if not isinstance(columns, list):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error", f"{src_path} > {name}", "columns must be a list"
|
||||
)
|
||||
)
|
||||
columns = []
|
||||
if not columns:
|
||||
errors.append(
|
||||
ValidationError(
|
||||
@@ -1020,16 +1100,19 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
)
|
||||
)
|
||||
|
||||
# Walk the raw columns list when available so indices match the file
|
||||
# (filtered loader positions would renumber past dropped junk).
|
||||
# Keyed by _source_dir so duplicate model names cannot cross-wire.
|
||||
raw_cols_for_model = raw_model_columns.get(src)
|
||||
col_entries = (
|
||||
list(enumerate(raw_cols_for_model))
|
||||
if raw_cols_for_model is not None
|
||||
else list(enumerate(columns))
|
||||
)
|
||||
col_names = set()
|
||||
for j, col in enumerate(columns):
|
||||
for j, col in col_entries:
|
||||
if not isinstance(col, dict):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
"error",
|
||||
f"{src_path} > {name} > columns[{j}]",
|
||||
"column entry must be an object",
|
||||
)
|
||||
)
|
||||
# Non-mappings already reported from the raw re-read above.
|
||||
continue
|
||||
col_name = col.get("name")
|
||||
if not col_name:
|
||||
@@ -1126,7 +1209,8 @@ def validate_project(project_path: Path) -> list[ValidationError]:
|
||||
"properties must be a mapping",
|
||||
)
|
||||
)
|
||||
for j, col in enumerate(columns):
|
||||
# Prefer raw-file indices for properties diagnostics too (same list).
|
||||
for j, col in col_entries:
|
||||
if not isinstance(col, dict):
|
||||
continue
|
||||
col_props = col.get("properties")
|
||||
@@ -1573,12 +1657,53 @@ def plan_upgrade(
|
||||
)
|
||||
|
||||
|
||||
def _reject_malformed_v1_model_columns(project_path: Path) -> None:
|
||||
"""Abort v1→v2 upgrade if any model YAML has malformed ``columns``.
|
||||
|
||||
Loader normalisation is correct for validation/runtime, but migration
|
||||
deletes the source file after writing the normalised model. Require a
|
||||
clean columns shape up front so content is never silently discarded.
|
||||
"""
|
||||
models_dir = project_path / "models"
|
||||
if not models_dir.is_dir():
|
||||
return
|
||||
problems: list[str] = []
|
||||
for f in sorted(models_dir.glob("*.yml")):
|
||||
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict) or "columns" not in data:
|
||||
continue
|
||||
raw_cols = data.get("columns")
|
||||
src = f"models/{f.name}"
|
||||
name = data.get("name") or f.stem
|
||||
if not isinstance(raw_cols, list):
|
||||
problems.append(
|
||||
f"{src} > {name} > columns: must be a list, "
|
||||
f"got {type(raw_cols).__name__}"
|
||||
)
|
||||
continue
|
||||
for j, col in enumerate(raw_cols):
|
||||
if not isinstance(col, dict):
|
||||
problems.append(
|
||||
f"{src} > {name} > columns[{j}]: column entry must be an object"
|
||||
)
|
||||
if problems:
|
||||
detail = "; ".join(problems)
|
||||
raise UpgradeError(
|
||||
"Cannot upgrade: malformed model columns would be discarded — " + detail
|
||||
)
|
||||
|
||||
|
||||
def _plan_v1_to_v2(project_path: Path) -> tuple[list[str], list[str]]:
|
||||
"""Plan the v1→v2 file restructuring. Returns (files_created, files_deleted)."""
|
||||
created: list[str] = []
|
||||
deleted: list[str] = []
|
||||
project_root = project_path.resolve()
|
||||
|
||||
# Reject malformed model columns before any write: load_models normalises
|
||||
# (drops non-list / non-dict entries), and _apply_v1_to_v2 then unlinks the
|
||||
# v1 source. Abort in preflight so upgrade cannot silently discard data.
|
||||
_reject_malformed_v1_model_columns(project_path)
|
||||
|
||||
# Models: flat files → directories
|
||||
models = _load_models_v1(project_path)
|
||||
for model in models:
|
||||
|
||||
@@ -52,12 +52,19 @@ def _data_mode_guidance(data_mode: str) -> str:
|
||||
|
||||
|
||||
def _format_model_inventory(models: list[dict]) -> str:
|
||||
"""One markdown bullet per model with its column names."""
|
||||
"""One markdown bullet per model with its column names.
|
||||
|
||||
Callers pass models from ``load_models`` (``columns`` already
|
||||
``list[dict]``). Coerce each ``name`` with ``str(...)`` so a non-string
|
||||
YAML name (e.g. ``name: 3``) cannot crash ``", ".join``.
|
||||
"""
|
||||
if not models:
|
||||
return "- (no models found — run `wren context build` first)"
|
||||
lines = []
|
||||
for model in models:
|
||||
cols = ", ".join(c.get("name", "?") for c in model.get("columns", []))
|
||||
raw_cols = model.get("columns") or []
|
||||
names = [str(c.get("name", "?")) for c in raw_cols]
|
||||
cols = ", ".join(names)
|
||||
lines.append(f"- **{model.get('name', '?')}**: {cols}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -1391,9 +1391,7 @@ def test_plan_upgrade_v1_to_v2_requires_portable_path_component(tmp_path, entity
|
||||
|
||||
|
||||
@pytest.mark.parametrize("statement", [42, 0])
|
||||
def test_plan_upgrade_v1_to_v2_rejects_non_string_view_statement(
|
||||
tmp_path, statement
|
||||
):
|
||||
def test_plan_upgrade_v1_to_v2_rejects_non_string_view_statement(tmp_path, statement):
|
||||
_make_v1_project(tmp_path)
|
||||
_set_v1_view_statement(tmp_path, statement)
|
||||
source_contents = _snapshot_v1_sources(tmp_path)
|
||||
@@ -1605,6 +1603,234 @@ def test_validate_project_reports_non_list_v1_views_container(
|
||||
assert not (tmp_path / "views").exists()
|
||||
|
||||
|
||||
# ── Regression: model columns non-list / non-dict entries ─────────────────
|
||||
|
||||
|
||||
def test_load_models_v2_normalises_non_list_columns(tmp_path):
|
||||
"""columns: scalar is dropped to [] by the loader."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"name: orders\ntable_reference:\n table: orders\ncolumns: id, customer_id\n"
|
||||
)
|
||||
models = load_models(tmp_path)
|
||||
assert len(models) == 1
|
||||
assert models[0]["columns"] == []
|
||||
|
||||
|
||||
def test_load_models_v2_omits_missing_columns_key(tmp_path):
|
||||
"""Absent columns key stays absent (no empty list injection)."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text("name: orders\ntable_reference:\n table: orders\n")
|
||||
models = load_models(tmp_path)
|
||||
assert len(models) == 1
|
||||
assert "columns" not in models[0]
|
||||
|
||||
|
||||
def test_load_models_v2_drops_non_dict_column_entries(tmp_path):
|
||||
"""Bare string / junk column entries are not coerced to column names."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "customers"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"name: customers\n"
|
||||
"table_reference:\n table: customers\n"
|
||||
"columns:\n"
|
||||
" - name: id\n type: INTEGER\n"
|
||||
" - bare\n"
|
||||
" - 3\n"
|
||||
)
|
||||
models = load_models(tmp_path)
|
||||
assert len(models) == 1
|
||||
assert models[0]["columns"] == [{"name": "id", "type": "INTEGER"}]
|
||||
|
||||
|
||||
def test_validate_project_reports_non_list_model_columns(tmp_path):
|
||||
"""validate_project reports hand-edited non-list columns (loader already empty)."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"name: orders\ntable_reference:\n table: orders\ncolumns: id, customer_id\n"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
msgs = [f"{e.path}: {e.message}" for e in errors]
|
||||
assert any("must be a list, got str" in m for m in msgs), msgs
|
||||
assert any("models/orders/metadata.yml > orders > columns" in m for m in msgs), msgs
|
||||
|
||||
|
||||
def test_validate_project_reports_null_model_columns(tmp_path):
|
||||
"""Explicit `columns:` (YAML null) is present and non-list — report it."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"name: orders\ntable_reference:\n table: orders\ncolumns:\n"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
msgs = [f"{e.path}: {e.message}" for e in errors]
|
||||
assert any("columns" in m and "must be a list, got NoneType" in m for m in msgs), (
|
||||
msgs
|
||||
)
|
||||
|
||||
|
||||
def test_validate_project_reports_non_dict_column_entries(tmp_path):
|
||||
"""Bare-string column entries must error, not vanish silently."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"name: orders\n"
|
||||
"table_reference:\n table: orders\n"
|
||||
"columns:\n"
|
||||
" - name: id\n type: INTEGER\n"
|
||||
" - bare_column\n"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
msgs = [f"{e.path}: {e.message}" for e in errors]
|
||||
assert any(
|
||||
"columns[1]" in m and "column entry must be an object" in m for m in msgs
|
||||
), msgs
|
||||
|
||||
|
||||
def test_validate_project_duplicate_model_names_do_not_crosswire_columns(tmp_path):
|
||||
"""Duplicate model names must not share/steal each other's raw column lists."""
|
||||
_make_v2_project(tmp_path)
|
||||
for dirname, columns_yaml, clean in (
|
||||
(
|
||||
"a",
|
||||
" - bare_junk\n - type: INTEGER\n - name: ok\n type: INT\n",
|
||||
False,
|
||||
),
|
||||
(
|
||||
"b",
|
||||
" - name: only\n type: INT\n",
|
||||
True,
|
||||
),
|
||||
):
|
||||
d = tmp_path / "models" / dirname
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
f"name: orders\ntable_reference:\n table: orders\ncolumns:\n{columns_yaml}"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
diagnostics = [f"{e.path}: {e.message}" for e in errors]
|
||||
assert any("duplicate model name" in d for d in diagnostics), diagnostics
|
||||
# Real errors on a/ must remain.
|
||||
assert any(
|
||||
"models/a/metadata.yml" in d
|
||||
and "columns[0]" in d
|
||||
and "column entry must be an object" in d
|
||||
for d in diagnostics
|
||||
), diagnostics
|
||||
assert any(
|
||||
"models/a/metadata.yml" in d
|
||||
and "columns[1]" in d
|
||||
and "column missing 'name'" in d
|
||||
for d in diagnostics
|
||||
), diagnostics
|
||||
# No phantom column errors against clean b/.
|
||||
b_col_errs = [
|
||||
d
|
||||
for d in diagnostics
|
||||
if "models/b/metadata.yml" in d
|
||||
and ("column entry must be an object" in d or "column missing 'name'" in d)
|
||||
]
|
||||
assert b_col_errs == [], b_col_errs
|
||||
|
||||
|
||||
def test_plan_upgrade_v1_to_v2_rejects_malformed_model_columns(tmp_path):
|
||||
"""v1→v2 must abort before discarding non-list / non-object columns."""
|
||||
_make_v1_project(tmp_path)
|
||||
models_dir = tmp_path / "models"
|
||||
(models_dir / "orders.yml").write_text(
|
||||
"name: orders\ntable_reference:\n table: orders\ncolumns: id, customer_id\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(models_dir / "items.yml").write_text(
|
||||
"name: items\n"
|
||||
"table_reference:\n table: items\n"
|
||||
"columns:\n"
|
||||
" - name: id\n type: INTEGER\n"
|
||||
" - bare_column\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
source_contents = _snapshot_v1_sources(tmp_path)
|
||||
|
||||
from wren.context import UpgradeError as _UE # noqa: PLC0415
|
||||
|
||||
with pytest.raises(_UE, match="malformed model columns"):
|
||||
plan_upgrade(tmp_path, target_version=2)
|
||||
|
||||
_assert_v1_sources_unchanged(tmp_path, source_contents)
|
||||
# Source content still intact (not normalised away).
|
||||
assert "id, customer_id" in (models_dir / "orders.yml").read_text(encoding="utf-8")
|
||||
assert "bare_column" in (models_dir / "items.yml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_validate_project_column_indices_match_file(tmp_path):
|
||||
"""Junk at [0] must not renumber a later unnamed column's error."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"name: orders\n"
|
||||
"table_reference:\n table: orders\n"
|
||||
"columns:\n"
|
||||
" - bare_junk\n"
|
||||
" - type: INTEGER\n"
|
||||
" - name: amt\n type: DOUBLE\n"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
diagnostics = [f"{e.path}: {e.message}" for e in errors]
|
||||
assert any(
|
||||
"columns[0]" in d and "column entry must be an object" in d for d in diagnostics
|
||||
), diagnostics
|
||||
assert any(
|
||||
"columns[1]" in d and "column missing 'name'" in d for d in diagnostics
|
||||
), diagnostics
|
||||
# Must not report missing-name against the renumbered filtered index 0.
|
||||
missing_name_paths = [d for d in diagnostics if "column missing 'name'" in d]
|
||||
assert all("columns[0]" not in d for d in missing_name_paths), missing_name_paths
|
||||
|
||||
|
||||
def test_validate_project_v1_model_paths_use_flat_yml(tmp_path):
|
||||
"""v1 errors should label models/<stem>.yml, not models/<stem>/metadata.yml."""
|
||||
(tmp_path / "wren_project.yml").write_text("schema_version: 1\n", encoding="utf-8")
|
||||
models_dir = tmp_path / "models"
|
||||
models_dir.mkdir()
|
||||
(models_dir / "orders.yml").write_text(
|
||||
"name: orders\ncolumns: id, customer_id\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
diagnostics = [f"{e.path} {e.message}" for e in errors]
|
||||
assert any(
|
||||
"models/orders.yml > orders > columns" in d and "must be a list" in d
|
||||
for d in diagnostics
|
||||
), diagnostics
|
||||
assert not any("models/orders/metadata.yml" in d for d in diagnostics), diagnostics
|
||||
|
||||
|
||||
def test_validate_project_uses_dir_name_when_model_name_missing(tmp_path):
|
||||
"""v2 error path should use models/<dir>, not stem 'metadata'."""
|
||||
_make_v2_project(tmp_path)
|
||||
d = tmp_path / "models" / "orders"
|
||||
d.mkdir(parents=True)
|
||||
(d / "metadata.yml").write_text(
|
||||
"table_reference:\n table: orders\ncolumns: not-a-list\n"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
msgs = [f"{e.path}: {e.message}" for e in errors]
|
||||
assert any(
|
||||
"models/orders/metadata.yml > orders > columns" in m and "must be a list" in m
|
||||
for m in msgs
|
||||
), msgs
|
||||
|
||||
|
||||
def test_build_manifest_drops_v1_views_yml_non_mapping_entries(tmp_path):
|
||||
_make_v1_project(tmp_path)
|
||||
_corrupt_v1_views_yml(tmp_path)
|
||||
@@ -1991,7 +2217,9 @@ def test_validate_project_reports_non_dict_relationship_entries(tmp_path: Path)
|
||||
|
||||
def test_validate_project_reports_relationships_not_list(tmp_path: Path) -> None:
|
||||
(tmp_path / "wren_project.yml").write_text("schema_version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "relationships.yml").write_text("relationships: nope\n", encoding="utf-8")
|
||||
(tmp_path / "relationships.yml").write_text(
|
||||
"relationships: nope\n", encoding="utf-8"
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
msgs = [e.message for e in errors]
|
||||
assert any("'relationships' must be a list, got str" in m for m in msgs)
|
||||
@@ -2015,16 +2243,11 @@ def test_validate_project_relationship_indices_match_file(tmp_path: Path) -> Non
|
||||
"""Junk at [0] must not renumber a later unnamed relationship's warnings."""
|
||||
(tmp_path / "wren_project.yml").write_text("schema_version: 1\n", encoding="utf-8")
|
||||
(tmp_path / "relationships.yml").write_text(
|
||||
"relationships:\n"
|
||||
" - 42\n"
|
||||
" - models: [a, b]\n"
|
||||
" condition: a.id = b.id\n",
|
||||
"relationships:\n - 42\n - models: [a, b]\n condition: a.id = b.id\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors = validate_project(tmp_path)
|
||||
diagnostics = [
|
||||
f"{getattr(e, 'path', '')} {e.message}" for e in errors
|
||||
]
|
||||
diagnostics = [f"{getattr(e, 'path', '')} {e.message}" for e in errors]
|
||||
assert any("got int" in diagnostic for diagnostic in diagnostics)
|
||||
assert any(
|
||||
"relationships[1]" in diagnostic and "join_type" in diagnostic
|
||||
|
||||
@@ -94,6 +94,33 @@ def test_build_includes_model_inventory(tmp_path: Path) -> None:
|
||||
assert "duckdb" in result.output
|
||||
|
||||
|
||||
def test_format_inventory_stringifies_non_string_column_names() -> None:
|
||||
"""Non-string column names (YAML ``name: 3``) must not crash join."""
|
||||
from wren.genbi.composer import _format_model_inventory
|
||||
|
||||
models = [{"name": "customers", "columns": [{"name": 3}, {"name": "id"}]}]
|
||||
assert _format_model_inventory(models) == "- **customers**: 3, id"
|
||||
|
||||
|
||||
def test_build_inventory_stringifies_non_string_column_names(tmp_path: Path) -> None:
|
||||
"""compose_build_instruction survives non-string column names."""
|
||||
from wren.genbi.composer import compose_build_instruction
|
||||
|
||||
models = [
|
||||
{"name": "customers", "columns": [{"name": 3}, {"name": "id"}]},
|
||||
]
|
||||
text = compose_build_instruction(
|
||||
app_name="demo",
|
||||
data_mode="snapshot",
|
||||
user_prompt="hi",
|
||||
mdl_path=tmp_path / "mdl.json",
|
||||
app_dir=tmp_path / "app",
|
||||
models=models,
|
||||
data_source="duckdb",
|
||||
)
|
||||
assert "- **customers**: 3, id" in text
|
||||
|
||||
|
||||
def test_build_includes_wasm_wiring_and_final_steps(tmp_path: Path) -> None:
|
||||
project = _make_project(tmp_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user