Repository: dbt-labs/dbt-core

Issue:
[CT-3090] Support properties for null value coalescing in metrics
# Details
It was raised in [MetricFlow](https://github.com/dbt-labs/metricflow) recently that null values aren't always handled gracefully (https://github.com/dbt-labs/metricflow/issues/764). The proposed solution (https://github.com/dbt-labs/metricflow/issues/759) requires some changes to the Metric sub protocols as define in the dbt-semantic-interfaces issue (https://github.com/dbt-labs/dbt-semantic-interfaces/issues/142). Once the changes have been made in dbt-semantic-interfaces, we'll want to update our implementation of the [affected protocol](https://github.com/dbt-labs/dbt-core/blob/b39eeb328c9a245f21b54fb48f2761ad03d4d6ad/core/dbt/contracts/graph/nodes.py#L1408) and the associated parsing logic ([unparsed definition](https://github.com/dbt-labs/dbt-core/blob/b39eeb328c9a245f21b54fb48f2761ad03d4d6ad/core/dbt/contracts/graph/unparsed.py#L583), [schema_yaml_reader](https://github.com/dbt-labs/dbt-core/blob/b39eeb328c9a245f21b54fb48f2761ad03d4d6ad/core/dbt/parser/schema_yaml_readers.py#L166))

## Acceptance Criteria

- People can specify `join_to_timespine` boolean value in their metric type params measure inputs
  - If not specified, default to `False`
- People can specify `fill_nulls_with` integer value in their metric type params measure inputs
  - If not specified, default to `None`
- `join_to_timespine` and `fill_nulls_with` are propagated to `semantic_manifest.json`


### Blocked By
https://github.com/dbt-labs/dbt-semantic-interfaces/issues/142

### Backport
The issue in DSI will be backported to DSI 0.2.latest and that  will allow for backporting to dbt-core 1.6.latest.


Discussion:

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
@@ -1342,6 +1342,8 @@ class MetricInputMeasure(dbtClassMixin):
     name: str
     filter: Optional[WhereFilter] = None
     alias: Optional[str] = None
+    join_to_timespine: bool = False
+    fill_nulls_with: Optional[int] = None
 
     def measure_reference(self) -> MeasureReference:
         return MeasureReference(element_name=self.name)
diff --git a/core/dbt/contracts/graph/unparsed.py b/core/dbt/contracts/graph/unparsed.py
--- a/core/dbt/contracts/graph/unparsed.py
+++ b/core/dbt/contracts/graph/unparsed.py
@@ -597,6 +597,8 @@ class UnparsedMetricInputMeasure(dbtClassMixin):
     name: str
     filter: Optional[str] = None
     alias: Optional[str] = None
+    join_to_timespine: bool = False
+    fill_nulls_with: Optional[int] = None
 
 
 @dataclass
diff --git a/core/dbt/parser/schema_yaml_readers.py b/core/dbt/parser/schema_yaml_readers.py
--- a/core/dbt/parser/schema_yaml_readers.py
+++ b/core/dbt/parser/schema_yaml_readers.py
@@ -178,6 +178,8 @@ def _get_input_measure(
                 name=unparsed_input_measure.name,
                 filter=filter,
                 alias=unparsed_input_measure.alias,
+                join_to_timespine=unparsed_input_measure.join_to_timespine,
+                fill_nulls_with=unparsed_input_measure.fill_nulls_with,
             )
 
     def _get_optional_input_measure(


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
@@ -116,6 +116,8 @@
       measure:
         name: years_tenure
         filter: "{{ Dimension('id__loves_dbt') }} is true"
+        join_to_timespine: true
+        fill_nulls_with: 0
 
   - name: collective_window
     label: "Collective window"
diff --git a/tests/unit/test_contracts_graph_unparsed.py b/tests/unit/test_contracts_graph_unparsed.py
--- a/tests/unit/test_contracts_graph_unparsed.py
+++ b/tests/unit/test_contracts_graph_unparsed.py
@@ -870,6 +870,7 @@ def get_ok_dict(self):
                 "measure": {
                     "name": "customers",
                     "filter": "is_new = true",
+                    "join_to_timespine": False,
                 },
             },
             "config": {},
diff --git a/tests/unit/test_semantic_layer_nodes_satisfy_protocols.py b/tests/unit/test_semantic_layer_nodes_satisfy_protocols.py
--- a/tests/unit/test_semantic_layer_nodes_satisfy_protocols.py
+++ b/tests/unit/test_semantic_layer_nodes_satisfy_protocols.py
@@ -216,7 +216,11 @@ def simple_metric_input_measure() -> MetricInputMeasure:
 @pytest.fixture(scope="session")
 def complex_metric_input_measure(where_filter) -> MetricInputMeasure:
     return MetricInputMeasure(
-        name="test_complex_metric_input_measure", filter=where_filter, alias="complex_alias"
+        name="test_complex_metric_input_measure",
+        filter=where_filter,
+        alias="complex_alias",
+        join_to_timespine=True,
+        fill_nulls_with=0,
     )