Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions core/dbt/contracts/graph/unparsed.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,19 @@ class UnparsedSemanticModelConfig(dbtClassMixin):
group: Optional[str] = None
config: Optional[UnparsedSemanticResourceConfig] = None

@classmethod
@override
def validate(cls, data: Any) -> None:
if isinstance(data, dict):
allowed = set(cls.__dataclass_fields__.keys())
extra = set(data.keys()) - allowed
if extra:
raise ValidationError(
f"Unknown field(s) in semantic_model config: {', '.join(sorted(extra))}. "
f"Valid fields are: {', '.join(sorted(allowed))}."
)
super().validate(data)


@dataclass
class UnparsedModelUpdate(UnparsedNodeUpdate):
Expand All @@ -570,6 +583,18 @@ class UnparsedModelUpdate(UnparsedNodeUpdate):
metrics: Optional[List[UnparsedMetricV2]] = None
derived_semantics: Optional[UnparsedDerivedSemantics] = None

@classmethod
@override
def validate(cls, data: Any) -> None:
# Validate the semantic_model sub-object before the full JSON Schema runs so
# that unknown fields produce a clear error instead of the opaque JSON Schema
# message "is not valid under any of the given schemas".
if isinstance(data, dict):
sm = data.get("semantic_model")
if isinstance(sm, dict):
UnparsedSemanticModelConfig.validate(sm)
super().validate(data)

def __post_init__(self) -> None:
if self.latest_version:
version_values = [version.v for version in self.versions]
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/contracts/graph/test_unparsed.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
UnparsedNode,
UnparsedNodeUpdate,
UnparsedRunHook,
UnparsedSemanticModelConfig,
UnparsedSourceDefinition,
UnparsedSourceTableDefinition,
UnparsedVersion,
Expand Down Expand Up @@ -807,6 +808,31 @@ def test_bad_test_type(self):
self.assert_fails_validation(dct)


class TestUnparsedSemanticModelConfig(ContractTestCase):
ContractType = UnparsedSemanticModelConfig

def test_valid_config_passes(self):
self.ContractType.validate({"name": "purchases", "enabled": True})

def test_extra_field_gives_clear_error(self):
self.assert_fails_validation_with_message(
{"enabled": True, "name": "purchases", "description": "my semantic model"},
"Unknown field(s) in semantic_model config: description",
)

def test_extra_field_error_lists_valid_fields(self):
self.assert_fails_validation_with_message(
{"enabled": True, "unexpected_key": "value"},
"Valid fields are:",
)

def test_multiple_extra_fields_all_listed(self):
self.assert_fails_validation_with_message(
{"enabled": True, "description": "oops", "label": "also wrong"},
"Unknown field(s) in semantic_model config: description, label",
)


class TestUnparsedExposure(ContractTestCase):
ContractType = UnparsedExposure

Expand Down
14 changes: 14 additions & 0 deletions tests/unit/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,20 @@ def assert_fails_validation(self, dct, cls=None):
cls.validate(dct)
cls.from_dict(dct)

def assert_fails_validation_with_message(self, dct, expected_message, cls=None):
"""Assert that validation fails and the error message contains expected_message.

Use this instead of assert_fails_validation when you want to verify that
the user-facing error is actionable (e.g. names the offending field).
"""
if cls is None:
cls = self.ContractType

with self.assertRaises(ValidationError) as ctx:
cls.validate(dct)
cls.from_dict(dct)
self.assertIn(expected_message, str(ctx.exception))


def compare_dicts(dict1, dict2):
first_set = set(dict1.keys())
Expand Down
Loading