mirror of
https://github.com/apache/superset.git
synced 2026-07-28 17:42:40 +00:00
Compare commits
1 Commits
fix/dashbo
...
feat/publi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0e3bce25 |
65
.github/workflows/superset-python-unittest-report.yml
vendored
Normal file
65
.github/workflows/superset-python-unittest-report.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
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"
|
||||
33
.github/workflows/superset-python-unittest.yml
vendored
33
.github/workflows/superset-python-unittest.yml
vendored
@@ -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
|
||||
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
|
||||
- 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
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
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
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
@@ -89,6 +89,33 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
@@ -42,30 +41,6 @@ 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');
|
||||
@@ -284,56 +259,6 @@ 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;
|
||||
|
||||
@@ -238,19 +238,17 @@ const PropertiesModal = ({
|
||||
}, handleErrorResponse);
|
||||
}, [dashboardId, handleDashboardData]);
|
||||
|
||||
// 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 = () => {
|
||||
const getJsonMetadata = () => {
|
||||
try {
|
||||
return jsonMetadata?.length ? JSON.parse(jsonMetadata) : {};
|
||||
const jsonMetadataObj = jsonMetadata?.length
|
||||
? JSON.parse(jsonMetadata)
|
||||
: {};
|
||||
return jsonMetadataObj;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const getJsonMetadata = () => parseJsonMetadata() ?? {};
|
||||
|
||||
const handleOnChangeEditors = (values: SubjectPickerValue[]) => {
|
||||
setEditors(mapPickerValuesToSubjects(values));
|
||||
};
|
||||
@@ -345,12 +343,7 @@ const PropertiesModal = ({
|
||||
? resettableCustomLabels
|
||||
: false;
|
||||
const jsonMetadataObj = getJsonMetadata();
|
||||
// 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.refresh_frequency = refreshFrequency;
|
||||
jsonMetadataObj.show_chart_timestamps = Boolean(showChartTimestamps);
|
||||
const customLabelColors = jsonMetadataObj.label_colors || {};
|
||||
const updatedDashboardMetadata = {
|
||||
@@ -544,19 +537,8 @@ 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(
|
||||
@@ -598,15 +580,7 @@ const PropertiesModal = ({
|
||||
const refreshLimit =
|
||||
dashboardInfo?.common?.conf
|
||||
?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
|
||||
// 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,
|
||||
);
|
||||
return validateRefreshFrequency(refreshFrequency, refreshLimit);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 filter_to_remove and is_adhoc_column(filter_to_remove): # type: ignore
|
||||
if is_adhoc_column(filter_to_remove): # type: ignore
|
||||
filter_to_remove = filter_to_remove.get("sqlExpression")
|
||||
|
||||
if filter_to_remove:
|
||||
|
||||
@@ -206,9 +206,11 @@ class QueryCacheManager:
|
||||
)
|
||||
query_cache.status = QueryStatus.SUCCESS
|
||||
query_cache.is_loaded = True
|
||||
query_cache.is_cached = True
|
||||
query_cache.is_cached = cache_value is not None
|
||||
query_cache.sql_rowcount = cache_value.get("sql_rowcount", None)
|
||||
query_cache.cache_dttm = cache_value["dttm"]
|
||||
query_cache.cache_dttm = (
|
||||
cache_value["dttm"] if cache_value is not None else None
|
||||
)
|
||||
query_cache.queried_dttm = cache_value.get(
|
||||
"queried_dttm", cache_value.get("dttm")
|
||||
)
|
||||
|
||||
@@ -396,36 +396,14 @@ class DashboardDAO(BaseDAO[Dashboard]):
|
||||
else:
|
||||
md["color_namespace"] = data.get("color_namespace")
|
||||
|
||||
# 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["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", {})
|
||||
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
|
||||
|
||||
@@ -534,7 +534,6 @@ 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", {})
|
||||
|
||||
@@ -1235,7 +1235,6 @@ 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 [
|
||||
@@ -1249,7 +1248,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 = defaultdict(int)
|
||||
unnested_rows: dict[int, int] = defaultdict(int)
|
||||
current_array_level = level
|
||||
|
||||
name = column["column_name"]
|
||||
|
||||
@@ -73,46 +73,6 @@ 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")
|
||||
|
||||
@@ -532,42 +532,6 @@ 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)
|
||||
|
||||
@@ -24,7 +24,6 @@ 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
|
||||
|
||||
|
||||
@@ -118,53 +117,3 @@ 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"
|
||||
)
|
||||
|
||||
@@ -571,53 +571,3 @@ 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"]
|
||||
|
||||
Reference in New Issue
Block a user