Skip to content

Commit 3325afe

Browse files
tnk-yskcolin-rogers-dbtVersusFacit
authored
Add BigQuery resource tags support via resource_tags config option (#1177)
Co-authored-by: Colin Rogers <[email protected]> Co-authored-by: Mila Page <[email protected]>
1 parent 5fba80c commit 3325afe

File tree

6 files changed

+380
-3
lines changed

6 files changed

+380
-3
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Add support for BigQuery resource tags via `resource_tags` configuration option
3+
time: 2025-06-27T18:12:43.109922+09:00
4+
custom:
5+
Author: tnk-ysk
6+
Issue: 565 577

dbt-bigquery/src/dbt/adapters/bigquery/impl.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,9 @@ def get_common_options(
783783
if labels:
784784
opts["labels"] = list(labels.items()) # type: ignore[assignment]
785785

786+
if resource_tags := config.get("resource_tags"):
787+
opts["tags"] = list(resource_tags.items()) # type: ignore[assignment]
788+
786789
return opts
787790

788791
@available.parse(lambda *a, **k: {})

dbt-bigquery/src/dbt/adapters/bigquery/relation_configs/_options.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from dataclasses import dataclass
1+
from dataclasses import dataclass, field
22
from datetime import datetime, timedelta
3+
from types import MappingProxyType
34
from typing import Any, Dict, Optional
45

56
from dbt.adapters.relation_configs import RelationConfigChange
@@ -11,7 +12,7 @@
1112
from dbt.adapters.bigquery.utility import bool_setting, float_setting, sql_escape
1213

1314

14-
@dataclass(frozen=True, eq=True, unsafe_hash=True)
15+
@dataclass(frozen=True, eq=True)
1516
class BigQueryOptionsConfig(BigQueryBaseRelationConfig):
1617
"""
1718
This config manages materialized view options. See the following for more information:
@@ -24,7 +25,21 @@ class BigQueryOptionsConfig(BigQueryBaseRelationConfig):
2425
max_staleness: Optional[str] = None
2526
kms_key_name: Optional[str] = None
2627
description: Optional[str] = None
27-
labels: Optional[Dict[str, str]] = None
28+
labels: MappingProxyType = field(default_factory=lambda: MappingProxyType({}))
29+
tags: MappingProxyType = field(default_factory=lambda: MappingProxyType({}))
30+
31+
def __hash__(self) -> int:
32+
"""Custom hash method to handle unhashable dict fields."""
33+
hashable_fields = []
34+
for field_name, field_value in self.__dict__.items():
35+
if isinstance(field_value, (dict, MappingProxyType)):
36+
# Convert dict/MappingProxyType to sorted tuple of items for hashing
37+
hashable_fields.append(
38+
(field_name, tuple(sorted(field_value.items())) if field_value else None)
39+
)
40+
else:
41+
hashable_fields.append((field_name, field_value))
42+
return hash(tuple(hashable_fields))
2843

2944
def as_ddl_dict(self) -> Dict[str, Any]:
3045
"""
@@ -61,6 +76,7 @@ def array(x):
6176
"kms_key_name": string,
6277
"description": escaped_string,
6378
"labels": array,
79+
"tags": array,
6480
}
6581

6682
def formatted_option(name: str) -> Optional[Any]:
@@ -88,6 +104,7 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> Self:
88104
"kms_key_name": None,
89105
"description": None,
90106
"labels": None,
107+
"tags": None,
91108
}
92109

93110
def formatted_setting(name: str) -> Any:
@@ -131,6 +148,10 @@ def parse_relation_config(cls, relation_config: RelationConfig) -> Dict[str, Any
131148
if not relation_config.config.persist_docs: # type:ignore
132149
del config_dict["description"]
133150

151+
# Handle resource_tags if present
152+
if resource_tags := relation_config.config.extra.get("resource_tags"): # type:ignore
153+
config_dict.update({"tags": resource_tags})
154+
134155
return config_dict
135156

136157
@classmethod
@@ -147,6 +168,10 @@ def parse_bq_table(cls, table: BigQueryTable) -> Dict[str, Any]:
147168
if labels := table.labels:
148169
config_dict.update({"labels": labels})
149170

171+
# Handle tags if they exist on the table
172+
if hasattr(table, "resource_tags") and table.resource_tags:
173+
config_dict.update({"tags": table.resource_tags})
174+
150175
if encryption_configuration := table.encryption_configuration:
151176
config_dict.update({"kms_key_name": encryption_configuration.kms_key_name})
152177
return config_dict

dbt-bigquery/tests/unit/test_bigquery_adapter.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,59 @@ def test_get_common_options_empty(self):
932932
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
933933
self.assertEqual(expected, actual)
934934

935+
def test_get_common_options_resource_tags(self):
936+
adapter = self.get_adapter("oauth")
937+
mock_config = create_autospec(RuntimeConfigObject)
938+
config = {
939+
"resource_tags": {"test-project/env": "dev", "test-project/team": "data"},
940+
}
941+
mock_config.get.side_effect = lambda name, default=None: config.get(name, default)
942+
943+
expected = {"tags": [("test-project/env", "dev"), ("test-project/team", "data")]}
944+
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
945+
# Compare as sets since tag order depends on dictionary ordering
946+
self.assertEqual(set(actual["tags"]), set(expected["tags"]))
947+
948+
def test_get_common_options_resource_tags_empty(self):
949+
adapter = self.get_adapter("oauth")
950+
mock_config = create_autospec(RuntimeConfigObject)
951+
config = {
952+
"resource_tags": {},
953+
}
954+
mock_config.get.side_effect = lambda name, default=None: config.get(name, default)
955+
956+
expected = {}
957+
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
958+
self.assertEqual(expected, actual)
959+
960+
def test_get_common_options_resource_tags_and_labels(self):
961+
adapter = self.get_adapter("oauth")
962+
mock_config = create_autospec(RuntimeConfigObject)
963+
config = {
964+
"labels": {"label_key": "label_value"},
965+
"resource_tags": {"test-project/tag_key": "tag_value"},
966+
}
967+
mock_config.get.side_effect = lambda name, default=None: config.get(name, default)
968+
969+
expected_labels = [("label_key", "label_value")]
970+
expected_tags = [("test-project/tag_key", "tag_value")]
971+
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
972+
973+
self.assertEqual(set(actual["labels"]), set(expected_labels))
974+
self.assertEqual(set(actual["tags"]), set(expected_tags))
975+
976+
def test_get_common_options_resource_tags_none(self):
977+
adapter = self.get_adapter("oauth")
978+
mock_config = create_autospec(RuntimeConfigObject)
979+
config = {
980+
"resource_tags": None,
981+
}
982+
mock_config.get.side_effect = lambda name, default=None: config.get(name, default)
983+
984+
expected = {}
985+
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
986+
self.assertEqual(expected, actual)
987+
935988

936989
class TestBigQueryFilterCatalog(unittest.TestCase):
937990
def test__catalog_filter_table(self):
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import unittest
2+
3+
from dbt.adapters.bigquery.relation_configs._options import BigQueryOptionsConfig
4+
5+
6+
class TestBigQueryOptionsConfig(unittest.TestCase):
7+
"""Unit tests for BigQuery options config with tags"""
8+
9+
def test_from_dict_with_tags(self):
10+
"""Test creating BigQueryOptionsConfig from dict with tags"""
11+
config_dict = {
12+
"enable_refresh": True,
13+
"refresh_interval_minutes": 45,
14+
"description": "Test materialized view",
15+
"labels": {"env": "dev", "version": "1.0"},
16+
"tags": {"test-project/team": "data", "test-project/cost_center": "analytics"},
17+
}
18+
19+
options = BigQueryOptionsConfig.from_dict(config_dict)
20+
21+
# Verify all fields are correctly set
22+
self.assertTrue(options.enable_refresh)
23+
self.assertEqual(options.refresh_interval_minutes, 45)
24+
self.assertEqual(options.description, "Test materialized view")
25+
self.assertEqual(options.labels, {"env": "dev", "version": "1.0"})
26+
self.assertEqual(
27+
options.tags, {"test-project/team": "data", "test-project/cost_center": "analytics"}
28+
)
29+
30+
def test_as_ddl_dict_with_tags(self):
31+
"""Test generating DDL dict with tags"""
32+
options = BigQueryOptionsConfig(
33+
enable_refresh=True,
34+
refresh_interval_minutes=30,
35+
description="Test MV",
36+
labels={"env": "test"},
37+
tags={"test-project/team": "data"},
38+
)
39+
40+
ddl_dict = options.as_ddl_dict()
41+
42+
# Verify the output format
43+
self.assertTrue(ddl_dict["enable_refresh"])
44+
self.assertEqual(ddl_dict["refresh_interval_minutes"], 30)
45+
self.assertEqual(ddl_dict["description"], '"""Test MV"""')
46+
47+
# Check labels and tags are properly formatted as arrays of tuples
48+
expected_labels = [("env", "test")]
49+
expected_tags = [("test-project/team", "data")]
50+
51+
self.assertEqual(ddl_dict["labels"], expected_labels)
52+
self.assertEqual(ddl_dict["tags"], expected_tags)
53+
54+
def test_tags_none_not_included_in_ddl(self):
55+
"""Test that None tags are not included in DDL dict"""
56+
options = BigQueryOptionsConfig(enable_refresh=True, tags=None)
57+
58+
ddl_dict = options.as_ddl_dict()
59+
60+
# tags should not be in the output when None
61+
self.assertNotIn("tags", ddl_dict)
62+
self.assertIn("enable_refresh", ddl_dict)
63+
64+
def test_parse_relation_config_with_resource_tags(self):
65+
"""Test parse_relation_config method handles resource_tags correctly"""
66+
from unittest.mock import Mock
67+
68+
# Mock relation config with resource_tags
69+
mock_relation_config = Mock()
70+
mock_config = Mock()
71+
mock_config.extra = {
72+
"enable_refresh": True,
73+
"refresh_interval_minutes": 30,
74+
"description": "Test view",
75+
"labels": {"env": "test"},
76+
"resource_tags": {"test-project/team": "data", "test-project/project": "analytics"},
77+
}
78+
mock_config.persist_docs = True
79+
mock_relation_config.config = mock_config
80+
81+
config_dict = BigQueryOptionsConfig.parse_relation_config(mock_relation_config)
82+
83+
# Verify resource_tags gets mapped to tags
84+
self.assertEqual(
85+
config_dict["tags"], {"test-project/team": "data", "test-project/project": "analytics"}
86+
)
87+
self.assertEqual(config_dict["labels"], {"env": "test"})
88+
self.assertEqual(config_dict["description"], "Test view")
89+
self.assertTrue(config_dict["enable_refresh"])
90+
91+
def test_parse_relation_config_without_resource_tags(self):
92+
"""Test parse_relation_config when resource_tags is not present"""
93+
from unittest.mock import Mock
94+
95+
# Mock relation config without resource_tags
96+
mock_relation_config = Mock()
97+
mock_config = Mock()
98+
mock_config.extra = {"enable_refresh": False, "labels": {"env": "prod"}}
99+
mock_config.persist_docs = True
100+
mock_relation_config.config = mock_config
101+
102+
config_dict = BigQueryOptionsConfig.parse_relation_config(mock_relation_config)
103+
104+
# Verify tags is not in the config_dict when resource_tags is not present
105+
self.assertNotIn("tags", config_dict)
106+
self.assertEqual(config_dict["labels"], {"env": "prod"})
107+
self.assertFalse(config_dict["enable_refresh"])
108+
109+
110+
if __name__ == "__main__":
111+
unittest.main()

0 commit comments

Comments
 (0)