Repository: dbt-labs/dbt-core

Issue:
[Bug] Semantic Layer where filter strings are parsed into lists
### 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

Currently, if you pass a string into a filter YAML param, it will be assumed to be a list. This means your semantic manifest ends up with a list of strings for where filters. For example, this YAML:
```
filter: |
    {{ Dimension('customer__customer_type') }}  = 'new'
```
would look like this in the semantic manifest:
```
{"where_filters": [{"where_sql_template": "{"}, {"where_sql_template": "{"}, {"where_sql_template": " "}, {"where_sql_template": "D"}, {"where_sql_template": "i"}, {"where_sql_template": "m"}, {"where_sql_template": "e"}, {"where_sql_template": "n"}, {"where_sql_template": "s"}, {"where_sql_template": "i"}, {"where_sql_template": "o"}, {"where_sql_template": "n"}, {"where_sql_template": "("}, {"where_sql_template": "'"}, {"where_sql_template": "c"}, {"where_sql_template": "u"}, {"where_sql_template": "s"}, {"where_sql_template": "t"}, {"where_sql_template": "o"}, {"where_sql_template": "m"}, {"where_sql_template": "e"}, {"where_sql_template": "r"}, {"where_sql_template": "_"}, {"where_sql_template": "_"}, {"where_sql_template": "c"}, {"where_sql_template": "u"}, {"where_sql_template": "s"}, {"where_sql_template": "t"}, {"where_sql_template": "o"}, {"where_sql_template": "m"}, {"where_sql_template": "e"}, {"where_sql_template": "r"}, {"where_sql_template": "_"}, {"where_sql_template": "t"}, {"where_sql_template": "y"}, {"where_sql_template": "p"}, {"where_sql_template": "e"}, {"where_sql_template": "'"}, {"where_sql_template": ")"}, {"where_sql_template": " "}, {"where_sql_template": "}"}, {"where_sql_template": "}"}, {"where_sql_template": " "}, {"where_sql_template": " "}, {"where_sql_template": "="}, {"where_sql_template": " "}, {"where_sql_template": "'"}, {"where_sql_template": "n"}, {"where_sql_template": "e"}, {"where_sql_template": "w"}, {"where_sql_template": "'"}]}
```

### Expected Behavior

The semantic manifest should show this:
`{"where_filters": [{"where_sql_template": "{{ Dimension('customer__customer_type') }}  = 'new'"}]}`

### Steps To Reproduce

Add a string filter to a metric or saved query and run `dbt parse`, then look at the generated semantic manifest.

### Relevant log output

_No response_

### Environment

```markdown
- OS:
- Python:
- dbt:
```


### Which database adapter are you using with dbt?

_No response_

### Additional Context

_No response_


Discussion:

Resolving patch:
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
@@ -562,7 +562,8 @@ def __bool__(self):
 @dataclass
 class UnparsedMetricInputMeasure(dbtClassMixin):
     name: str
-    filter: Optional[Union[str, List[str]]] = None
+    # Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
+    filter: Union[str, List[str], None] = None
     alias: Optional[str] = None
     join_to_timespine: bool = False
     fill_nulls_with: Optional[int] = None
@@ -571,7 +572,8 @@ class UnparsedMetricInputMeasure(dbtClassMixin):
 @dataclass
 class UnparsedMetricInput(dbtClassMixin):
     name: str
-    filter: Optional[Union[str, List[str]]] = None
+    # Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
+    filter: Union[str, List[str], None] = None
     alias: Optional[str] = None
     offset_window: Optional[str] = None
     offset_to_grain: Optional[str] = None  # str is really a TimeGranularity Enum
@@ -608,7 +610,8 @@ class UnparsedMetric(dbtClassMixin):
     type: str
     type_params: UnparsedMetricTypeParams
     description: str = ""
-    filter: Optional[Union[str, List[str]]] = None
+    # Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
+    filter: Union[str, List[str], None] = None
     # metadata: Optional[Unparsedetadata] = None # TODO
     meta: Dict[str, Any] = field(default_factory=dict)
     tags: List[str] = field(default_factory=list)
@@ -718,7 +721,8 @@ class UnparsedSemanticModel(dbtClassMixin):
 class UnparsedQueryParams(dbtClassMixin):
     metrics: List[str] = field(default_factory=list)
     group_by: List[str] = field(default_factory=list)
-    where: Optional[Union[str, List[str]]] = None
+    # Note: `Union` must be the outermost part of the type annotation for serialization to work properly.
+    where: Union[str, List[str], None] = None
 
 
 @dataclass


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
@@ -664,3 +664,38 @@
           conversion_measure: num_orders
           entity: purchase
 """
+
+filtered_metrics_yml = """
+version: 2
+
+metrics:
+
+  - name: collective_tenure_measure_filter_str
+    label: "Collective tenure1"
+    description: Total number of years of team experience
+    type: simple
+    type_params:
+      measure:
+        name: "years_tenure"
+        filter: "{{ Dimension('id__loves_dbt') }} is true"
+
+  - name: collective_tenure_metric_filter_str
+    label: Collective tenure3
+    description: Total number of years of team experience
+    type: simple
+    type_params:
+      measure:
+        name: "years_tenure"
+    filter: "{{ Dimension('id__loves_dbt') }} is true"
+
+
+  - name: average_tenure_filter_str
+    label: Average tenure of people who love dbt1
+    description: Average tenure of people who love dbt
+    type: derived
+    type_params:
+      expr: "average_tenure"
+      metrics:
+        - name: average_tenure
+          filter: "{{ Dimension('id__loves_dbt') }} is true"
+"""
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
@@ -28,6 +28,8 @@
     semantic_model_people_yml,
     semantic_model_purchasing_yml,
     purchasing_model_sql,
+    filtered_metrics_yml,
+    basic_metrics_yml,
 )
 
 
@@ -399,3 +401,53 @@ def test_conversion_metric(
             ].type_params.conversion_type_params.entity
             == "purchase"
         )
+
+
+class TestFilterParsing:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "basic_metrics.yml": basic_metrics_yml,
+            "filtered_metrics.yml": filtered_metrics_yml,
+            "metricflow_time_spine.sql": metricflow_time_spine_sql,
+            "semantic_model_people.yml": semantic_model_people_yml,
+            "people.sql": models_people_sql,
+        }
+
+    # Tests that filters are parsed to their appropriate type
+    def test_string_filter_parsing(
+        self,
+        project,
+    ):
+        runner = dbtRunner()
+        result = runner.invoke(["parse"])
+        assert result.success
+        assert isinstance(result.result, Manifest)
+
+        manifest = get_manifest(project.project_root)
+        assert manifest
+
+        # Test metrics with input measure filters.
+        filters1 = (
+            manifest.metrics["metric.test.collective_tenure_measure_filter_str"]
+            .input_measures[0]
+            .filter.where_filters
+        )
+        assert len(filters1) == 1
+        assert filters1[0].where_sql_template == "{{ Dimension('id__loves_dbt') }} is true"
+
+        # Test metrics with metric-level filters.
+        filters2 = manifest.metrics[
+            "metric.test.collective_tenure_metric_filter_str"
+        ].filter.where_filters
+        assert len(filters2) == 1
+        assert filters2[0].where_sql_template == "{{ Dimension('id__loves_dbt') }} is true"
+
+        # Test derived metrics with input metric filters.
+        filters3 = (
+            manifest.metrics["metric.test.average_tenure_filter_str"]
+            .input_metrics[0]
+            .filter.where_filters
+        )
+        assert len(filters3) == 1
+        assert filters3[0].where_sql_template == "{{ Dimension('id__loves_dbt') }} is true"
diff --git a/tests/functional/saved_queries/fixtures.py b/tests/functional/saved_queries/fixtures.py
--- a/tests/functional/saved_queries/fixtures.py
+++ b/tests/functional/saved_queries/fixtures.py
@@ -25,6 +25,39 @@
             schema: my_export_schema_name
 """
 
+saved_queries_with_diff_filters_yml = """
+version: 2
+
+saved_queries:
+  - name: test_saved_query_where_list
+    description: "{{ doc('saved_query_description') }}"
+    label: Test Saved Query
+    query_params:
+        metrics:
+            - simple_metric
+        group_by:
+            - "Dimension('user__ds')"
+        where:
+            - "{{ Dimension('user__ds', 'DAY') }} <= now()"
+            - "{{ Dimension('user__ds', 'DAY') }} >= '2023-01-01'"
+    exports:
+        - name: my_export
+          config:
+            alias: my_export_alias
+            export_as: table
+            schema: my_export_schema_name
+
+  - name: test_saved_query_where_str
+    description: "{{ doc('saved_query_description') }}"
+    label: Test Saved Query2
+    query_params:
+      metrics:
+        - simple_metric
+      group_by:
+        - "Dimension('user__ds')"
+      where: "{{ Dimension('user__ds', 'DAY') }} <= now()"
+"""
+
 saved_query_with_extra_config_attributes_yml = """
 version: 2
 
diff --git a/tests/functional/saved_queries/test_saved_query_parsing.py b/tests/functional/saved_queries/test_saved_query_parsing.py
--- a/tests/functional/saved_queries/test_saved_query_parsing.py
+++ b/tests/functional/saved_queries/test_saved_query_parsing.py
@@ -7,7 +7,11 @@
 from dbt.tests.util import write_file
 from dbt_semantic_interfaces.type_enums.export_destination_type import ExportDestinationType
 from tests.functional.assertions.test_runner import dbtTestRunner
-from tests.functional.saved_queries.fixtures import saved_queries_yml, saved_query_description
+from tests.functional.saved_queries.fixtures import (
+    saved_queries_yml,
+    saved_query_description,
+    saved_queries_with_diff_filters_yml,
+)
 from tests.functional.semantic_models.fixtures import (
     fct_revenue_sql,
     metricflow_time_spine_sql,
@@ -63,12 +67,38 @@ class TestSavedQueryPartialParsing:
     def models(self):
         return {
             "saved_queries.yml": saved_queries_yml,
+            "saved_queries_with_diff_filters.yml": saved_queries_with_diff_filters_yml,
             "schema.yml": schema_yml,
             "fct_revenue.sql": fct_revenue_sql,
             "metricflow_time_spine.sql": metricflow_time_spine_sql,
             "docs.md": saved_query_description,
         }
 
+    def test_saved_query_filter_types(self, project):
+        runner = dbtTestRunner()
+        result = runner.invoke(["parse"])
+        assert result.success
+
+        manifest = result.result
+        saved_query1 = manifest.saved_queries["saved_query.test.test_saved_query_where_list"]
+        saved_query2 = manifest.saved_queries["saved_query.test.test_saved_query_where_str"]
+
+        # List filter
+        assert len(saved_query1.query_params.where.where_filters) == 2
+        assert {
+            where_filter.where_sql_template
+            for where_filter in saved_query1.query_params.where.where_filters
+        } == {
+            "{{ Dimension('user__ds', 'DAY') }} <= now()",
+            "{{ Dimension('user__ds', 'DAY') }} >= '2023-01-01'",
+        }
+        # String filter
+        assert len(saved_query2.query_params.where.where_filters) == 1
+        assert (
+            saved_query2.query_params.where.where_filters[0].where_sql_template
+            == "{{ Dimension('user__ds', 'DAY') }} <= now()"
+        )
+
     def test_saved_query_metrics_changed(self, project):
         # First, use the default saved_queries.yml to define our saved_queries, and
         # run the dbt parse command