You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/10/13 16:27:44 UTC

(superset) branch master updated: fix(bigquery): expose STRUCT fields as columns on virtual datasets (#45150)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 6d4dc0d3363 fix(bigquery): expose STRUCT fields as columns on virtual datasets (#45150)
6d4dc0d3363 is described below

commit 6d4dc0d336329f7dcd1172eb45da5a5ff0aed6b0
Author: Gabriel Torres Ruiz <ga...@gmail.com>
AuthorDate: Tue Oct 13 13:27:35 2026 -0300

    fix(bigquery): expose STRUCT fields as columns on virtual datasets (#45150)
---
 superset/connectors/sqla/models.py                |  22 +++
 superset/connectors/sqla/utils.py                 |   2 +-
 superset/db_engine_specs/base.py                  |  14 ++
 superset/db_engine_specs/bigquery.py              |  63 ++++++
 superset/superset_typing.py                       |   4 +
 tests/unit_tests/connectors/sqla/models_test.py   |  87 ++++++++-
 tests/unit_tests/connectors/sqla/utils_test.py    |  38 ++++
 tests/unit_tests/db_engine_specs/test_base.py     |  11 ++
 tests/unit_tests/db_engine_specs/test_bigquery.py | 225 ++++++++++++++++++++++
 9 files changed, 463 insertions(+), 3 deletions(-)

diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index c2b6dade5e3..6247a95b7f8 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -175,6 +175,27 @@ def _is_calculated_column(column: TableColumn) -> bool:
     return column.expression != quoted_path
 
 
+def _without_shadowed_nested_fields(
+    new_columns: list[ResultSetColumnType],
+    old_columns_by_name: dict[str, TableColumn],
+) -> list[ResultSetColumnType]:
+    """Drop expanded nested fields named like an existing calculated column.
+
+    Such calculated columns (e.g. ``calendar.day`` on a BigQuery STRUCT) are
+    user-defined and must keep their expression; ``fetch_metadata`` adds them
+    back with the other calculated columns.
+    """
+    return [
+        col
+        for col in new_columns
+        if not (
+            col.get("nested_field")
+            and (old_column := old_columns_by_name.get(col["column_name"]))
+            and _is_calculated_column(old_column)
+        )
+    ]
+
+
 METRIC_FORM_DATA_PARAMS = [
     "metric",
     "metric_2",
@@ -2395,6 +2416,7 @@ class SqlaTable(
         old_columns_by_name: dict[str, TableColumn] = {
             col.column_name: col for col in old_columns
         }
+        new_columns = _without_shadowed_nested_fields(new_columns, old_columns_by_name)
         results = MetadataResult(
             removed=[
                 col
diff --git a/superset/connectors/sqla/utils.py b/superset/connectors/sqla/utils.py
index cc94f4046b3..c0c38b60d15 100644
--- a/superset/connectors/sqla/utils.py
+++ b/superset/connectors/sqla/utils.py
@@ -221,7 +221,7 @@ def get_columns_description(
                 result_set = SupersetResultSet(
                     result, cursor.description, db_engine_spec
                 )
-            return result_set.columns
+            return db_engine_spec.expand_nested_columns(cursor, result_set.columns)
     except (SupersetErrorException, SupersetDBAPIError):
         # Preserve exceptions that already carry specific, actionable typing
         # (e.g. OAuth2RedirectError, or a driver connection/timeout error
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index 085adcb48ef..379c09fd540 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -2782,6 +2782,20 @@ class BaseEngineSpec:  # pylint: disable=too-many-public-methods
         """
         return None
 
+    @classmethod
+    def expand_nested_columns(
+        cls,
+        cursor: Any,
+        columns: list[ResultSetColumnType],
+    ) -> list[ResultSetColumnType]:
+        """
+        Add one column per nested field (e.g. STRUCT members) to the columns
+        inferred from ``cursor.description`` by ``get_columns_description``, so
+        virtual datasets get the dotted columns the inspector returns for
+        physical tables. The default returns ``columns`` unchanged.
+        """
+        return columns
+
     @staticmethod
     def pyodbc_rows_to_tuples(data: list[Any]) -> list[tuple[Any, ...]]:
         """
diff --git a/superset/db_engine_specs/bigquery.py b/superset/db_engine_specs/bigquery.py
index 3199d210da6..c31c4f6fbd0 100644
--- a/superset/db_engine_specs/bigquery.py
+++ b/superset/db_engine_specs/bigquery.py
@@ -1121,6 +1121,69 @@ class BigQueryEngineSpec(BaseEngineSpec):  # pylint: disable=too-many-public-met
             for c in cols
         ]
 
+    @classmethod
+    def expand_nested_columns(
+        cls,
+        cursor: Any,
+        columns: list[ResultSetColumnType],
+    ) -> list[ResultSetColumnType]:
+        """
+        Add the STRUCT members that ``cursor.description`` drops as dotted
+        columns, matching what the inspector returns for physical tables.
+        """
+        schema = cls._get_cursor_schema(cursor)
+        if not schema or len(schema) != len(columns):
+            return columns
+        taken = {col["column_name"] for col in columns}
+        expanded: list[ResultSetColumnType] = []
+        for col, field in zip(columns, schema, strict=True):
+            expanded.append(col)
+            expanded.extend(cls._expand_schema_field(field, col["column_name"], taken))
+        return expanded
+
+    @staticmethod
+    def _get_cursor_schema(cursor: Any) -> list[Any] | None:
+        # The DB-API cursor keeps the RowIterator of the last execute(), whose
+        # schema still has the nested fields that cursor.description drops; the
+        # public query_job property would cost an extra API call
+        rows = getattr(cursor, "_query_rows", None)
+        schema = getattr(rows, "schema", None)
+        return list(schema) if schema else None
+
+    @classmethod
+    def _expand_schema_field(
+        cls,
+        field: Any,
+        prefix: str,
+        taken: set[str],
+    ) -> list[ResultSetColumnType]:
+        if field.field_type not in {"RECORD", "STRUCT"} or field.mode == "REPEATED":
+            return []
+        columns: list[ResultSetColumnType] = []
+        for child in field.fields:
+            name = f"{prefix}.{child.name}"
+            if name in taken:
+                continue
+            taken.add(name)
+            # the inspector types REPEATED members as ARRAY
+            repeated = child.mode == "REPEATED"
+            spec = None if repeated else cls.get_column_spec(child.field_type)
+            is_dttm = bool(spec and spec.is_dttm)
+            columns.append(
+                ResultSetColumnType(
+                    name=name,
+                    column_name=name,
+                    type="ARRAY" if repeated else child.field_type,
+                    type_generic=utils.GenericDataType.TEMPORAL
+                    if is_dttm
+                    else (spec.generic_type if spec else None),
+                    is_dttm=is_dttm,
+                    nested_field=True,
+                )
+            )
+            columns.extend(cls._expand_schema_field(child, name, taken))
+        return columns
+
     @classmethod
     def parse_error_exception(cls, exception: Exception) -> Exception:
         try:
diff --git a/superset/superset_typing.py b/superset/superset_typing.py
index 0862e7d2810..c29d7814c14 100644
--- a/superset/superset_typing.py
+++ b/superset/superset_typing.py
@@ -138,6 +138,10 @@ class ResultSetColumnType(TypedDict):
     # identifier.
     expression: NotRequired[Any]
 
+    # Set on columns an engine spec expanded from a nested type (e.g. a STRUCT
+    # member); such a column never replaces a calculated column of its name.
+    nested_field: NotRequired[bool]
+
 
 CacheConfig: TypeAlias = dict[str, Any]
 DbapiDescriptionRow: TypeAlias = tuple[
diff --git a/tests/unit_tests/connectors/sqla/models_test.py b/tests/unit_tests/connectors/sqla/models_test.py
index 949d82789a6..30b62bcf66a 100644
--- a/tests/unit_tests/connectors/sqla/models_test.py
+++ b/tests/unit_tests/connectors/sqla/models_test.py
@@ -15,7 +15,9 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from collections.abc import Mapping, Sequence
 from datetime import datetime
+from types import SimpleNamespace
 from unittest.mock import MagicMock, PropertyMock
 
 import pandas as pd
@@ -55,7 +57,11 @@ from superset.models.helpers import (
 from superset.sql.parse import Table
 from superset.subjects.models import Subject
 from superset.subjects.types import SubjectType
-from superset.superset_typing import AdhocMetric, QueryObjectDict
+from superset.superset_typing import (
+    AdhocMetric,
+    QueryObjectDict,
+    ResultSetColumnType,
+)
 from superset.utils import json
 
 
@@ -944,7 +950,7 @@ def test_fetch_metadata_empty_comment_field_handling(mocker: MockerFixture) -> N
 
 def _table_for_fetch_metadata(
     mocker: MockerFixture,
-    source_columns: list[dict[str, str]],
+    source_columns: Sequence[Mapping[str, object]],
     existing: list[dict[str, str]] | None = None,
 ) -> SqlaTable:
     """Build a SqlaTable whose ``fetch_metadata`` reads *source_columns*."""
@@ -1174,6 +1180,83 @@ def test_fetch_metadata_keeps_dotted_calculated_column(
     assert table.changed_on == original_changed_on
 
 
+def _bigquery_struct_source_columns() -> list[ResultSetColumnType]:
+    """Columns of ``SELECT calendar`` as BigQuery expands them for a virtual dataset."""
+    from google.cloud.bigquery import SchemaField
+
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    schema = [
+        SchemaField(
+            "calendar",
+            "RECORD",
+            fields=[SchemaField("day", "STRING"), SchemaField("year", "INTEGER")],
+        )
+    ]
+    cursor = SimpleNamespace(_query_rows=SimpleNamespace(schema=schema))
+    return BigQueryEngineSpec.expand_nested_columns(
+        cursor,
+        [
+            ResultSetColumnType(
+                name="calendar", column_name="calendar", type="RECORD", is_dttm=False
+            )
+        ],
+    )
+
+
+def test_fetch_metadata_keeps_calculated_column_named_like_struct_field(
+    mocker: MockerFixture,
+) -> None:
+    """An expanded STRUCT member never replaces a calculated column of its name.
+
+    Such calculated columns were the way to reach STRUCT fields on virtual
+    datasets before the fields were listed, so a refresh must keep them.
+    """
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=_bigquery_struct_source_columns(),
+        existing=[
+            {"column_name": "calendar", "type": "RECORD"},
+            {
+                "column_name": "calendar.day",
+                "type": "STRING",
+                "expression": "UPPER(calendar.day)",
+            },
+        ],
+    )
+    result = table.fetch_metadata()
+
+    names = [col.column_name for col in table.columns]
+    assert sorted(names) == ["calendar", "calendar.day", "calendar.year"]
+    calculated = next(col for col in table.columns if col.column_name == "calendar.day")
+    assert calculated.expression == "UPPER(calendar.day)"
+    assert result.added == ["calendar.year"]
+    assert result.modified == []
+
+
+def test_fetch_metadata_source_column_replaces_calculated_column(
+    mocker: MockerFixture,
+) -> None:
+    """A source column that is not an expanded nested field still wins its name."""
+    table = _table_for_fetch_metadata(
+        mocker,
+        source_columns=[{"column_name": "calendar.day", "type": "STRING"}],
+        existing=[
+            {
+                "column_name": "calendar.day",
+                "type": "STRING",
+                "expression": "UPPER(calendar.day)",
+            },
+        ],
+    )
+
+    result = table.fetch_metadata()
+
+    assert [col.column_name for col in table.columns] == ["calendar.day"]
+    assert table.columns[0].expression == ""
+    assert result.modified == ["calendar.day"]
+
+
 @pytest.mark.parametrize(
     "supports_cross_catalog,table_name,catalog,schema,expected_name,expected_schema",
     [
diff --git a/tests/unit_tests/connectors/sqla/utils_test.py b/tests/unit_tests/connectors/sqla/utils_test.py
index 2b2dde8e562..a3b138d25a3 100644
--- a/tests/unit_tests/connectors/sqla/utils_test.py
+++ b/tests/unit_tests/connectors/sqla/utils_test.py
@@ -70,6 +70,41 @@ def test_get_columns_description_caps_complex_tsql_limits(
     result_set.assert_called_once_with([(1,)], cursor.description, MssqlEngineSpec)
 
 
+def test_get_columns_description_expands_nested_columns(
+    mocker: MockerFixture,
+) -> None:
+    """Column discovery lets the engine spec expand nested fields from the cursor."""
+    from superset.db_engine_specs.sqlite import SqliteEngineSpec
+    from superset.superset_typing import ResultSetColumnType
+
+    database = Database(database_name="test_database", sqlalchemy_uri="sqlite://")
+    mocker.patch.object(database, "get_db_engine_spec", return_value=SqliteEngineSpec)
+    connection = mocker.patch.object(database, "get_raw_connection")
+    cursor = connection.return_value.__enter__.return_value.cursor.return_value
+    cursor.description = [("calendar", "RECORD", None, None, None, None, None)]
+    cursor.fetchall.return_value = [(None,)]
+    mocker.patch.object(
+        database, "mutate_sql_based_on_config", side_effect=lambda sql: sql
+    )
+    mocker.patch.object(SqliteEngineSpec, "execute")
+    expanded = ResultSetColumnType(
+        name="calendar.day",
+        column_name="calendar.day",
+        type="STRING",
+        is_dttm=False,
+    )
+    hook = mocker.patch.object(
+        SqliteEngineSpec, "expand_nested_columns", return_value=[expanded]
+    )
+
+    result = get_columns_description(database, None, None, "SELECT calendar FROM t")
+
+    assert result == [expanded]
+    hook.assert_called_once()
+    assert hook.call_args.args[0] is cursor
+    assert hook.call_args.args[1][0]["column_name"] == "calendar"
+
+
 # Returns column descriptions when given valid database, catalog, schema, and query
 def test_returns_column_descriptions(mocker: MockerFixture) -> None:
     database = mocker.MagicMock()
@@ -89,6 +124,7 @@ def test_returns_column_descriptions(mocker: MockerFixture) -> None:
 
     database.get_raw_connection.return_value.__enter__.return_value.cursor.return_value = cursor  # noqa: E501
     database.db_engine_spec = db_engine_spec
+    db_engine_spec.expand_nested_columns.side_effect = lambda _, columns: columns
     database.apply_limit_to_sql.return_value = "SELECT * FROM table LIMIT 1"
     database.mutate_sql_based_on_config.return_value = "SELECT * FROM table LIMIT 1"
     db_engine_spec.fetch_data.return_value = [("col1", "col1", "STRING", None, False)]
@@ -278,6 +314,7 @@ def test_get_columns_description_retries_with_comment_safe_sql_when_empty(
 
     database.get_raw_connection.return_value.__enter__.return_value.cursor.return_value = cursor  # noqa: E501
     database.db_engine_spec = db_engine_spec
+    db_engine_spec.expand_nested_columns.side_effect = lambda _, columns: columns
     database.apply_limit_to_sql.return_value = "SELECT 1 WHERE false"
     database.mutate_sql_based_on_config.return_value = (
         "-- comment\nSELECT 1 WHERE false"
@@ -332,6 +369,7 @@ def test_get_columns_description_no_retry_when_engine_has_no_hook(
 
     database.get_raw_connection.return_value.__enter__.return_value.cursor.return_value = cursor  # noqa: E501
     database.db_engine_spec = db_engine_spec
+    db_engine_spec.expand_nested_columns.side_effect = lambda _, columns: columns
     database.apply_limit_to_sql.return_value = "SELECT * FROM table"
     database.mutate_sql_based_on_config.return_value = "SELECT * FROM table"
 
diff --git a/tests/unit_tests/db_engine_specs/test_base.py b/tests/unit_tests/db_engine_specs/test_base.py
index 8c5099cb88e..6eba3e68af1 100644
--- a/tests/unit_tests/db_engine_specs/test_base.py
+++ b/tests/unit_tests/db_engine_specs/test_base.py
@@ -2045,3 +2045,14 @@ def test_epoch_us_to_dttm(spec: type[BaseEngineSpec], expected: str) -> None:
     microsecond function (via their own override).
     """
     assert spec.epoch_us_to_dttm() == expected
+
+
+def test_expand_nested_columns_default_returns_input() -> None:
+    """
+    By default the columns inferred from ``cursor.description`` are kept as is.
+    """
+    columns = [
+        ResultSetColumnType(name="a", column_name="a", type="INT", is_dttm=False)
+    ]
+
+    assert BaseEngineSpec.expand_nested_columns(object(), columns) is columns
diff --git a/tests/unit_tests/db_engine_specs/test_bigquery.py b/tests/unit_tests/db_engine_specs/test_bigquery.py
index cdcc2550c09..0ae44ab09c7 100644
--- a/tests/unit_tests/db_engine_specs/test_bigquery.py
+++ b/tests/unit_tests/db_engine_specs/test_bigquery.py
@@ -19,10 +19,12 @@
 
 from datetime import datetime
 from itertools import islice
+from types import SimpleNamespace
 from typing import Any, Optional
 from unittest import mock
 
 import pytest
+from google.cloud.bigquery import SchemaField
 from pytest_mock import MockerFixture
 from sqlalchemy import column, select
 from sqlalchemy.engine.url import make_url
@@ -32,6 +34,7 @@ from sqlalchemy_bigquery import BigQueryDialect
 from superset.sql.parse import Table
 from superset.superset_typing import FetchedRows, ResultSetColumnType
 from superset.utils import json
+from superset.utils.core import GenericDataType
 from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
 from tests.unit_tests.fixtures.common import dttm  # noqa: F401
 
@@ -1303,3 +1306,225 @@ def test_where_latest_partition_skips_where_clause_when_max_is_none(
     # No WHERE clause was appended: the query is returned unchanged
     assert filtered is query
     assert not query.whereclause
+
+
+def _struct_schema() -> list[SchemaField]:
+    return [
+        SchemaField("date", "DATE"),
+        SchemaField(
+            "calendar",
+            "RECORD",
+            fields=[
+                SchemaField("day_num_of_month", "INTEGER"),
+                SchemaField("day", "STRING"),
+                SchemaField("client_year", "INTEGER"),
+                SchemaField(
+                    "client",
+                    "RECORD",
+                    fields=[
+                        SchemaField("week_number", "INTEGER"),
+                        SchemaField("week_start_date", "DATE"),
+                    ],
+                ),
+            ],
+        ),
+        SchemaField("currency", "STRING"),
+        SchemaField("event_count", "INTEGER"),
+    ]
+
+
+def _top_level_columns(schema: list[SchemaField]) -> list[ResultSetColumnType]:
+    return [
+        ResultSetColumnType(
+            name=field.name,
+            column_name=field.name,
+            type=field.field_type,
+            is_dttm=field.field_type == "DATE",
+        )
+        for field in schema
+    ]
+
+
+EXPANDED_STRUCT_NAMES = [
+    "date",
+    "calendar",
+    "calendar.day_num_of_month",
+    "calendar.day",
+    "calendar.client_year",
+    "calendar.client",
+    "calendar.client.week_number",
+    "calendar.client.week_start_date",
+    "currency",
+    "event_count",
+]
+
+
+def test_expand_nested_columns_adds_struct_fields() -> None:
+    """
+    STRUCT members dropped by ``cursor.description`` are added as dotted columns.
+    """
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    schema = _struct_schema()
+    columns = _top_level_columns(schema)
+    cursor = SimpleNamespace(_query_rows=SimpleNamespace(schema=schema))
+
+    result = BigQueryEngineSpec.expand_nested_columns(cursor, columns)
+
+    assert [col["column_name"] for col in result] == EXPANDED_STRUCT_NAMES
+    assert result[0] is columns[0]
+    assert result[1] is columns[1]
+    assert result[8] is columns[2]
+    assert result[9] is columns[3]
+    by_name = {col["column_name"]: col for col in result}
+    assert by_name["calendar.day"]["type"] == "STRING"
+    assert by_name["calendar.day"]["type_generic"] == GenericDataType.STRING
+    assert by_name["calendar.day"]["is_dttm"] is False
+    assert by_name["calendar.day"]["nested_field"] is True
+    assert "nested_field" not in by_name["calendar"]
+    assert by_name["calendar.day_num_of_month"]["type_generic"] == (
+        GenericDataType.NUMERIC
+    )
+    assert by_name["calendar.client"]["type"] == "RECORD"
+    assert by_name["calendar.client"]["type_generic"] is None
+    week_start = by_name["calendar.client.week_start_date"]
+    assert week_start["type"] == "DATE"
+    assert week_start["is_dttm"] is True
+    assert week_start["type_generic"] == GenericDataType.TEMPORAL
+
+
+def test_expand_nested_columns_skips_repeated_record_children() -> None:
+    """
+    Children of an ARRAY<STRUCT> are not selectable as ``a.b`` and are skipped;
+    REPEATED members are typed as arrays, like the inspector does.
+    """
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    schema = [
+        SchemaField(
+            "calendar",
+            "RECORD",
+            fields=[
+                SchemaField("tags", "STRING", mode="REPEATED"),
+                SchemaField("dates", "DATE", mode="REPEATED"),
+                SchemaField(
+                    "items",
+                    "RECORD",
+                    mode="REPEATED",
+                    fields=[SchemaField("sku", "STRING")],
+                ),
+            ],
+        ),
+        SchemaField(
+            "trailer",
+            "RECORD",
+            mode="REPEATED",
+            fields=[SchemaField("key", "STRING"), SchemaField("value", "STRING")],
+        ),
+    ]
+    cursor = SimpleNamespace(_query_rows=SimpleNamespace(schema=schema))
+
+    result = BigQueryEngineSpec.expand_nested_columns(
+        cursor, _top_level_columns(schema)
+    )
+
+    assert [col["column_name"] for col in result] == [
+        "calendar",
+        "calendar.tags",
+        "calendar.dates",
+        "calendar.items",
+        "trailer",
+    ]
+    for col in result[1:4]:
+        assert col["type"] == "ARRAY"
+        assert col["type_generic"] is None
+        assert col["is_dttm"] is False
+        assert col["nested_field"] is True
+
+
+@pytest.mark.parametrize(
+    "cursor",
+    [
+        SimpleNamespace(_query_rows=None, query_job=None),
+        SimpleNamespace(),
+        SimpleNamespace(
+            _query_rows=None,
+            query_job=SimpleNamespace(result=mock.Mock(side_effect=AssertionError)),
+        ),
+    ],
+)
+def test_expand_nested_columns_without_schema_is_noop(cursor: Any) -> None:
+    """
+    Without a result schema on the cursor the columns are returned unchanged.
+
+    ``query_job`` is never read, since it costs an API call.
+    """
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    columns = _top_level_columns(_struct_schema())
+
+    assert BigQueryEngineSpec.expand_nested_columns(cursor, columns) is columns
+
+
+def test_expand_nested_columns_schema_length_mismatch_is_noop() -> None:
+    """
+    A schema that does not line up with the columns is ignored.
+    """
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    schema = _struct_schema()
+    columns = _top_level_columns(schema)
+    cursor = SimpleNamespace(_query_rows=SimpleNamespace(schema=schema[:2]))
+
+    assert BigQueryEngineSpec.expand_nested_columns(cursor, columns) == columns
+
+
+def test_expand_nested_columns_reads_real_dbapi_cursor(mocker: MockerFixture) -> None:
+    """
+    The nested schema is read from the DB-API cursor without an extra API call.
+    """
+    from google.cloud.bigquery.dbapi import Connection
+
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    schema = _struct_schema()
+    client = mocker.MagicMock()
+    client.query_and_wait.return_value = SimpleNamespace(
+        schema=schema, total_rows=0, num_dml_affected_rows=None, job_id=None
+    )
+    cursor = Connection(client=client).cursor()
+    cursor.execute("SELECT * FROM t")
+
+    result = BigQueryEngineSpec.expand_nested_columns(
+        cursor, _top_level_columns(schema)
+    )
+
+    assert [col["column_name"] for col in result] == EXPANDED_STRUCT_NAMES
+    client.get_job.assert_not_called()
+
+
+def test_expand_nested_columns_keeps_user_alias() -> None:
+    """
+    A selected column already named like a STRUCT member is not duplicated.
+    """
+    from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+
+    schema = [
+        SchemaField(
+            "calendar",
+            "RECORD",
+            fields=[SchemaField("day", "STRING"), SchemaField("year", "INTEGER")],
+        ),
+        SchemaField("calendar.day", "STRING"),
+    ]
+    columns = _top_level_columns(schema)
+    cursor = SimpleNamespace(_query_rows=SimpleNamespace(schema=schema))
+
+    result = BigQueryEngineSpec.expand_nested_columns(cursor, columns)
+
+    assert [col["column_name"] for col in result] == [
+        "calendar",
+        "calendar.year",
+        "calendar.day",
+    ]
+    assert result[2] is columns[1]