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]