Repository: dbt-labs/dbt-core

Issue:
[regression] Defining properties for model + exposure with same name raises exception
### Describe the bug

dbt raises the exception `raise_duplicate_patch_name` when an exposure and a model have the same `name`:

https://github.com/fishtown-analytics/dbt/blob/ce6967d396f6723f44ad8d5efb058c760f27d167/core/dbt/parser/results.py#L156-L158

I've confirmed that this exception is _not_ raised by v0.18.1 or v0.19.0-b1 (the version we released on Oct 22), but it _is_ raised when I install dbt from the current development branch `dev/kiyoshi-kuromiya`.

### Steps To Reproduce

Create a model and an exposure with the same name, and define resource properties for both:
```yml
version: 2

models:
  - name: metrics
    description: My metrics model

exposures:
  - name: metrics
    type: dashboard
    description: My metrics dashboard
    depends_on:
      - ref('metrics')
    owner:
      email: data@acme.com
```

Try running any command, e.g. `dbt compile`:
```
  dbt found two schema.yml entries for the same resource named metrics. Resources and their associated columns may only be described a single time. To fix this, remove one of the resource entries for metrics in this file:
   - models/metrics/schema.yml
```

### Expected behavior
Since `exposures` are not included in the set of `nodes` (models, seeds, snapshots, tests) but rather their own resource type (like `sources`), it should be possible to define properties for an exposure and a model with the same name.

Otherwise, this will be a breaking change in v0.19.0.

**The output of `dbt --version`:**
```
$ dbt --version
installed version: 0.19.0-b1
```
i.e. current development version in `dev/kiyoshi-kuromiya`


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
@@ -406,7 +406,7 @@ class ExposureOwner(JsonSchemaMixin, Replaceable):
 
 
 @dataclass
-class UnparsedExposure(HasYamlMetadata, Replaceable):
+class UnparsedExposure(JsonSchemaMixin, Replaceable):
     name: str
     type: ExposureType
     owner: ExposureOwner
diff --git a/core/dbt/parser/schemas.py b/core/dbt/parser/schemas.py
--- a/core/dbt/parser/schemas.py
+++ b/core/dbt/parser/schemas.py
@@ -95,6 +95,7 @@ def error_context(
 
 class ParserRef:
     """A helper object to hold parse-time references."""
+
     def __init__(self):
         self.column_info: Dict[str, ColumnInfo] = {}
 
@@ -584,6 +585,10 @@ def parse_file(self, block: FileBlock) -> None:
                     parser = MacroPatchParser(self, yaml_block, plural)
                 elif key == NodeType.Analysis:
                     parser = AnalysisPatchParser(self, yaml_block, plural)
+                elif key == NodeType.Exposure:
+                    # handle exposures separately, but they are
+                    # technically still "documentable"
+                    continue
                 else:
                     parser = TestablePatchParser(self, yaml_block, plural)
                 for test_block in parser.parse():


Test patch:
diff --git a/test/integration/025_duplicate_model_test/models-exposure-dupes/schema.yml b/test/integration/025_duplicate_model_test/models-exposure-dupes/schema.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/025_duplicate_model_test/models-exposure-dupes/schema.yml
@@ -0,0 +1,10 @@
+version: 2
+exposures:
+  - name: something
+    type: dashboard
+    owner:
+      email: test@example.com
+  - name: something
+    type: dashboard
+    owner:
+      email: test@example.com
diff --git a/test/integration/025_duplicate_model_test/models-naming-dupes/schema.yml b/test/integration/025_duplicate_model_test/models-naming-dupes/schema.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/025_duplicate_model_test/models-naming-dupes/schema.yml
@@ -0,0 +1,9 @@
+version: 2
+models:
+  - name: something
+    description: This table has basic information about orders, as well as some derived facts based on payments
+exposures:
+  - name: something
+    type: dashboard
+    owner:
+      email: test@example.com
diff --git a/test/integration/025_duplicate_model_test/test_duplicate_exposure.py b/test/integration/025_duplicate_model_test/test_duplicate_exposure.py
new file mode 100644
--- /dev/null
+++ b/test/integration/025_duplicate_model_test/test_duplicate_exposure.py
@@ -0,0 +1,23 @@
+from dbt.exceptions import CompilationException
+from test.integration.base import DBTIntegrationTest, use_profile
+
+
+class TestDuplicateExposure(DBTIntegrationTest):
+
+    @property
+    def schema(self):
+        return "duplicate_exposure_025"
+
+    @property
+    def models(self):
+        return "models-exposure-dupes"
+
+    @use_profile("postgres")
+    def test_postgres_duplicate_exposure(self):
+        message = "dbt found two resources with the name"
+        try:
+            self.run_dbt(["compile"])
+            self.assertTrue(False, "dbt did not throw for duplicate exposures")
+        except CompilationException as e:
+            self.assertTrue(message in str(
+                e), "dbt did not throw the correct error message")
diff --git a/test/integration/025_duplicate_model_test/test_duplicate_model.py b/test/integration/025_duplicate_model_test/test_duplicate_model.py
--- a/test/integration/025_duplicate_model_test/test_duplicate_model.py
+++ b/test/integration/025_duplicate_model_test/test_duplicate_model.py
@@ -12,26 +12,6 @@ def schema(self):
     def models(self):
         return "models-1"
 
-    @property
-    def profile_config(self):
-        return {
-            "test": {
-                "outputs": {
-                    "dev": {
-                        "type": "postgres",
-                        "threads": 1,
-                        "host": self.database_host,
-                        "port": 5432,
-                        "user": "root",
-                        "pass": "password",
-                        "dbname": "dbt",
-                        "schema": self.unique_schema()
-                    },
-                },
-                "target": "dev"
-            }
-        }
-
     @use_profile("postgres")
     def test_postgres_duplicate_model_enabled(self):
         message = "dbt found two resources with the name"
@@ -39,7 +19,8 @@ def test_postgres_duplicate_model_enabled(self):
             self.run_dbt(["run"])
             self.assertTrue(False, "dbt did not throw for duplicate models")
         except CompilationException as e:
-            self.assertTrue(message in str(e), "dbt did not throw the correct error message")
+            self.assertTrue(message in str(
+                e), "dbt did not throw the correct error message")
 
 
 class TestDuplicateModelDisabled(DBTIntegrationTest):
@@ -52,26 +33,6 @@ def schema(self):
     def models(self):
         return "models-2"
 
-    @property
-    def profile_config(self):
-        return {
-            "test": {
-                "outputs": {
-                    "dev": {
-                        "type": "postgres",
-                        "threads": 1,
-                        "host": self.database_host,
-                        "port": 5432,
-                        "user": "root",
-                        "pass": "password",
-                        "dbname": "dbt",
-                        "schema": self.unique_schema()
-                    },
-                },
-                "target": "dev"
-            }
-        }
-
     @use_profile("postgres")
     def test_postgres_duplicate_model_disabled(self):
         try:
@@ -124,7 +85,8 @@ def test_postgres_duplicate_model_enabled_across_packages(self):
             self.run_dbt(["run"])
             self.assertTrue(False, "dbt did not throw for duplicate models")
         except CompilationException as e:
-            self.assertTrue(message in str(e), "dbt did not throw the correct error message")
+            self.assertTrue(message in str(
+                e), "dbt did not throw the correct error message")
 
 
 class TestDuplicateModelDisabledAcrossPackages(DBTIntegrationTest):
diff --git a/test/integration/025_duplicate_model_test/test_duplicate_resource.py b/test/integration/025_duplicate_model_test/test_duplicate_resource.py
new file mode 100644
--- /dev/null
+++ b/test/integration/025_duplicate_model_test/test_duplicate_resource.py
@@ -0,0 +1,21 @@
+from dbt.exceptions import CompilationException
+from test.integration.base import DBTIntegrationTest, use_profile
+
+
+class TestDuplicateSchemaResource(DBTIntegrationTest):
+
+    @property
+    def schema(self):
+        return "duplicate_resource_025"
+
+    @property
+    def models(self):
+        return "models-naming-dupes-1"
+
+    @use_profile("postgres")
+    def test_postgres_duplicate_model_and_exposure(self):
+        try:
+            self.run_dbt(["compile"])
+        except CompilationException:
+            self.fail("Compilation Exception raised on model and "
+                      "exposure with the same name")
diff --git a/test/integration/025_duplicate_model_test/test_duplicate_source.py b/test/integration/025_duplicate_model_test/test_duplicate_source.py
--- a/test/integration/025_duplicate_model_test/test_duplicate_source.py
+++ b/test/integration/025_duplicate_model_test/test_duplicate_source.py
@@ -12,31 +12,12 @@ def schema(self):
     def models(self):
         return "models-source-dupes"
 
-    @property
-    def profile_config(self):
-        return {
-            "test": {
-                "outputs": {
-                    "dev": {
-                        "type": "postgres",
-                        "threads": 1,
-                        "host": self.database_host,
-                        "port": 5432,
-                        "user": "root",
-                        "pass": "password",
-                        "dbname": "dbt",
-                        "schema": self.unique_schema()
-                    },
-                },
-                "target": "dev"
-            }
-        }
-
     @use_profile("postgres")
-    def test_postgres_duplicate_model_enabled(self):
+    def test_postgres_duplicate_source_enabled(self):
         message = "dbt found two resources with the name"
         try:
             self.run_dbt(["compile"])
             self.assertTrue(False, "dbt did not throw for duplicate sources")
         except CompilationException as e:
-            self.assertTrue(message in str(e), "dbt did not throw the correct error message")
+            self.assertTrue(message in str(
+                e), "dbt did not throw the correct error message")
diff --git a/test/unit/test_contracts_graph_unparsed.py b/test/unit/test_contracts_graph_unparsed.py
--- a/test/unit/test_contracts_graph_unparsed.py
+++ b/test/unit/test_contracts_graph_unparsed.py
@@ -579,7 +579,6 @@ class TestUnparsedExposure(ContractTestCase):
 
     def get_ok_dict(self):
         return {
-        	'yaml_key': 'exposures',
             'name': 'my_exposure',
             'type': 'dashboard',
             'owner': {
@@ -592,13 +591,10 @@ def get_ok_dict(self):
                 'ref("my_model")',
                 'source("raw", "source_table")',
             ],
-            'original_file_path': '/some/fake/path',
-            'package_name': 'test'
         }
 
     def test_ok(self):
         exposure = self.ContractType(
-        	yaml_key='exposures',
             name='my_exposure',
             type=ExposureType.Dashboard,
             owner=ExposureOwner(email='name@example.com'),
@@ -606,8 +602,6 @@ def test_ok(self):
             url='https://example.com/dashboards/1',
             description='A exposure',
             depends_on=['ref("my_model")', 'source("raw", "source_table")'],
-            original_file_path='/some/fake/path',
-            package_name='test'
         )
         dct = self.get_ok_dict()
         self.assert_symmetric(exposure, dct)