Compare commits

..

6 Commits

Author SHA1 Message Date
rusackas
6962044df5 fix: address bot review findings on refresh_frequency JSON sync
- Validate the JSON editor's refresh_frequency (not just the dropdown
  state) so a value edited directly in Advanced JSON still enforces
  SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT.
- Don't overwrite the Advanced JSON editor with a one-field object when
  the dropdown changes while the editor holds invalid/in-progress JSON.
- Keep shared_label_colors, map_label_colors, and color_scheme_domain
  resetting to their default when absent from the payload, since the
  Properties modal never sends them and preserving stale values could
  leave outdated color mappings after a color scheme change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:54:44 -07:00
Evan
16e61fb856 test: mock SupersetClient.put so the save path reaches onSubmit
The refresh_frequency preservation test clicks Save with onlyApply: false,
which PUTs to the dashboard API before calling onSubmit. Without a mocked
put the request rejects and onSubmit is never invoked, failing the test in
CI. Mirrors the mock used by the existing onlyApply:false submit test.
Adds an aria-label to the mocked textarea to satisfy jsx-a11y.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 02:57:44 -07:00
Evan Rusackas
47fa9705e4 fix(dashboard): keep refresh_frequency set via the Advanced JSON editor
The Dashboard Properties modal seeds a separate refreshFrequency state from the
Refresh dropdown and, on save, unconditionally overwrote
json_metadata.refresh_frequency with it, so a value typed directly into the
Advanced JSON editor was clobbered by the (stale) dropdown value and reset to 0.
This mirrors how color_scheme is already handled: the JSON editor value now
takes precedence on save, and a dropdown change syncs back into the JSON so the
two sources can't diverge.

Also hardens DashboardDAO.set_dash_metadata so metadata fields absent from the
incoming payload are preserved instead of reset to their defaults (the pattern
dosu flagged on the issue), preventing partial updates from wiping unrelated
fields.

Fixes #42116

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 02:57:41 -07:00
Mehmet Salih Yavuz
ecc7f726a4 fix: guard potential null derefs and remove dead branches (#42358)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-07-28 11:43:46 +03:00
PRATHAMESH HUKKERI
26b6f7bb5d fix(dashboard): preserve refresh_frequency when absent from save data (#42354)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
2026-07-27 23:13:11 -07:00
Phuc Hung Nguyen
a10c3f0b1d test(result_set): add regression test for empty result set column metadata (#35962)
Co-authored-by: Phuc Hung Nguyen <phucnguyen@geotab.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 23:08:59 -07:00
13 changed files with 323 additions and 115 deletions

View File

@@ -1,65 +0,0 @@
name: Python Unit Test Results
on:
# zizmor: ignore[dangerous-triggers] - runs in base-branch context and only consumes artifacts uploaded by Python-Unit; never checks out PR code (see note below)
workflow_run:
workflows: ["Python-Unit"]
types: [completed]
# This workflow publishes a check run annotating failing Python unit tests
# inline on the PR diff, using JUnit XML uploaded by the Python-Unit workflow.
# It uses the workflow_run trigger so that it always runs in the base-branch
# context and can safely be granted write permissions, even for PRs from
# forks or Dependabot.
#
# IMPORTANT: This workflow must NEVER check out code from the PR branch. All
# data comes from artifacts uploaded by the Python-Unit workflow.
permissions:
contents: read
checks: write
issues: read
actions: read
jobs:
report:
runs-on: ubuntu-26.04
timeout-minutes: 10
if: >
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
# Fails soft (continue-on-error) because the source unit-tests job is
# itself gated on change detection: a docs-only PR skips it entirely,
# so there is nothing to download or report on.
- name: Download JUnit results
id: download
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: "junit-results-*"
path: artifacts
merge-multiple: true
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Download event file
id: download-event
if: steps.download.outcome == 'success'
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: "Event File"
path: event
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish test results
if: steps.download.outcome == 'success' && steps.download-event.outcome == 'success'
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
with:
commit: ${{ github.event.workflow_run.head_sha }}
event_file: event/event.json
event_name: ${{ github.event.workflow_run.event }}
files: "artifacts/**/*.xml"
check_name: "Python Unit Test Results"
comment_mode: "off"

View File

@@ -74,14 +74,14 @@ jobs:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50
- name: Python 100% coverage unit tests
env:
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-sql-coverage.xml
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100 --junit-xml=test-results/junit-semantic-layers-coverage.xml
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
@@ -89,33 +89,6 @@ jobs:
verbose: true
use_oidc: true
slug: apache/superset
# Uploaded even when a pytest step above fails, since that is exactly
# when the JUnit results are needed downstream, to annotate the PR with
# the failing tests. Consumed by the "Python Unit Test Results" workflow
# via workflow_run (see that workflow for why it can't just be a step
# here: it needs to run with write permissions, which this PR-triggered
# job can't safely have on a fork PR).
- name: Upload JUnit test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: junit-results-${{ matrix.python-version }}
path: test-results/
retention-days: 7
# Uploads the raw pull_request event payload so the "Python Unit Test
# Results" workflow (running via workflow_run, in base-branch context) can
# look up which PR/commit to annotate without checking out untrusted code.
event-file:
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Upload event file
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: Event File
path: ${{ github.event_path }}
retention-days: 7
# Stable required-status-check anchor. `unit-tests` is a matrix job gated on
# change detection, so on non-Python PRs it is skipped and never produces its

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import {
fireEvent,
render,
screen,
userEvent,
@@ -41,6 +42,30 @@ jest.mock('@superset-ui/core', () => ({
})),
}));
// Mock the Advanced JSON editor (Ace-based, not drivable in jsdom) with a plain
// textarea so a direct JSON edit can be simulated. Keeps the "JSON Metadata"
// label so the existing "should open advance" test still passes.
jest.mock('./sections/AdvancedSection', () => ({
__esModule: true,
default: ({
jsonMetadata,
onJsonMetadataChange,
}: {
jsonMetadata: string;
onJsonMetadataChange: (value: string) => void;
}) => (
<div>
<span>JSON Metadata</span>
<textarea
aria-label="JSON metadata editor"
data-test="mock-json-editor"
value={jsonMetadata}
onChange={e => onJsonMetadataChange(e.target.value)}
/>
</div>
),
}));
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const spyColorSchemeSelect = jest.spyOn(ColorSchemeSelect, 'default');
@@ -259,6 +284,56 @@ describe('PropertiesModal', () => {
});
});
test('preserves a refresh_frequency edited in the JSON editor on save (#42116)', async () => {
// Save (onlyApply: false) PUTs to the API before calling onSubmit, so the
// request must be mocked or onSubmit is never reached.
const put = jest.spyOn(SupersetCore.SupersetClient, 'put');
put.mockResolvedValue({
json: {
result: {
dashboard_title: 'dashboard_title',
slug: 'slug',
json_metadata: 'json_metadata',
editors: 'editors',
},
},
} as any);
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
const propsWithDashboardInfo = {
...props,
dashboardInfo: {
...dashboardInfo,
json_metadata: mockedJsonMetadata,
},
};
render(<PropertiesModal {...propsWithDashboardInfo} />, {
useRedux: true,
});
await screen.findByTestId('dashboard-edit-properties-form');
// Expand the Advanced settings panel so the (mocked) JSON editor mounts.
const advancedHeader = screen
.getByText('Advanced settings')
.closest('.ant-collapse-header');
await userEvent.click(advancedHeader!);
// Edit refresh_frequency directly in the JSON editor without touching the
// Refresh dropdown (the reproduction of #42116).
const editor = await screen.findByTestId('mock-json-editor');
fireEvent.change(editor, {
target: { value: JSON.stringify({ refresh_frequency: 30 }) },
});
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledTimes(1);
});
const submitted = JSON.parse(props.onSubmit.mock.calls[0][0].jsonMetadata);
expect(submitted.refresh_frequency).toBe(30);
});
test('should close modal', async () => {
mockedIsFeatureEnabled.mockImplementation((flag: any) => {
if (flag === FeatureFlag.TaggingSystem) return true;

View File

@@ -238,17 +238,19 @@ const PropertiesModal = ({
}, handleErrorResponse);
}, [dashboardId, handleDashboardData]);
const getJsonMetadata = () => {
// Returns undefined (rather than {}) when the editor holds invalid JSON,
// so callers can distinguish "empty/no metadata yet" from "user is
// mid-edit with unparsable text" and avoid clobbering the latter.
const parseJsonMetadata = () => {
try {
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
return jsonMetadata?.length ? JSON.parse(jsonMetadata) : {};
} catch (_) {
return {};
return undefined;
}
};
const getJsonMetadata = () => parseJsonMetadata() ?? {};
const handleOnChangeEditors = (values: SubjectPickerValue[]) => {
setEditors(mapPickerValuesToSubjects(values));
};
@@ -343,7 +345,12 @@ const PropertiesModal = ({
? resettableCustomLabels
: false;
const jsonMetadataObj = getJsonMetadata();
jsonMetadataObj.refresh_frequency = refreshFrequency;
// A refresh_frequency edited directly in the Advanced JSON editor takes
// precedence over the Refresh dropdown state, mirroring how color_scheme is
// handled above. Nullish coalescing preserves an explicit 0 ("Don't
// refresh") rather than falling through to the dropdown value (#42116).
jsonMetadataObj.refresh_frequency =
jsonMetadataObj.refresh_frequency ?? refreshFrequency;
jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps);
const customLabelColors = jsonMetadataObj.label_colors || {};
const updatedDashboardMetadata = {
@@ -537,8 +544,19 @@ const PropertiesModal = ({
// Section handlers for extracted components
const handleThemeChange = (value: any) => setSelectedThemeId(value || null);
const handleRefreshFrequencyChange = (value: number) =>
const handleRefreshFrequencyChange = (value: number) => {
setRefreshFrequency(value);
// Keep the Advanced JSON editor in sync with the dropdown so the two
// sources can't diverge, mirroring onColorSchemeChange (#42116). Skip
// this while the editor holds invalid/in-progress JSON so we don't
// clobber the user's unfinished edit with a one-field object.
const jsonMetadataObj = parseJsonMetadata();
if (!jsonMetadataObj) {
return;
}
jsonMetadataObj.refresh_frequency = value;
setJsonMetadata(jsonStringify(jsonMetadataObj));
};
// Helper function for styling section
const hasCustomLabelsColor = !!Object.keys(
@@ -580,7 +598,15 @@ const PropertiesModal = ({
const refreshLimit =
dashboardInfo?.common?.conf
?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
return validateRefreshFrequency(refreshFrequency, refreshLimit);
// A refresh_frequency edited directly in the Advanced JSON editor
// takes precedence over the dropdown when saving (see onFinish),
// so validate that effective value rather than the dropdown state.
const effectiveRefreshFrequency =
getJsonMetadata()?.refresh_frequency ?? refreshFrequency;
return validateRefreshFrequency(
effectiveRefreshFrequency,
refreshLimit,
);
},
},
{

View File

@@ -318,7 +318,7 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
# another temporal filter. A new filter based on the value of
# the granularity will be added later in the code.
# In practice, this is replacing the previous default temporal filter.
if is_adhoc_column(filter_to_remove): # type: ignore
if filter_to_remove and is_adhoc_column(filter_to_remove): # type: ignore
filter_to_remove = filter_to_remove.get("sqlExpression")
if filter_to_remove:

View File

@@ -206,11 +206,9 @@ class QueryCacheManager:
)
query_cache.status = QueryStatus.SUCCESS
query_cache.is_loaded = True
query_cache.is_cached = cache_value is not None
query_cache.is_cached = True
query_cache.sql_rowcount = cache_value.get("sql_rowcount", None)
query_cache.cache_dttm = (
cache_value["dttm"] if cache_value is not None else None
)
query_cache.cache_dttm = cache_value["dttm"]
query_cache.queried_dttm = cache_value.get(
"queried_dttm", cache_value.get("dttm")
)

View File

@@ -396,14 +396,36 @@ class DashboardDAO(BaseDAO[Dashboard]):
else:
md["color_namespace"] = data.get("color_namespace")
md["expanded_slices"] = data.get("expanded_slices", {})
md["refresh_frequency"] = data.get("refresh_frequency", 0)
md["color_scheme"] = data.get("color_scheme", "")
md["label_colors"] = data.get("label_colors", {})
# Only overwrite these metadata fields when the caller explicitly sends
# them. Previously each used ``data.get(key, default)``, which reset a
# value to its default whenever it was absent from the payload -- e.g. a
# ``refresh_frequency`` set directly in the Advanced JSON editor got
# wiped on save. ``setdefault`` still seeds a default for brand-new
# dashboards that have never had the key, keeping the shape stable
# without clobbering existing values (#42116).
metadata_defaults: dict[str, Any] = {
"expanded_slices": {},
"refresh_frequency": 0,
"color_scheme": "",
"label_colors": {},
"cross_filters_enabled": True,
}
for key, default_value in metadata_defaults.items():
if key in data:
md[key] = data[key]
else:
md.setdefault(key, default_value)
# These are derived from ``color_scheme``/``label_colors`` on the
# frontend (see ``applyDashboardLabelsColorOnLoad``) and are always
# omitted from the Properties modal's payload, so they must keep
# resetting to their default when absent rather than being preserved
# -- otherwise a color scheme change made through that modal leaves
# stale label-to-color mappings in place instead of triggering
# recomputation on next load.
md["shared_label_colors"] = data.get("shared_label_colors", [])
md["map_label_colors"] = data.get("map_label_colors", {})
md["color_scheme_domain"] = data.get("color_scheme_domain", [])
md["cross_filters_enabled"] = data.get("cross_filters_enabled", True)
dashboard.json_metadata = json.dumps(md)
@staticmethod

View File

@@ -534,6 +534,7 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
],
) -> list[SupersetError]:
errors: list[SupersetError] = []
connect_args: dict[str, Any] = {}
if extra := json.loads(properties.get("extra")): # type: ignore
engine_params = extra.get("engine_params", {})
connect_args = engine_params.get("connect_args", {})

View File

@@ -1235,6 +1235,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
all_columns: list[ResultSetColumnType] = []
expanded_columns = []
current_array_level = None
unnested_rows: dict[int, int] = defaultdict(int)
while to_process:
column, level = to_process.popleft()
if column["column_name"] not in [
@@ -1248,7 +1249,7 @@ class PrestoEngineSpec(PrestoBaseEngineSpec):
# added by the first. every time we change a level in the nested arrays
# we reinitialize this.
if level != current_array_level:
unnested_rows: dict[int, int] = defaultdict(int)
unnested_rows = defaultdict(int)
current_array_level = level
name = column["column_name"]

View File

@@ -73,6 +73,46 @@ class TestDashboardDAO(SupersetTestCase):
DashboardDAO.set_dash_metadata(dashboard, original_data)
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.utils.core.g")
@patch("superset.security.manager.g")
def test_set_dash_metadata_preserves_unsent_fields(self, mock_sm_g, mock_g):
"""
set_dash_metadata must not reset metadata fields that are absent from the
incoming payload, such as a ``refresh_frequency`` edited directly in the
Advanced JSON editor (#42116). Fields that are present still override.
"""
mock_g.user = mock_sm_g.user = security_manager.find_user("admin")
with self.client.application.test_request_context():
dashboard = (
db.session.query(Dashboard).filter_by(slug="world_health").first()
)
original_json_metadata = dashboard.json_metadata
try:
# Seed an existing refresh_frequency in the stored metadata.
metadata = json.loads(dashboard.json_metadata or "{}")
metadata["refresh_frequency"] = 60
dashboard.json_metadata = json.dumps(metadata)
db.session.commit()
# Payload omits refresh_frequency: it must be preserved, not
# reset to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"color_scheme": "d3Category10"}
)
db.session.commit()
saved = json.loads(dashboard.json_metadata)
assert saved["refresh_frequency"] == 60
assert saved["color_scheme"] == "d3Category10"
# An explicitly-sent value still overrides.
DashboardDAO.set_dash_metadata(dashboard, {"refresh_frequency": 30})
db.session.commit()
assert json.loads(dashboard.json_metadata)["refresh_frequency"] == 30
finally:
dashboard.json_metadata = original_json_metadata
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.daos.dashboard.g")
@patch("superset.security.manager.g")

View File

@@ -532,6 +532,42 @@ class TestQueryContextFactory:
assert query_object.columns == ["ds", "other_col"]
def test_apply_granularity_no_filter_to_remove(self):
"""No x-axis and no temporal filters leaves the filters untouched."""
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [{"col": "other_col", "op": "==", "val": "value"}]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [{"col": "other_col", "op": "==", "val": "value"}]
def test_apply_granularity_with_adhoc_temporal_filter(self):
"""An adhoc temporal filter is matched on its SQL expression."""
adhoc_column = {"label": "ds_expr", "sqlExpression": "DATE(ds)"}
query_object = Mock(spec=QueryObject)
query_object.granularity = "P1D"
query_object.columns = ["other_col"]
query_object.post_processing = []
query_object.filter = [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"},
{"col": "DATE(ds)", "op": "TEMPORAL_RANGE", "val": "a : b"},
]
datasource = Mock()
datasource.columns = [{"column_name": "ds", "is_dttm": True}]
self.factory._apply_granularity(query_object, {}, datasource)
assert query_object.filter == [
{"col": adhoc_column, "op": "TEMPORAL_RANGE", "val": "a : b"}
]
def test_apply_filters_with_time_range(self):
"""Test _apply_filters with time_range"""
query_object = Mock(spec=QueryObject)

View File

@@ -24,6 +24,7 @@ from superset.connectors.sqla.models import Database, SqlaTable
from superset.daos.dashboard import DashboardDAO
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.utils import json
from tests.unit_tests.conftest import with_feature_flags
@@ -117,3 +118,53 @@ def test_set_dash_metadata_preserves_soft_deleted_members(
)
# And the position slot kept its UUID rather than being nulled.
assert positions["CHART-trashed"]["meta"]["uuid"] == str(trashed_chart.uuid)
def test_set_dash_metadata_preserves_refresh_frequency(session: Session) -> None:
"""set_dash_metadata must not reset refresh_frequency when absent from data.
Regression test for #42116: ``data.get("refresh_frequency", 0)`` would
unconditionally overwrite the existing value with 0 whenever the caller
did not include ``refresh_frequency`` in the data dict.
"""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that does NOT include refresh_frequency
# (e.g. changing only the title via the PropertiesModal).
DashboardDAO.set_dash_metadata(dashboard, {"color_scheme": "superset"})
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 30, (
"refresh_frequency should be preserved when not present in data"
)
def test_set_dash_metadata_updates_refresh_frequency_when_present(
session: Session,
) -> None:
"""set_dash_metadata must update refresh_frequency when it IS in data."""
Dashboard.metadata.create_all(session.get_bind())
dashboard = Dashboard(
dashboard_title="refresh_test_dash_2",
json_metadata=json.dumps({"refresh_frequency": 30}),
)
db.session.add(dashboard)
db.session.flush()
# Simulate a save that explicitly sets refresh_frequency to 0.
DashboardDAO.set_dash_metadata(
dashboard, {"refresh_frequency": 0, "color_scheme": "superset"}
)
md = json.loads(dashboard.json_metadata)
assert md["refresh_frequency"] == 0, (
"refresh_frequency should be updated when present in data"
)

View File

@@ -571,3 +571,53 @@ def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
# Must not raise — falls back to str()
result = stringify_values(data)
assert result[0] == str({"key": _Unserializable()})
def test_empty_result_set_preserves_column_metadata() -> None:
"""
Test that column metadata is preserved when query returns zero rows.
When a query returns no data but has a valid cursor description, the
column names and types from cursor_description should be preserved
in the result set. This allows downstream consumers (like the UI)
to display column headers even for empty result sets.
"""
data: DbapiResult = []
description = [
("id", "int", None, None, None, None, True),
("name", "varchar", None, None, None, None, True),
("created_at", "timestamp", None, None, None, None, True),
]
result_set = SupersetResultSet(
data,
description, # type: ignore
BaseEngineSpec,
)
# Verify column count
assert len(result_set.columns) == 3
# Verify column names are preserved
column_names = [col["column_name"] for col in result_set.columns]
assert column_names == ["id", "name", "created_at"]
assert result_set.columns[0]["type"] == BaseEngineSpec.get_datatype(
description[0][1]
)
assert result_set.columns[1]["type"] == BaseEngineSpec.get_datatype(
description[1][1]
)
assert result_set.columns[2]["type"] == BaseEngineSpec.get_datatype(
description[2][1]
)
# Verify the PyArrow table has the correct schema
assert result_set.table.num_rows == 0
assert len(result_set.table.column_names) == 3
assert list(result_set.table.column_names) == ["id", "name", "created_at"]
# Verify DataFrame conversion works
df = result_set.to_pandas_df()
assert len(df) == 0
assert list(map(str, df.columns)) == ["id", "name", "created_at"]