Skip to content

Commit 937554d

Browse files
Improve error message for unknown fields in semantic_model config
When a user adds an unknown field (e.g. description) inside the semantic_model: config object, replace the opaque JSON Schema error "is not valid under any of the given schemas" with a clear message naming the offending field and listing the valid ones. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eee9587 commit 937554d

File tree

3 files changed

+65
-0
lines changed

3 files changed

+65
-0
lines changed

core/dbt/contracts/graph/unparsed.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,19 @@ class UnparsedSemanticModelConfig(dbtClassMixin):
551551
group: Optional[str] = None
552552
config: Optional[UnparsedSemanticResourceConfig] = None
553553

554+
@classmethod
555+
@override
556+
def validate(cls, data: Any) -> None:
557+
if isinstance(data, dict):
558+
allowed = set(cls.__dataclass_fields__.keys())
559+
extra = set(data.keys()) - allowed
560+
if extra:
561+
raise ValidationError(
562+
f"Unknown field(s) in semantic_model config: {', '.join(sorted(extra))}. "
563+
f"Valid fields are: {', '.join(sorted(allowed))}."
564+
)
565+
super().validate(data)
566+
554567

555568
@dataclass
556569
class UnparsedModelUpdate(UnparsedNodeUpdate):
@@ -570,6 +583,18 @@ class UnparsedModelUpdate(UnparsedNodeUpdate):
570583
metrics: Optional[List[UnparsedMetricV2]] = None
571584
derived_semantics: Optional[UnparsedDerivedSemantics] = None
572585

586+
@classmethod
587+
@override
588+
def validate(cls, data: Any) -> None:
589+
# Validate the semantic_model sub-object before the full JSON Schema runs so
590+
# that unknown fields produce a clear error instead of the opaque JSON Schema
591+
# message "is not valid under any of the given schemas".
592+
if isinstance(data, dict):
593+
sm = data.get("semantic_model")
594+
if isinstance(sm, dict):
595+
UnparsedSemanticModelConfig.validate(sm)
596+
super().validate(data)
597+
573598
def __post_init__(self) -> None:
574599
if self.latest_version:
575600
version_values = [version.v for version in self.versions]

tests/unit/contracts/graph/test_unparsed.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
UnparsedNode,
3434
UnparsedNodeUpdate,
3535
UnparsedRunHook,
36+
UnparsedSemanticModelConfig,
3637
UnparsedSourceDefinition,
3738
UnparsedSourceTableDefinition,
3839
UnparsedVersion,
@@ -807,6 +808,31 @@ def test_bad_test_type(self):
807808
self.assert_fails_validation(dct)
808809

809810

811+
class TestUnparsedSemanticModelConfig(ContractTestCase):
812+
ContractType = UnparsedSemanticModelConfig
813+
814+
def test_valid_config_passes(self):
815+
self.ContractType.validate({"name": "purchases", "enabled": True})
816+
817+
def test_extra_field_gives_clear_error(self):
818+
self.assert_fails_validation_with_message(
819+
{"enabled": True, "name": "purchases", "description": "my semantic model"},
820+
"Unknown field(s) in semantic_model config: description",
821+
)
822+
823+
def test_extra_field_error_lists_valid_fields(self):
824+
self.assert_fails_validation_with_message(
825+
{"enabled": True, "unexpected_key": "value"},
826+
"Valid fields are:",
827+
)
828+
829+
def test_multiple_extra_fields_all_listed(self):
830+
self.assert_fails_validation_with_message(
831+
{"enabled": True, "description": "oops", "label": "also wrong"},
832+
"Unknown field(s) in semantic_model config: description, label",
833+
)
834+
835+
810836
class TestUnparsedExposure(ContractTestCase):
811837
ContractType = UnparsedExposure
812838

tests/unit/utils/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ def assert_fails_validation(self, dct, cls=None):
172172
cls.validate(dct)
173173
cls.from_dict(dct)
174174

175+
def assert_fails_validation_with_message(self, dct, expected_message, cls=None):
176+
"""Assert that validation fails and the error message contains expected_message.
177+
178+
Use this instead of assert_fails_validation when you want to verify that
179+
the user-facing error is actionable (e.g. names the offending field).
180+
"""
181+
if cls is None:
182+
cls = self.ContractType
183+
184+
with self.assertRaises(ValidationError) as ctx:
185+
cls.validate(dct)
186+
cls.from_dict(dct)
187+
self.assertIn(expected_message, str(ctx.exception))
188+
175189

176190
def compare_dicts(dict1, dict2):
177191
first_set = set(dict1.keys())

0 commit comments

Comments
 (0)