Repository: dbt-labs/dbt-core

Issue:
Add "tags" and "meta" attributes to Exposure schema
### Describe the feature
I would like to be able to tag and add metadata to exposures, so that dbt can serve as a system of record for major data-related initiatives. The current options are solid, but we have additional metadata we would like to add -- primarily so that we can use the exposure data in downstream systems.

### Describe alternatives you've considered
We can use some of the existing data fields but additional flexibility would be nice.

### Additional context
This feature is mentioned by a few commenters in issue [2835](https://github.com/fishtown-analytics/dbt/issues/2835) but it does not look like action has been taken.

### Who will this benefit?
This will be useful for people using exposures and will also bring the object into closer alignment with other resources.

### Are you interested in contributing this feature?
Yes, I will link the PR.


Discussion:

Resolving patch:
diff --git a/core/dbt/contracts/graph/parsed.py b/core/dbt/contracts/graph/parsed.py
--- a/core/dbt/contracts/graph/parsed.py
+++ b/core/dbt/contracts/graph/parsed.py
@@ -687,6 +687,8 @@ class ParsedExposure(UnparsedBaseNode, HasUniqueID, HasFqn):
     resource_type: NodeType = NodeType.Exposure
     description: str = ''
     maturity: Optional[MaturityType] = None
+    meta: Dict[str, Any] = field(default_factory=dict)
+    tags: List[str] = field(default_factory=list)
     url: Optional[str] = None
     depends_on: DependsOn = field(default_factory=DependsOn)
     refs: List[List[str]] = field(default_factory=list)
@@ -700,11 +702,6 @@ def depends_on_nodes(self):
     def search_name(self):
         return self.name
 
-    # no tags for now, but we could definitely add them
-    @property
-    def tags(self):
-        return []
-
     def same_depends_on(self, old: 'ParsedExposure') -> bool:
         return set(self.depends_on.nodes) == set(old.depends_on.nodes)
 
@@ -725,6 +722,7 @@ def same_url(self, old: 'ParsedExposure') -> bool:
 
     def same_contents(self, old: Optional['ParsedExposure']) -> bool:
         # existing when it didn't before is a change!
+        # metadata/tags changes are not "changes"
         if old is None:
             return True
 
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
@@ -413,5 +413,7 @@ class UnparsedExposure(dbtClassMixin, Replaceable):
     owner: ExposureOwner
     description: str = ''
     maturity: Optional[MaturityType] = None
+    meta: Dict[str, Any] = field(default_factory=dict)
+    tags: List[str] = field(default_factory=list)
     url: Optional[str] = None
     depends_on: List[str] = field(default_factory=list)
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
@@ -992,6 +992,8 @@ def parse_exposure(self, unparsed: UnparsedExposure) -> ParsedExposure:
             name=unparsed.name,
             type=unparsed.type,
             url=unparsed.url,
+            meta=unparsed.meta,
+            tags=unparsed.tags,
             description=unparsed.description,
             owner=unparsed.owner,
             maturity=unparsed.maturity,


Test patch:
diff --git a/test/integration/029_docs_generate_tests/models/schema.yml b/test/integration/029_docs_generate_tests/models/schema.yml
--- a/test/integration/029_docs_generate_tests/models/schema.yml
+++ b/test/integration/029_docs_generate_tests/models/schema.yml
@@ -74,4 +74,9 @@ exposures:
     description: >
       A description of the complex exposure
     maturity: medium
+    meta:
+      tool: 'my_tool'
+      languages:
+        - python
+    tags: ['my_department']
     url: http://example.com/notebook/1
diff --git a/test/integration/029_docs_generate_tests/ref_models/schema.yml b/test/integration/029_docs_generate_tests/ref_models/schema.yml
--- a/test/integration/029_docs_generate_tests/ref_models/schema.yml
+++ b/test/integration/029_docs_generate_tests/ref_models/schema.yml
@@ -41,3 +41,8 @@ exposures:
     description: "{{ doc('notebook_info') }}"
     maturity: medium
     url: http://example.com/notebook/1
+    meta:
+      tool: 'my_tool'
+      languages:
+        - python
+    tags: ['my_department']
diff --git a/test/integration/029_docs_generate_tests/test_docs_generate.py b/test/integration/029_docs_generate_tests/test_docs_generate.py
--- a/test/integration/029_docs_generate_tests/test_docs_generate.py
+++ b/test/integration/029_docs_generate_tests/test_docs_generate.py
@@ -1566,6 +1566,8 @@ def expected_seeded_manifest(self, model_database=None, quote_model=False):
                     'description': 'A description of the complex exposure\n',
                     'fqn': ['test', 'notebook_exposure'],
                     'maturity': 'medium',
+                    'meta': {'tool': 'my_tool', 'languages': ['python']},
+                    'tags': ['my_department'],
                     'name': 'notebook_exposure',
                     'original_file_path': self.dir('models/schema.yml'),
                     'owner': {
@@ -1608,6 +1610,8 @@ def expected_seeded_manifest(self, model_database=None, quote_model=False):
                     'unique_id': 'exposure.test.simple_exposure',
                     'url': None,
                     'maturity': None,
+                    'meta': {},
+                    'tags': []
                 }
             },
             'selectors': {},
@@ -2002,6 +2006,8 @@ def expected_postgres_references_manifest(self, model_database=None):
                     'description': 'A description of the complex exposure',
                     'fqn': ['test', 'notebook_exposure'],
                     'maturity': 'medium',
+                    'meta': {'tool': 'my_tool', 'languages': ['python']},
+                    'tags': ['my_department'],
                     'name': 'notebook_exposure',
                     'original_file_path': self.dir('ref_models/schema.yml'),
                     'owner': {
diff --git a/test/unit/test_contracts_graph_parsed.py b/test/unit/test_contracts_graph_parsed.py
--- a/test/unit/test_contracts_graph_parsed.py
+++ b/test/unit/test_contracts_graph_parsed.py
@@ -2025,6 +2025,8 @@ def minimal_parsed_exposure_dict():
         'fqn': ['test', 'exposures', 'my_exposure'],
         'unique_id': 'exposure.test.my_exposure',
         'package_name': 'test',
+        'meta': {},
+        'tags': [],
         'path': 'models/something.yml',
         'root_path': '/usr/src/app',
         'original_file_path': 'models/something.yml',
@@ -2053,7 +2055,9 @@ def basic_parsed_exposure_dict():
         'path': 'models/something.yml',
         'root_path': '/usr/src/app',
         'original_file_path': 'models/something.yml',
-        'description': ''
+        'description': '',
+        'meta': {},
+        'tags': []
     }
 
 
@@ -2069,7 +2073,9 @@ def basic_parsed_exposure_object():
         root_path='/usr/src/app',
         original_file_path='models/something.yml',
         owner=ExposureOwner(email='test@example.com'),
-        description=''
+        description='',
+        meta={},
+        tags=[]
     )
 
 
@@ -2086,6 +2092,11 @@ def complex_parsed_exposure_dict():
         'maturity': 'low',
         'url': 'https://example.com/analyses/1',
         'description': 'my description',
+        'meta': {
+            'tool': 'my_tool',
+            'is_something': False
+        },
+        'tags': ['my_department'],
         'depends_on': {
             'nodes': ['models.test.my_model'],
             'macros': [],
@@ -2110,6 +2121,8 @@ def complex_parsed_exposure_object():
         maturity=MaturityType.Low,
         url='https://example.com/analyses/1',
         description='my description',
+        meta={'tool': 'my_tool', 'is_something': False},
+        tags=['my_department'],
         depends_on=DependsOn(nodes=['models.test.my_model']),
         fqn=['test', 'exposures', 'my_exposure'],
         unique_id='exposure.test.my_exposure',
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
@@ -585,6 +585,8 @@ def get_ok_dict(self):
                 'email': 'name@example.com',
             },
             'maturity': 'medium',
+            'meta': {'tool': 'my_tool'},
+            'tags': ['my_department'],
             'url': 'https://example.com/dashboards/1',
             'description': 'A exposure',
             'depends_on': [
@@ -601,6 +603,8 @@ def test_ok(self):
             maturity=MaturityType.Medium,
             url='https://example.com/dashboards/1',
             description='A exposure',
+            meta={'tool': 'my_tool'},
+            tags=['my_department'],
             depends_on=['ref("my_model")', 'source("raw", "source_table")'],
         )
         dct = self.get_ok_dict()
@@ -648,3 +652,8 @@ def test_bad_owner_missing_things(self):
 
         del tst['owner']
         self.assert_fails_validation(tst)
+
+    def test_bad_tags(self):
+        tst = self.get_ok_dict()
+        tst['tags'] = [123]
+        self.assert_fails_validation(tst)