Skip to content

Commit 5151bdb

Browse files
ross-whatnotclaudematthieucan
authored
Fix JSON formatter name collision issue (#129)
## Summary - Fix JSON formatter to use `unique_id` instead of `name` as dictionary keys - Prevents evaluables of different types with the same name from overwriting each other - Adds comprehensive test case for name collision scenario - Updates existing tests to match new output format ## Problem The JSON formatter was using `evaluable.name` as the dictionary key, which caused issues when evaluables of different types (e.g., a model and an exposure) had the same name. The second evaluable processed would overwrite the first in the JSON output. ## Solution Changed the JSON formatter to use `evaluable.unique_id` (e.g., `model.package.model_name`, `exposure.package.exposure_name`) instead of just the name. This approach: - Ensures unique keys for all evaluables regardless of name collisions - Makes the JSON formatter consistent with the Manifest formatter behavior - Preserves all evaluables in the output ## Changes - Modified `src/dbt_score/formatters/json_formatter.py` to use `unique_id` as keys - Updated docstring examples to reflect the new key format - Updated existing tests in `tests/formatters/test_json_formatter.py` - Added new test case `test_json_formatter_name_collision_prevention` to verify the fix ## Test plan - [x] Existing JSON formatter tests pass with updated expected output - [x] New test case specifically validates name collision prevention - [x] All linting and type checking passes - [x] Pre-commit hooks pass 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Matthieu Caneill <[email protected]>
1 parent 500fb7d commit 5151bdb

File tree

6 files changed

+163
-11
lines changed

6 files changed

+163
-11
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to
99
## [Unreleased]
1010

1111
- Display the parse error message when `dbt parse` fails.
12+
- **Breaking**: JSON-formatted output is using `unique_id` as key instead of
13+
`name`, to avoid duplicates (e.g if exposure and model have the same name).
1214

1315
## [0.13.1] - 2025-07-29
1416

src/dbt_score/formatters/json_formatter.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
```json
66
{
77
"evaluables": {
8-
"model_foo": {
8+
"model.package.model_foo": {
99
"score": 5.0,
1010
"badge": "🥈",
1111
"pass": true,
@@ -23,7 +23,7 @@
2323
},
2424
"type": "model"
2525
},
26-
"model_bar": {
26+
"model.package.model_bar": {
2727
"score": 0.0,
2828
"badge": "🥉",
2929
"pass": false,
@@ -35,7 +35,7 @@
3535
},
3636
"type": "model"
3737
},
38-
"source_baz": {
38+
"source.package.source_name.source_baz": {
3939
"score": 10.0,
4040
"badge": "🥇",
4141
"pass": false,
@@ -80,7 +80,7 @@ def evaluable_evaluated(
8080
self, evaluable: Evaluable, results: EvaluableResultsType, score: Score
8181
) -> None:
8282
"""Callback when an evaluable item has been evaluated."""
83-
self.evaluable_results[evaluable.name] = {
83+
self.evaluable_results[evaluable.unique_id] = {
8484
"score": score.value,
8585
"badge": score.badge,
8686
"pass": score.value >= self._config.fail_any_item_under,
@@ -90,19 +90,25 @@ def evaluable_evaluated(
9090
for rule, result in results.items():
9191
severity = rule.severity.name.lower()
9292
if result is None:
93-
self.evaluable_results[evaluable.name]["results"][rule.source()] = {
93+
self.evaluable_results[evaluable.unique_id]["results"][
94+
rule.source()
95+
] = {
9496
"result": "OK",
9597
"severity": severity,
9698
"message": None,
9799
}
98100
elif isinstance(result, RuleViolation):
99-
self.evaluable_results[evaluable.name]["results"][rule.source()] = {
101+
self.evaluable_results[evaluable.unique_id]["results"][
102+
rule.source()
103+
] = {
100104
"result": "WARN",
101105
"severity": severity,
102106
"message": result.message,
103107
}
104108
else:
105-
self.evaluable_results[evaluable.name]["results"][rule.source()] = {
109+
self.evaluable_results[evaluable.unique_id]["results"][
110+
rule.source()
111+
] = {
106112
"result": "ERR",
107113
"severity": severity,
108114
"message": str(result),

tests/conftest.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ def model2(raw_manifest) -> Model:
8383
return Model.from_node(raw_manifest["nodes"]["model.package.model2"], [])
8484

8585

86+
@fixture
87+
def model_collision_test(raw_manifest) -> Model:
88+
"""Model with collision_test name."""
89+
return Model.from_node(raw_manifest["nodes"]["model.package.collision_test"], [])
90+
91+
8692
# Sources
8793

8894

@@ -123,13 +129,21 @@ def snapshot2(raw_manifest) -> Snapshot:
123129
@fixture
124130
def exposure1(raw_manifest) -> Exposure:
125131
"""Exposure 1."""
126-
return Exposure.from_node(raw_manifest["nodes"]["exposure.package.exposure1"])
132+
return Exposure.from_node(raw_manifest["exposures"]["exposure.package.exposure1"])
127133

128134

129135
@fixture
130136
def exposure2(raw_manifest) -> Exposure:
131137
"""Exposure 2."""
132-
return Exposure.from_node(raw_manifest["nodes"]["exposure.package.exposure2"])
138+
return Exposure.from_node(raw_manifest["exposures"]["exposure.package.exposure2"])
139+
140+
141+
@fixture
142+
def exposure_collision(raw_manifest) -> Exposure:
143+
"""Exposure with collision_test name."""
144+
return Exposure.from_node(
145+
raw_manifest["exposures"]["exposure.package.exposure_collision"]
146+
)
133147

134148

135149
# Multiple ways to create rules

tests/formatters/test_json_formatter.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for the JSON formatter."""
22

3+
import json
34
from typing import Type
45

56
from dbt_score.formatters.json_formatter import JSONFormatter
@@ -32,7 +33,7 @@ def test_json_formatter(
3233
stdout
3334
== """{
3435
"evaluables": {
35-
"model1": {
36+
"model.package.model1": {
3637
"score": 10.0,
3738
"badge": "🥇",
3839
"pass": true,
@@ -55,7 +56,7 @@ def test_json_formatter(
5556
},
5657
"type": "model"
5758
},
58-
"table1": {
59+
"source.package.my_source.table1": {
5960
"score": 10.0,
6061
"badge": "🥇",
6162
"pass": true,
@@ -87,3 +88,50 @@ def test_json_formatter(
8788
}
8889
"""
8990
)
91+
92+
93+
def test_json_formatter_name_collision_prevention(
94+
capsys,
95+
default_config,
96+
manifest_loader,
97+
model_collision_test,
98+
exposure_collision,
99+
rule_severity_medium,
100+
):
101+
"""Ensure evaluables with same name but different types don't overwrite."""
102+
# Verify they have the same name but different unique_ids
103+
assert model_collision_test.name == exposure_collision.name == "collision_test"
104+
assert model_collision_test.unique_id != exposure_collision.unique_id
105+
106+
formatter = JSONFormatter(manifest_loader=manifest_loader, config=default_config)
107+
results: dict[Type[Rule], RuleViolation | Exception | None] = {
108+
rule_severity_medium: RuleViolation("Test violation")
109+
}
110+
111+
# Evaluate both evaluables with same name
112+
formatter.evaluable_evaluated(model_collision_test, results, Score(5.0, "🥈"))
113+
formatter.evaluable_evaluated(exposure_collision, results, Score(7.0, "🥇"))
114+
formatter.project_evaluated(Score(6.0, "🥈"))
115+
116+
stdout = capsys.readouterr().out
117+
output_data = json.loads(stdout)
118+
119+
# Both evaluables should be present (no collision)
120+
evaluables = output_data["evaluables"]
121+
assert len(evaluables) == 2, "Both evaluables with same name should be preserved"
122+
123+
# Keys should be unique_id, not name
124+
assert model_collision_test.unique_id in evaluables
125+
assert exposure_collision.unique_id in evaluables
126+
127+
# Verify both evaluables maintain their distinct data
128+
model_data = evaluables[model_collision_test.unique_id]
129+
exposure_data = evaluables[exposure_collision.unique_id]
130+
131+
assert model_data["type"] == "model"
132+
assert model_data["score"] == 5.0
133+
assert model_data["badge"] == "🥈"
134+
135+
assert exposure_data["type"] == "exposure"
136+
assert exposure_data["score"] == 7.0
137+
assert exposure_data["badge"] == "🥇"

tests/resources/manifest.json

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,40 @@
141141
"access": "public",
142142
"group": "them_over_there"
143143
},
144+
"model.package.collision_test": {
145+
"resource_type": "model",
146+
"unique_id": "model.package.collision_test",
147+
"name": "collision_test",
148+
"relation_name": "database.schema.collision_test",
149+
"description": "A model to test name collision with exposure.",
150+
"original_file_path": "/path/to/collision_test.sql",
151+
"config": {},
152+
"meta": {},
153+
"columns": {
154+
"a": {
155+
"name": "column_a",
156+
"description": "Column A.",
157+
"data_type": "string",
158+
"meta": {},
159+
"constraints": [],
160+
"tags": []
161+
}
162+
},
163+
"constraints": [],
164+
"package_name": "package",
165+
"database": "db",
166+
"schema": "schema",
167+
"raw_code": "SELECT x FROM y",
168+
"alias": "collision_test_alias",
169+
"patch_path": "/path/to/collision_test.yml",
170+
"tags": [],
171+
"depends_on": {
172+
"nodes": ["model.package.model2"]
173+
},
174+
"language": "sql",
175+
"access": "public",
176+
"group": "default"
177+
},
144178
"model.package2.model1": {
145179
"resource_type": "model",
146180
"unique_id": "model.package2.model1",
@@ -520,6 +554,44 @@
520554
"sources": [],
521555
"metrics": [],
522556
"created_at": 1744832856.199685
557+
},
558+
"exposure.package.exposure_collision": {
559+
"name": "collision_test",
560+
"resource_type": "exposure",
561+
"package_name": "package",
562+
"path": "models/exposures/exposures.yml",
563+
"original_file_path": "models/exposures/exposures.yml",
564+
"unique_id": "exposure.package.exposure_collision",
565+
"fqn": ["package", "exposures", "exposure_collision"],
566+
"type": "application",
567+
"owner": {
568+
"email": null,
569+
"name": "owner"
570+
},
571+
"description": "This exposure has the same name as collision_test model.",
572+
"label": null,
573+
"maturity": null,
574+
"meta": {},
575+
"tags": ["collision", "test"],
576+
"config": {
577+
"enabled": true
578+
},
579+
"unrendered_config": {},
580+
"url": null,
581+
"depends_on": {
582+
"macros": [],
583+
"nodes": ["model.package.collision_test"]
584+
},
585+
"refs": [
586+
{
587+
"name": "collision_test",
588+
"package": null,
589+
"version": null
590+
}
591+
],
592+
"sources": [],
593+
"metrics": [],
594+
"created_at": 1744832856.199685
523595
}
524596
}
525597
}

tests/test_models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def test_manifest_load(mock_read_text, raw_manifest):
5151
]
5252
assert loader.models["model.package.model2"].children == [
5353
loader.models["model.package.model1"],
54+
loader.models["model.package.collision_test"],
5455
loader.exposures["exposure.package.exposure2"],
5556
]
5657
assert loader.models["model.package.model2"].parents == [
@@ -67,9 +68,18 @@ def test_manifest_load(mock_read_text, raw_manifest):
6768
loader.models["model.package.model2"]
6869
]
6970

71+
assert loader.models["model.package.collision_test"].parents == [
72+
loader.models["model.package.model2"]
73+
]
74+
assert loader.models["model.package.collision_test"].children == [
75+
loader.exposures["exposure.package.exposure_collision"],
76+
]
7077
assert loader.exposures["exposure.package.exposure1"].parents == [
7178
loader.models["model.package.model1"]
7279
]
80+
assert loader.exposures["exposure.package.exposure_collision"].parents == [
81+
loader.models["model.package.collision_test"]
82+
]
7383

7484

7585
@patch("dbt_score.models.Path.read_text")

0 commit comments

Comments
 (0)