Skip to content

Commit aac2f2f

Browse files
committed
fix(bigquery): wrap model in subquery before aliasing in test_unique/not_null
`get_where_subquery` renders `model` as `(select * from <rel> where ...) dbt_subquery` for `where:`-filtered generic tests. The previous form `from {{ model }} dbt_test__source` produced an invalid double alias `(...) dbt_subquery dbt_test__source` in that case. Wrap `model` in `(select * from {{ model }})` before applying the `dbt_test__source` alias so the macros work whether `model` is a bare relation or an already-aliased subquery. Add a regression test that exercises `unique` and `not_null` with the column/table name collision under a `where:` config, and switch the existing test's result lookup to prefix matching so it surfaces a clear error if generic-test node naming changes.
1 parent f22945d commit aac2f2f

3 files changed

Lines changed: 99 additions & 16 deletions

File tree

dbt-bigquery/.changes/unreleased/Fixes-20260427-083810.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
kind: Fixes
22
body: Alias the source relation in `bigquery__test_unique` and add a `bigquery__test_not_null`
33
override so that columns sharing a name with their model resolve to the column
4-
value instead of BigQuery's row STRUCT, fixing dbt-core#11067.
4+
value instead of BigQuery's row STRUCT. The relation is wrapped in a subquery
5+
before aliasing so `where:`-filtered tests, where `get_where_subquery` already
6+
emits a `dbt_subquery` alias, still produce valid SQL. Fixes dbt-core#11067.
57
time: 2026-04-27T08:38:10.000000+00:00
68
custom:
79
Author: "1fanwang"

dbt-bigquery/src/dbt/include/bigquery/macros/adapters.sql

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,17 @@
211211

212212
{# When a column has the same name as its table, BigQuery resolves an unqualified
213213
column reference to the row STRUCT instead of the column value. Aliasing the
214-
relation and qualifying the column reference forces resolution to the column. #}
214+
relation and qualifying the column reference forces resolution to the column.
215+
`model` is wrapped in a subquery before aliasing because `get_where_subquery`
216+
may already render it as `(...) dbt_subquery` for `where:`-filtered tests; a
217+
bare `{{ model }} dbt_test__source` would then produce an invalid double
218+
alias. #}
215219
{% macro bigquery__test_unique(model, column_name) %}
216220

217221
with dbt_test__target as (
218222

219223
select dbt_test__source.{{ column_name }} as unique_field
220-
from {{ model }} dbt_test__source
224+
from (select * from {{ model }}) dbt_test__source
221225
where dbt_test__source.{{ column_name }} is not null
222226

223227
)
@@ -232,14 +236,16 @@ having count(*) > 1
232236

233237
{% endmacro %}
234238

235-
{# Same row-vs-column resolution issue as bigquery__test_unique: alias the
236-
relation and qualify the column reference. #}
239+
{# Same row-vs-column resolution issue as bigquery__test_unique. `model` is
240+
wrapped in a subquery before aliasing so `where:`-filtered tests, where
241+
`get_where_subquery` already adds a `dbt_subquery` alias, do not produce an
242+
invalid double alias. #}
237243
{% macro bigquery__test_not_null(model, column_name) %}
238244

239245
{% set column_list = '*' if should_store_failures() else 'dbt_test__source.' ~ column_name %}
240246

241247
select {{ column_list }}
242-
from {{ model }} dbt_test__source
248+
from (select * from {{ model }}) dbt_test__source
243249
where dbt_test__source.{{ column_name }} is null
244250

245251
{% endmacro %}

dbt-bigquery/tests/functional/adapter/generic_tests/test_column_table_name_collision.py

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,46 @@
3636
""".lstrip()
3737

3838

39+
# Same column/table name collision but with a `where:` filter on each test.
40+
# `get_where_subquery` renders `model` as `(select * from <rel> where ...)
41+
# dbt_subquery`; the BigQuery overrides must still compile and execute against
42+
# that already-aliased subquery.
43+
_SCHEMA_WHERE_YML = """
44+
version: 2
45+
models:
46+
- name: orders
47+
columns:
48+
- name: orders
49+
data_tests:
50+
- unique:
51+
config:
52+
where: "orders is not null"
53+
- not_null:
54+
config:
55+
where: "orders is not null or orders is null"
56+
""".lstrip()
57+
58+
59+
def _result_for_prefix(results, prefix):
60+
"""Look up a test result by node-name prefix.
61+
62+
Generic test node names (`unique_<model>_<column>` etc.) are stable today
63+
but tying assertions to the literal name makes the test brittle across dbt
64+
versions if naming gains suffixes. Match on prefix and surface a clear
65+
error if the expected result is missing.
66+
"""
67+
matches = [r for r in results if r.node.name.startswith(prefix)]
68+
assert matches, (
69+
f"expected a test result whose node name starts with {prefix!r}; "
70+
f"found: {sorted(r.node.name for r in results)}"
71+
)
72+
assert len(matches) == 1, (
73+
f"expected exactly one result for prefix {prefix!r}; "
74+
f"got: {[r.node.name for r in matches]}"
75+
)
76+
return matches[0]
77+
78+
3979
class TestBigQueryGenericTestsColumnTableNameCollision:
4080
@pytest.fixture(scope="class")
4181
def models(self):
@@ -50,18 +90,53 @@ def test_unique_and_not_null_detect_failures(self, project):
5090

5191
assert len(results) == 2
5292

53-
statuses = {r.node.name: (r.status, r.failures) for r in results}
54-
55-
unique_status, unique_failures = statuses["unique_orders_orders"]
56-
assert unique_status == "fail", (
57-
f"unique test passed unexpectedly (status={unique_status}); "
93+
unique_result = _result_for_prefix(results, "unique_")
94+
assert unique_result.status == "fail", (
95+
f"unique test passed unexpectedly (status={unique_result.status}); "
5896
"with the bug, the row-struct grouping can mask duplicates"
5997
)
60-
assert unique_failures >= 1
98+
assert unique_result.failures >= 1
6199

62-
not_null_status, not_null_failures = statuses["not_null_orders_orders"]
63-
assert not_null_status == "fail", (
64-
f"not_null test passed unexpectedly (status={not_null_status}); "
100+
not_null_result = _result_for_prefix(results, "not_null_")
101+
assert not_null_result.status == "fail", (
102+
f"not_null test passed unexpectedly (status={not_null_result.status}); "
65103
"with the bug, the row-struct nullness check masks the null column value"
66104
)
67-
assert not_null_failures >= 1
105+
assert not_null_result.failures >= 1
106+
107+
108+
class TestBigQueryGenericTestsColumnTableNameCollisionWithWhere:
109+
"""`where:`-filtered tests render `model` as `(select * from <rel> where ...)
110+
dbt_subquery`. The BigQuery overrides wrap `model` in another subquery
111+
before aliasing so this case still produces valid SQL."""
112+
113+
@pytest.fixture(scope="class")
114+
def models(self):
115+
return {
116+
"orders.sql": _MODEL_SQL,
117+
"schema.yml": _SCHEMA_WHERE_YML,
118+
}
119+
120+
def test_unique_and_not_null_compile_with_where_filter(self, project):
121+
run_dbt(["run"])
122+
# `expect_pass=False` because the `not_null` test still observes the
123+
# null row (its `where:` keeps null values); the `unique` test still
124+
# sees duplicates of `orders=1`. The important assertion is that the
125+
# SQL compiles and runs at all when `model` is an aliased subquery.
126+
results = run_dbt(["test"], expect_pass=False)
127+
128+
assert len(results) == 2
129+
130+
unique_result = _result_for_prefix(results, "unique_")
131+
assert unique_result.status == "fail", (
132+
f"unique test status was {unique_result.status}; "
133+
"expected fail because duplicate non-null orders remain after the where filter"
134+
)
135+
assert unique_result.failures >= 1
136+
137+
not_null_result = _result_for_prefix(results, "not_null_")
138+
assert not_null_result.status == "fail", (
139+
f"not_null test status was {not_null_result.status}; "
140+
"expected fail because the where filter retains the null row"
141+
)
142+
assert not_null_result.failures >= 1

0 commit comments

Comments
 (0)