Skip to content

Commit 430b68c

Browse files
committed
Make unique, user-friendly test names for custom named dbt tests
1 parent 223422f commit 430b68c

File tree

2 files changed

+151
-2
lines changed

2 files changed

+151
-2
lines changed

sqlmesh/dbt/manifest.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
extract_call_names,
6262
jinja_call_arg_name,
6363
)
64+
from sqlglot.helper import ensure_list
6465

6566
if t.TYPE_CHECKING:
6667
from dbt.contracts.graph.manifest import Macro, Manifest
@@ -353,15 +354,17 @@ def _load_tests(self) -> None:
353354
)
354355

355356
test_model = _test_model(node)
357+
node_config = _node_base_config(node)
358+
node_config["name"] = _build_test_name(node, dependencies)
356359

357360
test = TestConfig(
358361
sql=sql,
359362
model_name=test_model,
360363
test_kwargs=node.test_metadata.kwargs if hasattr(node, "test_metadata") else {},
361364
dependencies=dependencies,
362-
**_node_base_config(node),
365+
**node_config,
363366
)
364-
self._tests_per_package[node.package_name][node.name.lower()] = test
367+
self._tests_per_package[node.package_name][node.unique_id] = test
365368
if test_model:
366369
self._tests_by_owner[test_model].append(test)
367370

@@ -798,3 +801,54 @@ def _strip_jinja_materialization_tags(materialization_jinja: str) -> str:
798801
)
799802

800803
return materialization_jinja.strip()
804+
805+
806+
def _build_test_name(node: ManifestNode, dependencies: Dependencies) -> str:
807+
"""
808+
Build a user-friendly test name that includes the test's model/source, column,
809+
and args for tests with custom user names. Needed because dbt only generates these
810+
names for tests that do not specify the "name" field in their YAML definition.
811+
812+
Name structure
813+
- Model test: [namespace]_[test name]_[model name]_[column name]__[arg values]
814+
- Source test: [namespace]_source_[test name]_[source name]_[table name]_[column name]__[arg values]
815+
"""
816+
# standalone test
817+
if not hasattr(node, "test_metadata"):
818+
return node.name
819+
820+
model_name = _test_model(node)
821+
source_name = None
822+
if not model_name and dependencies.sources:
823+
# extract source and table names
824+
source_parts = list(dependencies.sources)[0].split(".")
825+
source_name = "_".join(source_parts) if len(source_parts) == 2 else source_parts[-1]
826+
entity_name = model_name or source_name or ""
827+
828+
name_prefix = ""
829+
if namespace := getattr(node.test_metadata, "namespace", None):
830+
name_prefix += f"{namespace}_"
831+
if source_name and not model_name:
832+
name_prefix += "source_"
833+
834+
name_suffix = f"_{entity_name}"
835+
if column_name := getattr(node, "column_name", None):
836+
name_suffix += f"_{column_name}"
837+
838+
metadata_kwargs = node.test_metadata.kwargs
839+
arg_val_parts = []
840+
for arg, val in sorted(metadata_kwargs.items()):
841+
if arg in ("model", "column_name"):
842+
continue
843+
if isinstance(val, dict):
844+
val = list(val.values())
845+
val = [re.sub("[^0-9a-zA-Z_]+", "_", str(v)) for v in ensure_list(val)]
846+
arg_val_parts.extend(val)
847+
arg_vals = ("__" + "__".join(arg_val_parts)) if arg_val_parts else ""
848+
849+
auto_name = f"{name_prefix}{node.test_metadata.name}{name_suffix}{arg_vals}"
850+
851+
if node.name == auto_name:
852+
return node.name
853+
854+
return f"{name_prefix}{node.name}{name_suffix}{arg_vals}"

tests/dbt/test_test.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
15
from sqlmesh.dbt.test import TestConfig
26

37

@@ -8,3 +12,94 @@ def test_multiline_test_kwarg() -> None:
812
test_kwargs={"test_field": "foo\nbar\n"},
913
)
1014
assert test._kwargs() == 'test_field="foo\nbar"'
15+
16+
17+
@pytest.mark.xdist_group("dbt_manifest")
18+
def test_duplicate_test_names_get_unique_names(tmp_path: Path, create_empty_project) -> None:
19+
"""Integration test verifying that duplicate test names are made unique via entity/arg prefixing."""
20+
from sqlmesh.utils.yaml import YAML
21+
from sqlmesh.core.context import Context
22+
23+
yaml = YAML()
24+
project_dir, model_dir = create_empty_project(project_name="local")
25+
26+
model_file = model_dir / "my_model.sql"
27+
with open(model_file, "w", encoding="utf-8") as f:
28+
f.write("SELECT 1 as id, 'value1' as status")
29+
30+
# Create schema.yml with:
31+
# 1. Same test on model and source, both with/without custom test name
32+
# 2. Same test on same model with different args, both with/without custom test name
33+
schema_yaml = {
34+
"version": 2,
35+
"sources": [
36+
{
37+
"name": "raw",
38+
"tables": [
39+
{
40+
"name": "my_source",
41+
"columns": [
42+
{
43+
"name": "id",
44+
"data_tests": [
45+
{"not_null": {"name": "custom_notnull_name"}},
46+
{"not_null": {}},
47+
],
48+
}
49+
],
50+
}
51+
],
52+
}
53+
],
54+
"models": [
55+
{
56+
"name": "my_model",
57+
"columns": [
58+
{
59+
"name": "id",
60+
"data_tests": [
61+
{"not_null": {"name": "custom_notnull_name"}},
62+
{"not_null": {}},
63+
],
64+
},
65+
{
66+
"name": "status",
67+
"data_tests": [
68+
{"accepted_values": {"values": ["value1", "value2"]}},
69+
{"accepted_values": {"values": ["value1", "value2", "value3"]}},
70+
{
71+
"accepted_values": {
72+
"name": "custom_accepted_values_name",
73+
"values": ["value1", "value2"],
74+
}
75+
},
76+
{
77+
"accepted_values": {
78+
"name": "custom_accepted_values_name",
79+
"values": ["value1", "value2", "value3"],
80+
}
81+
},
82+
],
83+
},
84+
],
85+
}
86+
],
87+
}
88+
89+
schema_file = model_dir / "schema.yml"
90+
with open(schema_file, "w", encoding="utf-8") as f:
91+
yaml.dump(schema_yaml, f)
92+
93+
context = Context(paths=project_dir)
94+
95+
all_audit_names = list(context._audits.keys()) + list(context._standalone_audits.keys())
96+
assert sorted(all_audit_names) == [
97+
"local.accepted_values_my_model_status__value1__value2",
98+
"local.accepted_values_my_model_status__value1__value2__value3",
99+
"local.custom_accepted_values_name_my_model_status__value1__value2",
100+
"local.custom_accepted_values_name_my_model_status__value1__value2__value3",
101+
"local.custom_notnull_name_my_model_id",
102+
"local.not_null_my_model_id",
103+
"local.source_custom_notnull_name_raw_my_source_id",
104+
"local.source_not_null_raw_my_source_id",
105+
]

0 commit comments

Comments
 (0)