Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions .changes/unreleased/Under the Hood-20251014-165142.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Under the Hood
body: 'Add validation that measures with create_metric: True don''t have name overlaps with metrics.'
time: 2025-10-14T16:51:42.9386-07:00
custom:
Author: theyostalservice
Issue: "387"
8 changes: 4 additions & 4 deletions dbt_semantic_interfaces/transformations/proxy_measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ def transform_model(semantic_manifest: PydanticSemanticManifest) -> PydanticSema
if metric.name == measure.name:
if metric.type != MetricType.SIMPLE:
raise ModelTransformError(
f"Cannot have metric with the same name as a measure ({measure.name}) that is not a "
f"created mechanically from that measure using create_metric=True"
f"Cannot have metric with the same name as a measure '{measure.name}' that is not a "
f"simple metric"
)
logger.warning(
f"Metric already exists with name ({measure.name}). *Not* adding measure proxy metric for "
f"that measure"
f"Simple metric already exists with name '{measure.name}'. "
"*Not* adding simple proxy metric for that measure."
)
add_metric = False

Expand Down
46 changes: 46 additions & 0 deletions dbt_semantic_interfaces/validations/measures.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
SemanticManifestT,
)
from dbt_semantic_interfaces.references import MeasureReference, MetricModelReference
from dbt_semantic_interfaces.type_enums.metric_type import MetricType
from dbt_semantic_interfaces.validations.shared_measure_and_metric_helpers import (
SharedMeasureAndMetricHelpers,
)
Expand Down Expand Up @@ -37,6 +38,7 @@ class SemanticModelMeasuresUniqueRule(SemanticManifestValidationRule[SemanticMan
def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[ValidationIssue]: # noqa: D
issues: List[ValidationIssue] = []

metrics_by_name = {metric.name: metric for metric in getattr(semantic_manifest, "metrics", [])}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the reason for using getattr here instead of accessing the property directly? seems like we get better type safety if we access the property?

measure_references_to_semantic_models: Dict[MeasureReference, List] = defaultdict(list)
for semantic_model in semantic_manifest.semantic_models:
for measure in semantic_model.measures:
Expand All @@ -56,6 +58,50 @@ def validate_manifest(semantic_manifest: SemanticManifestT) -> Sequence[Validati
)
measure_references_to_semantic_models[measure.reference].append(semantic_model.name)

# As we iterate over all measures, check for name clash with metrics
metric = metrics_by_name.get(measure.name)
if metric is not None:
file_context = FileContext.from_metadata(metadata=semantic_model.metadata)
element_context = SemanticModelElementContext(
file_context=file_context,
semantic_model_element=SemanticModelElementReference(
semantic_model_name=semantic_model.name,
element_name=measure.name,
),
element_type=SemanticModelElementType.MEASURE,
)
metric_type = getattr(metric, "type", None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getattr instead of accessing the property? is this because we lost type safety on metrics_by_name?

if metric_type == MetricType.SIMPLE:
issues.append(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I missing the part where we check if there was create_metric: True for this measure? We should only throw these validations if that property is used!

ValidationWarning(
context=element_context,
message=(
f"A measure with name '{measure.name}' exists in semantic model "
f"'{semantic_model.name}', and a simple metric with the same name exists in "
"the project. This may result in ambiguous behavior. The existing metric will "
"take precedence over the proxy metric that would be auto-generated from this "
"measure."
),
)
)
else:
issues.append(
ValidationError(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going back to the thread on the other PR:

The validation we add to DSI should log a warning for any scenario where a measure using create_metric=True has the same name as another metric in the model (any metric type).

We actually need to make both of these warnings instead of errors, unfortunately! Reason being that it will be a breaking change if we make either of them errors since the error they would have hit wouldn't have been until query time in MF. This error would unexpectedly break their jobs and cause failures for things totally unrelated to SL.

But that means we don't need to check the type and we can consolidate to one validation.

context=element_context,
message=(
(
f"Cannot auto-generate a proxy metric for measure "
f"'{measure.name}' in semantic model '{semantic_model.name}' "
"because a metric with the same name already exists in the project. "
"This is elevated to an error instead of a warning because the metric "
"is not a simple metric "
f"(found type: '{metric_type}'). Rename the measure or the metric "
"to resolve the collision."
)
),
)
)

return issues


Expand Down
82 changes: 82 additions & 0 deletions tests/transformations/test_proxy_measure_transformation_rule.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
import logging
import textwrap
from typing import List, Optional

import pytest

from dbt_semantic_interfaces.errors import ModelTransformError
from dbt_semantic_interfaces.implementations.elements.measure import PydanticMeasure
from dbt_semantic_interfaces.implementations.metric import (
PydanticMetric,
PydanticMetricAggregationParams,
PydanticMetricInputMeasure,
PydanticMetricTypeParams,
)
from dbt_semantic_interfaces.implementations.node_relation import PydanticNodeRelation
from dbt_semantic_interfaces.implementations.semantic_manifest import (
PydanticSemanticManifest,
)
from dbt_semantic_interfaces.implementations.semantic_model import PydanticSemanticModel
from dbt_semantic_interfaces.parsing.dir_to_model import (
SemanticManifestBuildResult,
parse_yaml_files_to_validation_ready_semantic_manifest,
)
from dbt_semantic_interfaces.parsing.objects import YamlConfigFile
from dbt_semantic_interfaces.transformations.proxy_measure import CreateProxyMeasureRule
from dbt_semantic_interfaces.type_enums import AggregationType
from dbt_semantic_interfaces.type_enums.metric_type import MetricType
from tests.example_project_configuration import (
EXAMPLE_PROJECT_CONFIGURATION,
EXAMPLE_PROJECT_CONFIGURATION_YAML_CONFIG_FILE,
)

Expand Down Expand Up @@ -277,3 +285,77 @@ def test_proxy_measure_defaults_and_agg_params() -> None:
assert metric.type_params.input_measures == [
PydanticMetricInputMeasure(name=measure_name)
], "Created metrics should have measure_inputs populated"


def test_proxy_measure_conflicting_non_simple_metric_raises() -> None:
"""Error if a non-simple metric shares the same name as a create_metric measure."""
shared_name = "overlap_conflict"

# Build manifest directly with Pydantic objects
# (We can't do the parsing step because that will run validations, and validations
# don't like this either.)
semantic_model = PydanticSemanticModel(
name="conflict_model",
node_relation=PydanticNodeRelation(alias="source_table", schema_name="some_schema"),
measures=[
PydanticMeasure(name=shared_name, agg=AggregationType.SUM, create_metric=True),
],
)

existing_metric = PydanticMetric(
name=shared_name,
description="non-simple metric with same name",
type=MetricType.DERIVED,
type_params=PydanticMetricTypeParams(expr=shared_name),
)

manifest = PydanticSemanticManifest(
semantic_models=[semantic_model],
metrics=[existing_metric],
project_configuration=EXAMPLE_PROJECT_CONFIGURATION,
)

with pytest.raises(
ModelTransformError,
match=rf"Cannot have metric with the same name as a measure '{shared_name}' that is not a simple metric",
):
CreateProxyMeasureRule.transform_model(manifest)


def test_proxy_measure_existing_simple_metric_logs_warning_and_skips_add(caplog: pytest.LogCaptureFixture) -> None:
"""Warn and do not add a proxy when a simple metric shares the name."""
caplog.set_level(logging.WARNING, logger="dbt_semantic_interfaces.transformations.proxy_measure")

shared_name = "overlap_simple"
# Build manifest directly with Pydantic objects (avoid YAML parsing and validations)
semantic_model = PydanticSemanticModel(
name="simple_conflict_model",
node_relation=PydanticNodeRelation(alias="source_table", schema_name="some_schema"),
measures=[
PydanticMeasure(name=shared_name, agg=AggregationType.SUM, create_metric=True),
],
)

existing_metric = PydanticMetric(
name=shared_name,
description="simple metric with same name",
type=MetricType.SIMPLE,
type_params=PydanticMetricTypeParams(
measure=PydanticMetricInputMeasure(name=shared_name),
),
)

manifest = PydanticSemanticManifest(
semantic_models=[semantic_model],
metrics=[existing_metric],
project_configuration=EXAMPLE_PROJECT_CONFIGURATION,
)

out_manifest = CreateProxyMeasureRule.transform_model(manifest)

# Should not add a duplicate metric for the measure; keep the single simple metric
metrics: List[PydanticMetric] = out_manifest.metrics
assert len([m for m in metrics if m.name == shared_name]) == 1

warning_messages = "\n".join(record.getMessage() for record in caplog.records)
assert f"Simple metric already exists with name '{shared_name}'." in warning_messages
89 changes: 89 additions & 0 deletions tests/validations/test_measures_with_create_metric_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from dbt_semantic_interfaces.implementations.elements.measure import PydanticMeasure
from dbt_semantic_interfaces.implementations.metric import (
PydanticMetricInputMeasure,
PydanticMetricTypeParams,
)
from dbt_semantic_interfaces.implementations.semantic_manifest import (
PydanticSemanticManifest,
)
from dbt_semantic_interfaces.test_utils import (
metric_with_guaranteed_meta,
semantic_model_with_guaranteed_meta,
)
from dbt_semantic_interfaces.type_enums import AggregationType, MetricType
from dbt_semantic_interfaces.validations.measures import SemanticModelMeasuresUniqueRule
from dbt_semantic_interfaces.validations.semantic_manifest_validator import (
SemanticManifestValidator,
)
from tests.example_project_configuration import EXAMPLE_PROJECT_CONFIGURATION
from tests.validations.validation_test_utils import check_error_in_issues


def test_measure_and_simple_metric_same_name_warns() -> None:
"""If a metric shares a name with a measure and is SIMPLE, return a validation warning."""
measure_name = "num_sample_rows"
semantic_model_name = "sample_semantic_model"

semantic_model = semantic_model_with_guaranteed_meta(
name=semantic_model_name,
measures=[PydanticMeasure(name=measure_name, agg=AggregationType.SUM, expr="1")],
)
semantic_model.measures[0].create_metric = True

simple_metric = metric_with_guaranteed_meta(
name=measure_name,
type=MetricType.SIMPLE,
type_params=PydanticMetricTypeParams(measure=PydanticMetricInputMeasure(name=measure_name)),
)

manifest = PydanticSemanticManifest(
semantic_models=[semantic_model],
metrics=[simple_metric],
project_configuration=EXAMPLE_PROJECT_CONFIGURATION,
)

validation_results = SemanticManifestValidator[PydanticSemanticManifest](
[SemanticModelMeasuresUniqueRule()]
).validate_semantic_manifest(manifest)

expected_warning = (
f"A measure with name '{measure_name}' exists in semantic model '{semantic_model_name}', and a simple metric"
)
check_error_in_issues(error_substrings=[expected_warning], issues=validation_results.warnings)


def test_measure_and_non_simple_metric_same_name_errors() -> None:
"""If a metric shares a name with a measure and is NOT SIMPLE, return a validation error."""
measure_name = "num_sample_rows"
semantic_model_name = "sample_semantic_model"

semantic_model = semantic_model_with_guaranteed_meta(
name=semantic_model_name,
measures=[PydanticMeasure(name=measure_name, agg=AggregationType.SUM, expr="1")],
)
semantic_model.measures[0].create_metric = True

non_simple_metric = metric_with_guaranteed_meta(
name=measure_name,
type=MetricType.DERIVED,
type_params=PydanticMetricTypeParams(expr=measure_name),
)

manifest = PydanticSemanticManifest(
semantic_models=[semantic_model],
metrics=[non_simple_metric],
project_configuration=EXAMPLE_PROJECT_CONFIGURATION,
)

validation_results = SemanticManifestValidator[PydanticSemanticManifest](
[SemanticModelMeasuresUniqueRule()]
).validate_semantic_manifest(manifest)

expected_error = (
f"Cannot auto-generate a proxy metric for measure '{measure_name}' in semantic "
f"model '{semantic_model_name}' because a metric with the same name already exists "
f"in the project. This is elevated to an error instead of a warning because the "
f"metric is not a simple metric (found type: '{MetricType.DERIVED}'). Rename the "
f"measure or the metric to resolve the collision."
)
check_error_in_issues(error_substrings=[expected_error], issues=validation_results.errors)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also test that these validations don't get thrown if there is a name conflict but the measure doesn't use create_metric