Skip to content

Commit 09a1852

Browse files
katieclaiborne-duetcolin-k-rogersclaude
authored
Add support for pseudocolumns in unit tests (#1526)
Co-authored-by: Colin Rogers <111200756+colin-rogers-dbt@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 62a5495 commit 09a1852

13 files changed

Lines changed: 411 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: Features
2+
body: Update unit test column retrieval to support pseudocolumns
3+
time: 2026-01-18T11:14:37.820863-05:00
4+
custom:
5+
Author: katieclaiborne-duet
6+
Issue: "1517"

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
AdapterStandardizeGrantsDictRecord,
3434
AdapterListRelationsWithoutCachingRecord,
3535
AdapterGetColumnsInRelationRecord,
36+
AdapterGetPseudocolumnsForRelationRecord,
3637
SubmitPythonJobRecord,
3738
)
3839
from dbt_common.behavior_flags import Behavior, BehaviorFlag
@@ -820,6 +821,27 @@ def get_columns_in_relation(self, relation: BaseRelation) -> List[BaseColumn]:
820821
"""Get a list of the columns in the given Relation."""
821822
raise NotImplementedError("`get_columns_in_relation` is not implemented for this adapter!")
822823

824+
@record_function(
825+
AdapterGetPseudocolumnsForRelationRecord,
826+
method=True,
827+
index_on_thread_id=True,
828+
id_field_name="thread_id",
829+
)
830+
@available.parse_list
831+
def get_pseudocolumns_for_relation(self, relation: BaseRelation) -> List[BaseColumn]:
832+
"""Get a list of queryable pseudocolumns for the given relation.
833+
834+
Pseudocolumns are system-generated columns that can be queried but don't
835+
appear in the information schema (e.g., BigQuery's _FILE_NAME for external tables).
836+
837+
Default implementation returns an empty list. Adapters should override this
838+
to provide pseudocolumns specific to their platform.
839+
840+
:param relation: The relation to get pseudocolumns for
841+
:return: List of Column objects representing queryable pseudocolumns
842+
"""
843+
return []
844+
823845
def get_catalog_for_single_relation(self, relation: BaseRelation) -> Optional[CatalogTable]:
824846
"""Get catalog information including table-level and column-level metadata for a single relation."""
825847
raise NotImplementedError(

dbt-adapters/src/dbt/adapters/record/base.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,47 @@ class AdapterGetColumnsInRelationRecord(Record):
321321
params_cls = AdapterGetColumnsInRelationParams
322322
result_cls = AdapterGetColumnsInRelationResult
323323
group = "Available"
324+
325+
326+
@dataclasses.dataclass
327+
class AdapterGetPseudocolumnsForRelationParams:
328+
thread_id: str
329+
relation: "BaseRelation"
330+
331+
def _to_dict(self):
332+
from dbt.adapters.record.serialization import serialize_base_relation
333+
334+
return {
335+
"thread_id": self.thread_id,
336+
"relation": serialize_base_relation(self.relation),
337+
}
338+
339+
def _from_dict(self, data: Dict[str, Any]):
340+
from dbt.adapters.record.serialization import deserialize_base_relation
341+
342+
self.thread_id = data["thread_id"]
343+
self.relation = deserialize_base_relation(data["relation"])
344+
345+
346+
@dataclasses.dataclass
347+
class AdapterGetPseudocolumnsForRelationResult:
348+
return_val: List["BaseColumn"]
349+
350+
def _to_dict(self):
351+
from dbt.adapters.record.serialization import serialize_base_column_list
352+
353+
return {"return_val": serialize_base_column_list(self.return_val)}
354+
355+
def _from_dict(self, data: Dict[str, Any]):
356+
from dbt.adapters.record.serialization import deserialize_base_column_list
357+
358+
self.return_val = deserialize_base_column_list(data["return_val"])
359+
360+
361+
@Recorder.register_record_type
362+
class AdapterGetPseudocolumnsForRelationRecord(Record):
363+
"""Implements record/replay support for the BaseAdapter.get_pseudocolumns_for_relation() method."""
364+
365+
params_cls = AdapterGetPseudocolumnsForRelationParams
366+
result_cls = AdapterGetPseudocolumnsForRelationResult
367+
group = "Available"

dbt-adapters/src/dbt/include/global_project/macros/adapters/columns.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@
1616
{{ return(columns) }}
1717
{% endmacro %}
1818

19+
{% macro get_columns_for_unit_tests(relation) -%}
20+
{{ return(adapter.dispatch('get_columns_for_unit_tests', 'dbt')(relation)) }}
21+
{% endmacro %}
22+
23+
{% macro default__get_columns_for_unit_tests(relation) -%}
24+
{#-- Get native columns from information schema --#}
25+
{% set columns = adapter.get_columns_in_relation(relation) %}
26+
27+
{#-- Add pseudocolumns that are queryable but not in information schema --#}
28+
{% set pseudocolumns = adapter.get_pseudocolumns_for_relation(relation) %}
29+
30+
{#-- Merge two lists --#}
31+
{% set all_columns = columns + pseudocolumns %}
32+
33+
{{ return(all_columns) }}
34+
{% endmacro %}
35+
1936
{%- macro get_list_of_column_names(columns) -%}
2037
{% set col_names = [] %}
2138
{% for col in columns %}

dbt-adapters/src/dbt/include/global_project/macros/materializations/tests/unit.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
{%- set target_relation = this.incorporate(type='table') -%}
1111
{%- set temp_relation = make_temp_relation(target_relation)-%}
1212
{% do run_query(get_create_table_as_sql(True, temp_relation, get_empty_subquery_sql(sql))) %}
13-
{%- set columns_in_relation = adapter.get_columns_in_relation(temp_relation) -%}
13+
{%- set columns_in_relation = get_columns_for_unit_tests(temp_relation) -%}
1414
{%- set column_name_to_data_types = {} -%}
1515
{%- set column_name_to_quoted = {} -%}
1616
{%- for column in columns_in_relation -%}

dbt-adapters/src/dbt/include/global_project/macros/unit_test_sql/get_fixture_sql.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{%- if not column_name_to_data_types -%}
66
{#-- Use defer_relation IFF it is available in the manifest and 'this' is missing from the database --#}
77
{%- set this_or_defer_relation = defer_relation if (defer_relation and not load_relation(this)) else this -%}
8-
{%- set columns_in_relation = adapter.get_columns_in_relation(this_or_defer_relation) -%}
8+
{%- set columns_in_relation = get_columns_for_unit_tests(this_or_defer_relation) -%}
99

1010
{%- set column_name_to_data_types = {} -%}
1111
{%- set column_name_to_quoted = {} -%}
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 pseudocolumns in unit tests
3+
time: 2026-01-06T09:08:26.620597-05:00
4+
custom:
5+
Author: katieclaiborne-duet
6+
Issue: "1517"

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,23 @@ def get_columns_in_relation(self, relation: BigQueryRelation) -> List[BigQueryCo
372372
logger.debug("get_columns_in_relation error: {}".format(e))
373373
return []
374374

375+
@available.parse_list
376+
def get_columns_and_pseudocolumns_for_relation(
377+
self, relation: BigQueryRelation
378+
) -> List[BigQueryColumn]:
379+
"""Return all columns (regular + pseudocolumns) for a relation in a single API call."""
380+
try:
381+
table = self.connections.get_bq_table(
382+
database=relation.database, schema=relation.schema, identifier=relation.identifier
383+
)
384+
columns = self._get_dbt_columns_from_bq_table(table)
385+
if table.table_type == "EXTERNAL":
386+
columns.append(BigQueryColumn("_FILE_NAME", "STRING"))
387+
return columns
388+
except (ValueError, google.cloud.exceptions.NotFound) as e:
389+
logger.debug("get_columns_and_pseudocolumns_for_relation error: {}".format(e))
390+
return []
391+
375392
@available.parse(lambda *a, **k: [])
376393
def add_time_ingestion_partition_column(self, partition_by, columns) -> List[BigQueryColumn]:
377394
"""Add time ingestion partition column to columns list"""

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
{% macro bigquery__get_columns_for_unit_tests(relation) -%}
2+
{{ return(adapter.get_columns_and_pseudocolumns_for_relation(relation)) }}
3+
{%- endmacro %}
4+
15
{% macro bigquery__get_empty_subquery_sql(select_sql, select_sql_header=none) %}
26
{%- if select_sql_header is not none -%}
37
{{ select_sql_header }}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import pytest
2+
from unittest.mock import patch
3+
4+
from dbt.tests.adapter.unit_testing.test_pseudocolumns import (
5+
BasePseudocolumnUnitTest,
6+
external_table_csv,
7+
)
8+
9+
10+
# Model that uses BigQuery's _FILE_NAME pseudocolumn
11+
my_model_sql = """
12+
select
13+
id,
14+
_FILE_NAME as file_name
15+
from {{ source('test_source', 'external_table') }}
16+
"""
17+
18+
# Source definition for external table
19+
schema_sources_yml = """
20+
sources:
21+
- name: test_source
22+
schema: "{{ target.schema }}"
23+
tables:
24+
- name: external_table
25+
columns:
26+
- name: id
27+
"""
28+
29+
# Unit test that includes _FILE_NAME pseudocolumn in the fixture
30+
test_my_model_yml = """
31+
unit_tests:
32+
- name: test_bigquery_file_name_pseudocolumn
33+
model: my_model
34+
given:
35+
- input: source('test_source', 'external_table')
36+
rows:
37+
- {id: 1, _FILE_NAME: 'gs://bucket/file1.csv'}
38+
- {id: 2, _FILE_NAME: 'gs://bucket/file2.csv'}
39+
expect:
40+
rows:
41+
- {id: 1, file_name: 'gs://bucket/file1.csv'}
42+
- {id: 2, file_name: 'gs://bucket/file2.csv'}
43+
"""
44+
45+
46+
class TestBigQueryPseudocolumns(BasePseudocolumnUnitTest):
47+
"""Test BigQuery's _FILE_NAME pseudocolumn support in unit tests.
48+
49+
Uses BigQuery-specific SQL (_FILE_NAME as file_name) and validates that the
50+
pseudocolumn framework works with BigQueryColumn types.
51+
"""
52+
53+
@pytest.fixture(scope="class")
54+
def seeds(self):
55+
"""Seed data."""
56+
return {
57+
"external_table.csv": external_table_csv,
58+
}
59+
60+
@pytest.fixture(scope="class")
61+
def models(self):
62+
"""Models using BigQuery's _FILE_NAME pseudocolumn."""
63+
return {
64+
"my_model.sql": my_model_sql,
65+
"sources.yml": schema_sources_yml + test_my_model_yml,
66+
}
67+
68+
@pytest.fixture(scope="class")
69+
def setup_pseudocolumn_override(self, project):
70+
"""Patch BigQueryAdapter at the class level to return columns + _FILE_NAME for external_table.
71+
72+
Instance-level patching (project.adapter.X) doesn't work because dbtRunner
73+
creates its own adapter instance. Class-level patching affects all instances.
74+
"""
75+
from dbt.adapters.bigquery.column import BigQueryColumn
76+
from dbt.adapters.bigquery.impl import BigQueryAdapter
77+
78+
original_method = BigQueryAdapter.get_columns_and_pseudocolumns_for_relation
79+
80+
def mock_columns_and_pseudocolumns(_self, relation):
81+
if relation.identifier == "external_table":
82+
return [BigQueryColumn("id", "INTEGER"), BigQueryColumn("_FILE_NAME", "STRING")]
83+
# Fall through to the real method for temp relations (model output schema lookup)
84+
return original_method(_self, relation)
85+
86+
mock_columns_and_pseudocolumns._is_available_ = True
87+
mock_columns_and_pseudocolumns._parse_replacement_ = lambda *a, **k: []
88+
89+
with patch.object(
90+
BigQueryAdapter,
91+
"get_columns_and_pseudocolumns_for_relation",
92+
mock_columns_and_pseudocolumns,
93+
):
94+
yield

0 commit comments

Comments
 (0)