Repository: dbt-labs/dbt-core

Issue:
[CT-65] [Bug] Metrics' names shouldn’t be allowed to contain spaces
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

I'm working on macros to generate SQL to calculate metrics, and want to be able to use `metric.name` as the column alias. I just discovered that they're allowed to contain spaces - I don't know if this was intentional or not considered, but I think it's bad when there's a human-readable `label` available as well. 

### Expected Behavior

`slack users` to be rejected because it's not `[_A-Za-z]` (or maybe even just `[_a-z]`?)

### Steps To Reproduce

```version: 2 
metrics:
    - name: slack joiners
      label: Members of Community Slack
      model: dim_slack_users_2
```

### Relevant log output

_No response_

### Environment

```markdown
- dbt: 1.0.0
```


### What database are you using dbt with?

snowflake

### Additional Context

_No response_


Discussion:
I think that this was an oversight. We'll discuss what we'd like to accept for the metrics name and fix this.
@jtcohen6 is the exit criteria here just to prohibit spaces in metric names?
My preference would be to constrain it pretty severely - right now I'm passing the metric name directly in as a column alias. For example, even the metrics that our internal data team has made will be problematic, cos they're using periods:

```
  - name: okr_1.2.0__new_verified_accounts_activated
    label: New Verified Accounts Activated
    model: ref('fct_cloud_accounts')
    description: '{{ doc("okr_new_verified_accounts_activated") }}'
...
```

Ideally it should be just stuff that's legal in a column name (which also has implications around whether a metric's name can start with a digit). 

Alternatively, I can tidy up the column alias myself, but that could cause conflicts with any [low-code tools built on top of the metrics package](https://share.streamlit.io/stkbailey/streamlit-dbt-metrics-explorer/main/app.py) that will need to know how to find the resulting column. 

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
@@ -7,7 +7,7 @@
 
 # trigger the PathEncoder
 import dbt.helper_types  # noqa:F401
-from dbt.exceptions import CompilationException
+from dbt.exceptions import CompilationException, ParsingException
 
 from dbt.dataclass_schema import dbtClassMixin, StrEnum, ExtensibleDbtClassMixin
 
@@ -460,3 +460,9 @@ class UnparsedMetric(dbtClassMixin, Replaceable):
     filters: List[MetricFilter] = field(default_factory=list)
     meta: Dict[str, Any] = field(default_factory=dict)
     tags: List[str] = field(default_factory=list)
+
+    @classmethod
+    def validate(cls, data):
+        super(UnparsedMetric, cls).validate(data)
+        if "name" in data and " " in data["name"]:
+            raise ParsingException(f"Metrics name '{data['name']}' cannot contain spaces")


Test patch:
diff --git a/test/integration/075_metrics_tests/invalid-models/people.sql b/test/integration/075_metrics_tests/invalid-models/people.sql
deleted file mode 100644
--- a/test/integration/075_metrics_tests/invalid-models/people.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-select 1 as id, 'Drew' as first_name, 'Banin' as last_name, 'yellow' as favorite_color, true as loves_dbt, 5 as tenure, current_timestamp as created_at
-union all
-select 1 as id, 'Jeremy' as first_name, 'Cohen' as last_name, 'indigo' as favorite_color, true as loves_dbt, 4 as tenure, current_timestamp as created_at
diff --git a/test/integration/075_metrics_tests/invalid-models/people_metrics.yml b/test/integration/075_metrics_tests/invalid-models/people_metrics.yml
deleted file mode 100644
--- a/test/integration/075_metrics_tests/invalid-models/people_metrics.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-version: 2
-
-metrics:
-
-  - model: "ref(people)"
-    name: number_of_people
-    description: Total count of people
-    label: "Number of people"
-    type: count
-    sql: "*"
-    timestamp: created_at
-    time_grains: [day, week, month]
-    dimensions:
-      - favorite_color
-      - loves_dbt
-    meta:
-        my_meta: 'testing'
-
-  - model: "ref(people)"
-    name: collective_tenure
-    description: Total number of years of team experience
-    label: "Collective tenure"
-    type: sum
-    sql: tenure
-    timestamp: created_at
-    time_grains: [day]
-    filters:
-      - field: loves_dbt
-        operator: is
-        value: 'true'
diff --git a/test/integration/075_metrics_tests/models/people.sql b/test/integration/075_metrics_tests/models/people.sql
deleted file mode 100644
--- a/test/integration/075_metrics_tests/models/people.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-select 1 as id, 'Drew' as first_name, 'Banin' as last_name, 'yellow' as favorite_color, true as loves_dbt, 5 as tenure, current_timestamp as created_at
-union all
-select 1 as id, 'Jeremy' as first_name, 'Cohen' as last_name, 'indigo' as favorite_color, true as loves_dbt, 4 as tenure, current_timestamp as created_at
diff --git a/test/integration/075_metrics_tests/models/people_metrics.yml b/test/integration/075_metrics_tests/models/people_metrics.yml
deleted file mode 100644
--- a/test/integration/075_metrics_tests/models/people_metrics.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-version: 2
-
-metrics:
-
-  - model: "ref('people')"
-    name: number_of_people
-    description: Total count of people
-    label: "Number of people"
-    type: count
-    sql: "*"
-    timestamp: created_at
-    time_grains: [day, week, month]
-    dimensions:
-      - favorite_color
-      - loves_dbt
-    meta:
-        my_meta: 'testing'
-
-  - model: "ref('people')"
-    name: collective_tenure
-    description: Total number of years of team experience
-    label: "Collective tenure"
-    type: sum
-    sql: tenure
-    timestamp: created_at
-    time_grains: [day]
-    filters:
-      - field: loves_dbt
-        operator: is
-        value: 'true'
diff --git a/test/integration/075_metrics_tests/test_metrics.py b/test/integration/075_metrics_tests/test_metrics.py
deleted file mode 100644
--- a/test/integration/075_metrics_tests/test_metrics.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from test.integration.base import DBTIntegrationTest, use_profile, normalize, get_manifest
-from dbt.exceptions import ParsingException
-
-class BaseMetricTest(DBTIntegrationTest):
-
-    @property
-    def schema(self):
-        return "test_075"
-
-    @property
-    def models(self):
-        return "models"
-
-    @property
-    def project_config(self):
-        return {
-            'config-version': 2,
-            'seed-paths': ['seeds'],
-            'seeds': {
-                'quote_columns': False,
-            },
-        }
-
-    @use_profile('postgres')
-    def test_postgres_simple_metric(self):
-        # initial run
-        results = self.run_dbt(["run"])
-        self.assertEqual(len(results), 1)
-        manifest = get_manifest()
-        metric_ids = list(manifest.metrics.keys())
-        expected_metric_ids = ['metric.test.number_of_people', 'metric.test.collective_tenure']
-        self.assertEqual(metric_ids, expected_metric_ids)
-
-class InvalidRefMetricTest(DBTIntegrationTest):
-
-    @property
-    def schema(self):
-        return "test_075"
-
-    @property
-    def models(self):
-        return "invalid-models"
-
-    @property
-    def project_config(self):
-        return {
-            'config-version': 2,
-            'seed-paths': ['seeds'],
-            'seeds': {
-                'quote_columns': False,
-            },
-        }
-
-    @use_profile('postgres')
-    def test_postgres_simple_metric(self):
-        # initial run
-        with self.assertRaises(ParsingException):
-            results = self.run_dbt(["run"])
-
diff --git a/tests/functional/metrics/test_metrics.py b/tests/functional/metrics/test_metrics.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/metrics/test_metrics.py
@@ -0,0 +1,169 @@
+import pytest
+
+from dbt.tests.util import run_dbt, get_manifest
+from dbt.exceptions import ParsingException
+
+
+models__people_metrics_yml = """
+version: 2
+
+metrics:
+
+  - model: "ref('people')"
+    name: number_of_people
+    description: Total count of people
+    label: "Number of people"
+    type: count
+    sql: "*"
+    timestamp: created_at
+    time_grains: [day, week, month]
+    dimensions:
+      - favorite_color
+      - loves_dbt
+    meta:
+        my_meta: 'testing'
+
+  - model: "ref('people')"
+    name: collective_tenure
+    description: Total number of years of team experience
+    label: "Collective tenure"
+    type: sum
+    sql: tenure
+    timestamp: created_at
+    time_grains: [day]
+    filters:
+      - field: loves_dbt
+        operator: is
+        value: 'true'
+
+"""
+
+models__people_sql = """
+select 1 as id, 'Drew' as first_name, 'Banin' as last_name, 'yellow' as favorite_color, true as loves_dbt, 5 as tenure, current_timestamp as created_at
+union all
+select 1 as id, 'Jeremy' as first_name, 'Cohen' as last_name, 'indigo' as favorite_color, true as loves_dbt, 4 as tenure, current_timestamp as created_at
+
+"""
+
+
+class TestSimpleMetrics:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people_metrics.yml": models__people_metrics_yml,
+            "people.sql": models__people_sql,
+        }
+
+    def test_simple_metric(
+        self,
+        project,
+    ):
+        # initial run
+        results = run_dbt(["run"])
+        assert len(results) == 1
+        manifest = get_manifest(project.project_root)
+        metric_ids = list(manifest.metrics.keys())
+        expected_metric_ids = ["metric.test.number_of_people", "metric.test.collective_tenure"]
+        assert metric_ids == expected_metric_ids
+
+
+invalid_models__people_metrics_yml = """
+version: 2
+
+metrics:
+
+  - model: "ref(people)"
+    name: number_of_people
+    description: Total count of people
+    label: "Number of people"
+    type: count
+    sql: "*"
+    timestamp: created_at
+    time_grains: [day, week, month]
+    dimensions:
+      - favorite_color
+      - loves_dbt
+    meta:
+        my_meta: 'testing'
+
+  - model: "ref(people)"
+    name: collective_tenure
+    description: Total number of years of team experience
+    label: "Collective tenure"
+    type: sum
+    sql: tenure
+    timestamp: created_at
+    time_grains: [day]
+    filters:
+      - field: loves_dbt
+        operator: is
+        value: 'true'
+
+"""
+
+
+class TestInvalidRefMetrics:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people_metrics.yml": invalid_models__people_metrics_yml,
+            "people.sql": models__people_sql,
+        }
+
+    # tests that we get a ParsingException with an invalid model ref, where
+    # the model name does not have quotes
+    def test_simple_metric(
+        self,
+        project,
+    ):
+        # initial run
+        with pytest.raises(ParsingException):
+            run_dbt(["run"])
+
+
+names_with_spaces_metrics_yml = """
+version: 2
+
+metrics:
+
+  - model: "ref('people')"
+    name: number of people
+    description: Total count of people
+    label: "Number of people"
+    type: count
+    sql: "*"
+    timestamp: created_at
+    time_grains: [day, week, month]
+    dimensions:
+      - favorite_color
+      - loves_dbt
+    meta:
+        my_meta: 'testing'
+
+  - model: "ref('people')"
+    name: collective tenure
+    description: Total number of years of team experience
+    label: "Collective tenure"
+    type: sum
+    sql: tenure
+    timestamp: created_at
+    time_grains: [day]
+    filters:
+      - field: loves_dbt
+        operator: is
+        value: 'true'
+
+"""
+
+
+class TestNamesWithSpaces:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "people_metrics.yml": names_with_spaces_metrics_yml,
+            "people.sql": models__people_sql,
+        }
+
+    def test_names_with_spaces(self, project):
+        with pytest.raises(ParsingException):
+            run_dbt(["run"])