diff --git a/core/wren/src/wren/context.py b/core/wren/src/wren/context.py index 31df239bfd..7e0d65f975 100644 --- a/core/wren/src/wren/context.py +++ b/core/wren/src/wren/context.py @@ -965,6 +965,15 @@ def validate_project(project_path: Path) -> list[ValidationError]: src = model.get("_source_dir", f"models[{i}]") src_path = f"models/{src}/metadata.yml" name = model.get("name") + if isinstance(name, (list, dict)): + errors.append( + ValidationError( + "error", + src_path, + f"model 'name' must be a scalar value, got {type(name).__name__}", + ) + ) + continue if not name: errors.append(ValidationError("error", src_path, "model missing 'name'")) continue @@ -1213,6 +1222,15 @@ def validate_project(project_path: Path) -> list[ValidationError]: for i, view in enumerate(views): src_dir = view.get("_source_dir", f"views[{i}]") name = view.get("name") + if isinstance(name, (list, dict)): + errors.append( + ValidationError( + "error", + f"views/{src_dir}/metadata.yml", + f"view 'name' must be a scalar value, got {type(name).__name__}", + ) + ) + continue if not name: errors.append( ValidationError( diff --git a/core/wren/tests/unit/test_context.py b/core/wren/tests/unit/test_context.py index 49f45ba666..0d20b35155 100644 --- a/core/wren/tests/unit/test_context.py +++ b/core/wren/tests/unit/test_context.py @@ -555,6 +555,75 @@ def test_validate_duplicate_model(tmp_path): assert any("duplicate model name" in e.message for e in errors) +def test_validate_model_name_list_reports_error(tmp_path): + _make_v2_project(tmp_path) + d = tmp_path / "models" / "orders" + d.mkdir(parents=True) + (d / "metadata.yml").write_text( + "name: [a, b]\ntable_reference:\n table: orders\ncolumns: []\n" + ) + errors = validate_project(tmp_path) + assert any( + "model 'name' must be a scalar value, got list" in e.message for e in errors + ) + + +def test_validate_model_name_dict_reports_error(tmp_path): + _make_v2_project(tmp_path) + d = tmp_path / "models" / "orders" + d.mkdir(parents=True) + (d / "metadata.yml").write_text( + "name: {x: 1}\ntable_reference:\n table: orders\ncolumns: []\n" + ) + errors = validate_project(tmp_path) + assert any( + "model 'name' must be a scalar value, got dict" in e.message for e in errors + ) + + +def test_validate_model_name_int_is_not_rejected(tmp_path): + # An int name is unusual but hashable, so it never hit the crash this guard + # exists for; the guard must not turn it into a new validation error. + _make_v2_project(tmp_path) + d = tmp_path / "models" / "orders" + d.mkdir(parents=True) + (d / "metadata.yml").write_text( + "name: 3\ntable_reference:\n table: orders\ncolumns: []\n" + ) + errors = validate_project(tmp_path) + assert not any("must be a scalar value" in e.message for e in errors) + + +def test_validate_model_name_empty_list_reports_scalar_error_not_missing(tmp_path): + # An empty list is falsy, so the type guard must run before the missing-name + # check or this reports "missing 'name'" instead of the malformed type. + _make_v2_project(tmp_path) + d = tmp_path / "models" / "orders" + d.mkdir(parents=True) + (d / "metadata.yml").write_text( + "name: []\ntable_reference:\n table: orders\ncolumns: []\n" + ) + errors = validate_project(tmp_path) + assert any( + "model 'name' must be a scalar value, got list" in e.message for e in errors + ) + assert not any("model missing 'name'" in e.message for e in errors) + + +def test_validate_model_name_empty_dict_reports_scalar_error_not_missing(tmp_path): + _make_v2_project(tmp_path) + d = tmp_path / "models" / "orders" + d.mkdir(parents=True) + (d / "metadata.yml").write_text( + "name: {}\ntable_reference:\n table: orders\ncolumns: []\n" + ) + errors = validate_project(tmp_path) + assert any( + "model 'name' must be a scalar value, got dict" in e.message for e in errors + ) + assert not any("model missing 'name'" in e.message for e in errors) + + def test_validate_both_tref_and_ref_sql(tmp_path): _make_v2_project(tmp_path) d = tmp_path / "models" / "conflict" @@ -682,6 +751,54 @@ def test_validate_view_no_statement(tmp_path): assert any("missing 'statement'" in e.message for e in errors) +def test_validate_view_name_list_reports_error(tmp_path): + _make_v2_project(tmp_path) + d = tmp_path / "views" / "monthly" + d.mkdir(parents=True) + (d / "metadata.yml").write_text("name: [a, b]\nstatement: SELECT 1\n") + errors = validate_project(tmp_path) + assert any( + "view 'name' must be a scalar value, got list" in e.message for e in errors + ) + + +def test_validate_view_name_dict_reports_error(tmp_path): + _make_v2_project(tmp_path) + d = tmp_path / "views" / "monthly" + d.mkdir(parents=True) + (d / "metadata.yml").write_text("name: {x: 1}\nstatement: SELECT 1\n") + errors = validate_project(tmp_path) + assert any( + "view 'name' must be a scalar value, got dict" in e.message for e in errors + ) + + +def test_validate_view_name_empty_list_reports_scalar_error_not_missing(tmp_path): + # An empty list is falsy, so the type guard must run before the missing-name + # check or this reports "missing 'name'" instead of the malformed type. + _make_v2_project(tmp_path) + d = tmp_path / "views" / "monthly" + d.mkdir(parents=True) + (d / "metadata.yml").write_text("name: []\nstatement: SELECT 1\n") + errors = validate_project(tmp_path) + assert any( + "view 'name' must be a scalar value, got list" in e.message for e in errors + ) + assert not any("view missing 'name'" in e.message for e in errors) + + +def test_validate_view_name_empty_dict_reports_scalar_error_not_missing(tmp_path): + _make_v2_project(tmp_path) + d = tmp_path / "views" / "monthly" + d.mkdir(parents=True) + (d / "metadata.yml").write_text("name: {}\nstatement: SELECT 1\n") + errors = validate_project(tmp_path) + assert any( + "view 'name' must be a scalar value, got dict" in e.message for e in errors + ) + assert not any("view missing 'name'" in e.message for e in errors) + + def test_validate_missing_join_type(tmp_path): _make_valid_project(tmp_path) (tmp_path / "relationships.yml").write_text(