Repository: dbt-labs/dbt-core

Issue:
[CT-3541] [Bug] Duplicate Metric InputMeasures cause query time errors in MetricFlow
### Is this a new bug in dbt-core?

- [X] I believe this is a new bug in dbt-core
- [X] I have searched the existing issues, and I could not find an existing issue for this bug

### Current Behavior

After parsing a metric, we process a metric to populate it's `input_measures`. This happens in [_process_metric_node](https://github.com/dbt-labs/dbt-core/blob/84dfb22cd59edee0d3e2bca6041288449bfbe7c4/core/dbt/parser/manifest.py#L1563). It's possible for there to be _duplicate_ input measures in the `input_measures` list. When there is, a validation warning shows up in the output. Additionally on the MetricFlow side, this causes a query time error for the metric.

### Expected Behavior

There shouldn't be duplicate `InputMeasure`s in a `Metric`'s `Metric.type_params.input_measures` and thus no validation warning should be raised.

### Steps To Reproduce

1. Add a ratio metric that depends on two simple metrics that have the same underlying measure
  a. Alternatively pull down my [qmalcolm--duplicate-input-measure-repro](https://github.com/dbt-labs/jaffle-sl-template/compare/main...qmalcolm--duplicate-input-measure-repro) from [jaffle-sl-template](https://github.com/dbt-labs/jaffle-sl-template)
2. Run `dbt parse`
3. Observe validation warning in output

### Relevant log output

```shell
PydanticMetric ratio_new_customers has multiple identical input measures specifications for measure customers_with_orders. This might be hiding a semantic error. Input measure specification: name='customers_with_orders' filter=None alias=None join_to_timespine=False fill_nulls_with=None.
```


### Environment

```markdown
- OS: macOS (Sonoma 14.2)
- Python: 3.8
- dbt: 1.7.4
```


### Which database adapter are you using with dbt?

snowflake

### Additional Context

The Semantic Layer team, is going to work on mitigating this in MetricFlow as an immediate fix, however we should also fix this on the Core side. 


Discussion:
Additionally I would be a proponent of getting the fix backported to 1.7.latest

Resolving patch:
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -86,6 +86,7 @@
     Snapshot as SnapshotResource,
     Quoting as QuotingResource,
     SourceDefinition as SourceDefinitionResource,
+    MetricInputMeasure,
     UnitTestDefinition as UnitTestDefinitionResource,
 )
 
@@ -1400,6 +1401,12 @@ def same_contents(self, old: Optional["Metric"]) -> bool:
             and True
         )
 
+    def add_input_measure(self, input_measure: MetricInputMeasure) -> None:
+        for existing_input_measure in self.type_params.input_measures:
+            if input_measure == existing_input_measure:
+                return
+        self.type_params.input_measures.append(input_measure)
+
 
 # ====================================
 # Group node
diff --git a/core/dbt/parser/manifest.py b/core/dbt/parser/manifest.py
--- a/core/dbt/parser/manifest.py
+++ b/core/dbt/parser/manifest.py
@@ -1618,7 +1618,7 @@ def _process_metric_node(
         assert (
             metric.type_params.measure is not None
         ), f"{metric} should have a measure defined, but it does not."
-        metric.type_params.input_measures.append(metric.type_params.measure)
+        metric.add_input_measure(metric.type_params.measure)
         _process_metric_depends_on(
             manifest=manifest, current_project=current_project, metric=metric
         )
@@ -1627,8 +1627,8 @@ def _process_metric_node(
         assert (
             conversion_type_params
         ), f"{metric.name} is a conversion metric and must have conversion_type_params defined."
-        metric.type_params.input_measures.append(conversion_type_params.base_measure)
-        metric.type_params.input_measures.append(conversion_type_params.conversion_measure)
+        metric.add_input_measure(conversion_type_params.base_measure)
+        metric.add_input_measure(conversion_type_params.conversion_measure)
         _process_metric_depends_on(
             manifest=manifest, current_project=current_project, metric=metric
         )
@@ -1664,7 +1664,8 @@ def _process_metric_node(
             _process_metric_node(
                 manifest=manifest, current_project=current_project, metric=target_metric
             )
-            metric.type_params.input_measures.extend(target_metric.type_params.input_measures)
+            for input_measure in target_metric.type_params.input_measures:
+                metric.add_input_measure(input_measure)
             metric.depends_on.add_node(target_metric.unique_id)
     else:
         assert_values_exhausted(metric.type)


Test patch:
diff --git a/tests/functional/metrics/fixtures.py b/tests/functional/metrics/fixtures.py
--- a/tests/functional/metrics/fixtures.py
+++ b/tests/functional/metrics/fixtures.py
@@ -783,3 +783,21 @@
         - name: average_tenure
           filter: "{{ Dimension('id__loves_dbt') }} is true"
 """
+
+duplicate_measure_metric_yml = """
+metrics:
+  # Simple metrics
+  - name: people_with_tenure
+    description: "Count of people with tenure"
+    type: simple
+    label: People with tenure
+    type_params:
+      measure: people
+  - name: ratio_tenure_to_people
+    description: People to years of tenure
+    label: New customers to all customers
+    type: ratio
+    type_params:
+      numerator: people_with_tenure
+      denominator: number_of_people
+"""
diff --git a/tests/functional/metrics/test_metrics.py b/tests/functional/metrics/test_metrics.py
--- a/tests/functional/metrics/test_metrics.py
+++ b/tests/functional/metrics/test_metrics.py
@@ -30,6 +30,7 @@
     purchasing_model_sql,
     filtered_metrics_yml,
     basic_metrics_yml,
+    duplicate_measure_metric_yml,
 )
 
 
@@ -78,7 +79,7 @@ def test_simple_metric(
                     "metric.test.average_tenure_minus_people"
                 ].type_params.input_measures
             )
-            == 3
+            == 2
         )
 
 
@@ -451,3 +452,21 @@ def test_string_filter_parsing(
         )
         assert len(filters3) == 1
         assert filters3[0].where_sql_template == "{{ Dimension('id__loves_dbt') }} is true"
+
+
+class TestDuplicateInputMeasures:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "basic_metrics.yml": basic_metrics_yml,
+            "filtered_metrics.yml": duplicate_measure_metric_yml,
+            "metricflow_time_spine.sql": metricflow_time_spine_sql,
+            "semantic_model_people.yml": semantic_model_people_yml,
+            "people.sql": models_people_sql,
+        }
+
+    def test_duplicate_input_measures(self, project):
+        runner = dbtRunner()
+        result = runner.invoke(["parse"])
+        assert result.success
+        assert isinstance(result.result, Manifest)