Skip to content

Commit 9b085db

Browse files
committed
fix(bigquery): merge labels instead of clobbering
Merge labels across config levels instead of clobbering, so labels set for a whole folder in dbt_project.yml combine with model-specific labels. More specific levels win per label key. Implemented by using MergeBehavior from dbt_common.
1 parent 38bd0f6 commit 9b085db

4 files changed

Lines changed: 149 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixes
2+
body: Merge `labels` across config levels instead of clobbering, so labels set for a whole folder in dbt_project.yml combine with model-specific labels. More specific levels win per label key.
3+
time: 2026-08-07T14:17:38.959005+02:00
4+
custom:
5+
Author: yochem
6+
Issue: "15671"

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import copy
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field as dataclass_field
33
from datetime import datetime
44
from multiprocessing.context import SpawnContext
55
import threading
@@ -34,6 +34,7 @@
3434
ConstraintType,
3535
ModelLevelConstraint,
3636
)
37+
from dbt_common.contracts.config.base import MergeBehavior
3738
from dbt_common.dataclass_schema import dbtClassMixin
3839
from dbt_common.events.functions import fire_event
3940
import dbt_common.exceptions
@@ -175,7 +176,9 @@ class BigqueryConfig(AdapterConfig):
175176
cluster_by: Optional[Union[List[str], str]] = None
176177
partition_by: Optional[Dict[str, Any]] = None
177178
kms_key_name: Optional[str] = None
178-
labels: Optional[Dict[str, str]] = None
179+
labels: Optional[Dict[str, str]] = dataclass_field(
180+
default=None, metadata=MergeBehavior.Update.meta()
181+
)
179182
partitions: Optional[List[str]] = None
180183
grant_access_to: Optional[List[Dict[str, str]]] = None
181184
hours_to_expiration: Optional[int] = None

dbt-bigquery/tests/unit/test_bigquery_adapter.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from google.cloud.bigquery import AccessEntry
2929

30+
from .test_bigquery_label_merge import merge_config_levels
3031
from .utils import (
3132
config_from_parts_or_dicts,
3233
inject_adapter,
@@ -881,6 +882,30 @@ def test_get_common_options_labels_merge(self):
881882
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
882883
self.assertEqual(expected, actual)
883884

885+
def test_get_common_options_labels_merged_across_config_levels(self):
886+
adapter = self.get_adapter("oauth")
887+
mock_config = create_autospec(RuntimeConfigObject)
888+
# labels set in dbt_project.yml at the project level, then the folder
889+
# level, then in the model's own config() block
890+
config = merge_config_levels(
891+
{"labels_from_meta": True, "meta": {"meta_label": "value2"}},
892+
{"labels": {"dbt_project": "labelstest"}},
893+
{"labels": {"layer": "intermediate"}},
894+
{"labels": {"schedule": "daily", "dbt_project": ""}},
895+
)
896+
mock_config.get.side_effect = lambda name: config.get(name)
897+
898+
expected = {
899+
"labels": [
900+
("meta_label", "value2"),
901+
("dbt_project", ""),
902+
("layer", "intermediate"),
903+
("schedule", "daily"),
904+
]
905+
}
906+
actual = adapter.get_common_options(mock_config, node={}, temporary=False)
907+
self.assertEqual(expected, actual)
908+
884909
def test_get_common_options_labels_no_meta(self):
885910
adapter = self.get_adapter("oauth")
886911
mock_config = create_autospec(RuntimeConfigObject)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import unittest
2+
3+
from dbt.contracts.graph.model_config import ModelConfig
4+
5+
from dbt.adapters.bigquery.impl import BigqueryConfig
6+
7+
8+
def merge_config_levels(*levels):
9+
"""Layer configs from least to most specific and return the result as a dict.
10+
11+
Mirrors what dbt-core's ContextConfigGenerator does at parse time: each
12+
dbt_project.yml level (walking down the model's fqn) is applied in turn,
13+
followed by the schema.yml patch and finally the model's own config() block.
14+
"""
15+
config = ModelConfig.from_dict({})
16+
for level in levels:
17+
config = config.update_from(level, BigqueryConfig, validate=False)
18+
return config.to_dict(omit_none=True)
19+
20+
21+
class TestBigQueryLabelMerge(unittest.TestCase):
22+
def test_labels_merge_across_levels(self):
23+
config = merge_config_levels(
24+
{"labels": {"dbt_project": "labelstest"}},
25+
{"labels": {"layer": "intermediate"}},
26+
{"labels": {"schedule": "daily"}},
27+
)
28+
29+
self.assertEqual(
30+
config["labels"],
31+
{
32+
"dbt_project": "labelstest",
33+
"layer": "intermediate",
34+
"schedule": "daily",
35+
},
36+
)
37+
38+
def test_model_label_wins_over_project_label(self):
39+
config = merge_config_levels(
40+
{"labels": {"layer": "staging", "dbt_project": "labelstest"}},
41+
{"labels": {"layer": "intermediate"}},
42+
)
43+
44+
self.assertEqual(
45+
config["labels"],
46+
{"layer": "intermediate", "dbt_project": "labelstest"},
47+
)
48+
49+
def test_labels_only_set_at_one_level(self):
50+
self.assertEqual(
51+
merge_config_levels({"labels": {"layer": "intermediate"}}, {})["labels"],
52+
{"layer": "intermediate"},
53+
)
54+
self.assertEqual(
55+
merge_config_levels({}, {"labels": {"schedule": "daily"}})["labels"],
56+
{"schedule": "daily"},
57+
)
58+
59+
def test_labels_absent_when_never_configured(self):
60+
self.assertNotIn("labels", merge_config_levels({}, {"materialized": "table"}))
61+
62+
def test_empty_dict_does_not_clear_inherited_labels(self):
63+
config = merge_config_levels(
64+
{"labels": {"layer": "intermediate"}},
65+
{"labels": {}},
66+
)
67+
68+
self.assertEqual(config["labels"], {"layer": "intermediate"})
69+
70+
def test_empty_string_value_is_preserved(self):
71+
config = merge_config_levels(
72+
{"labels": {"dbt_project": "labelstest"}},
73+
{"labels": {"schedule": ""}},
74+
)
75+
76+
self.assertEqual(config["labels"], {"dbt_project": "labelstest", "schedule": ""})
77+
78+
def test_empty_string_value_overrides_inherited_value(self):
79+
config = merge_config_levels(
80+
{"labels": {"schedule": "daily"}},
81+
{"labels": {"schedule": ""}},
82+
)
83+
84+
self.assertEqual(config["labels"], {"schedule": ""})
85+
86+
def test_empty_string_value_is_overridden_by_more_specific_level(self):
87+
config = merge_config_levels(
88+
{"labels": {"schedule": ""}},
89+
{"labels": {"schedule": "daily"}},
90+
)
91+
92+
self.assertEqual(config["labels"], {"schedule": "daily"})
93+
94+
def test_labels_merge_alongside_meta(self):
95+
"""`labels_from_meta` reads `meta`, which merges across levels as well."""
96+
config = merge_config_levels(
97+
{"labels_from_meta": True, "meta": {"owner": "analytics"}},
98+
{"meta": {"layer": "intermediate"}, "labels": {"dbt_project": "labelstest"}},
99+
{"labels": {"schedule": "daily"}},
100+
)
101+
102+
self.assertTrue(config["labels_from_meta"])
103+
self.assertEqual(config["meta"], {"owner": "analytics", "layer": "intermediate"})
104+
self.assertEqual(config["labels"], {"dbt_project": "labelstest", "schedule": "daily"})
105+
106+
def test_other_dict_configs_still_clobber(self):
107+
"""Only `labels` gained merge behavior; `partition_by` is unchanged."""
108+
config = merge_config_levels(
109+
{"partition_by": {"field": "created_at", "data_type": "timestamp"}},
110+
{"partition_by": {"field": "updated_at"}},
111+
)
112+
113+
self.assertEqual(config["partition_by"], {"field": "updated_at"})

0 commit comments

Comments
 (0)