Compare commits

..

2 Commits

Author SHA1 Message Date
Evan Rusackas
82b609addb ci: cache apt packages with awalsh128/cache-apt-pkgs-action (#42499) 2026-07-27 21:46:23 -07:00
Evan Rusackas
10d2f0c585 ci: use astral-sh/setup-uv to speed up backend CI
setup-backend (used in 17 job steps across 13 workflows) bootstrapped uv
via `pip install --upgrade pip setuptools wheel uv` on every run, and only
cached pip's download dir via actions/setup-python's `cache: pip` -- pip
wasn't doing the actual installs (uv was), so that cache bought nothing.

astral-sh/setup-uv ships a prebuilt uv binary instead of installing it via
pip, and its own GitHub Actions cache (enable-cache: true) persists uv's
resolution/wheel cache across runs, keyed on requirements/*.txt and
pyproject.toml by default. Also drops the now-redundant pip/setuptools/wheel
upgrade -- uv's build isolation installs anything a package's build backend
declares (pyproject.toml's [build-system] already requires setuptools/wheel)
without needing them preinstalled in the target environment.

Also swaps the same `pip install uv` bootstrap in bump-python-package.yml.

The uv step is skipped when install-superset: false (helm-lint-test), since
otherwise there's nothing for it to install or cache and it fails the job.
2026-07-27 21:34:27 -07:00
13 changed files with 25 additions and 541 deletions

View File

@@ -5,10 +5,6 @@ inputs:
description: 'Python version to set up. Accepts a version number, "current", or "next".'
required: true
default: 'current'
cache:
description: 'Cache dependencies. Options: pip'
required: false
default: 'pip'
requirements-type:
description: 'Type of requirements to install. Options: base, development, default'
required: false
@@ -43,17 +39,24 @@ runs:
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ steps.set-python-version.outputs.python-version }}
cache: ${{ inputs.cache }}
- name: Install uv
if: inputs.install-superset == 'true'
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: ${{ steps.set-python-version.outputs.python-version }}
enable-cache: true
- name: Install apt packages
if: inputs.install-superset == 'true'
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libldap2-dev libsasl2-dev
version: 1.0
- name: Install dependencies
env:
INPUT_INSTALL_SUPERSET: ${{ inputs.install-superset }}
INPUT_REQUIREMENTS_TYPE: ${{ inputs.requirements-type }}
run: |
if [ "$INPUT_INSTALL_SUPERSET" = "true" ]; then
sudo apt-get update && sudo apt-get -y install libldap2-dev libsasl2-dev
pip install --upgrade pip setuptools wheel uv
if [ "$INPUT_REQUIREMENTS_TYPE" = "dev" ]; then
uv pip install --system -r requirements/development.txt
elif [ "$INPUT_REQUIREMENTS_TYPE" = "base" ]; then

View File

@@ -45,7 +45,10 @@ jobs:
python-version: "3.11"
- name: Install uv
run: pip install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.11"
enable-cache: true
- name: supersetbot bump-python -p "${{ github.event.inputs.package }}"
env:

View File

@@ -1,134 +0,0 @@
name: Frontend bundle size (nightly baseline + analyzer)
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
# report of the same build. Deliberately NOT triggered on every push to
# master: a day-old baseline/report is fine for catching relative
# regressions on PRs and for browsing what's actually in the bundle, and
# building the production bundle on every one of the many pushes master
# gets per day would burn CI time for no benefit a nightly refresh doesn't
# already cover.
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env:
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
permissions:
contents: read
jobs:
refresh-baseline:
runs-on: ubuntu-26.04
timeout-minutes: 30
steps:
- name: "Checkout master"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: master
- name: Build Docker Image
run: |
docker buildx build \
-t $TAG \
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
--target superset-node-ci \
.
# Same cache the PR-time bundle-size job restores/writes -- webpack's
# persistent filesystem cache turns a warm production build into ~20s
# instead of several minutes. See superset-frontend.yml for the
# matching restore step and why it's keyed this way.
- name: Restore webpack build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: superset-frontend/.temp_cache
key: >-
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
'superset-frontend/webpack.config.js') }}
# Only ever pull the last recorded data point off the cache, keyed by
# run ID -- `restore-keys` prefix-matches the most recently created
# entry. Absent on the very first run ever; benchmark-action starts a
# fresh history in that case.
- name: Restore bundle size history
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
restore-keys: |
bundle-size-history-
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
# they're independent env-gated additions in webpack.config.js (one
# sets `config.stats`, the other pushes plugins), so one production
# build produces both the numeric stats.json and the analyzer's
# report.html. Only report.html is mounted out, not
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
# documented in webpack.config.js as routinely exceeding 100MB for
# this app (it's .gitignore'd for exactly that reason), too large to
# publish as a static site page.
- name: Build production bundle with stats and analyzer report
run: |
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
mkdir -p ${{ github.workspace }}/superset/static/assets
docker run \
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
--rm $TAG \
bash -c \
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
- name: Summarize bundle size
run: |
node superset-frontend/scripts/bundle-size-summary.js \
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
rm -rf superset-frontend/bundle-stats
# No PR to comment on here, so comment-on-alert is off -- the job
# summary (summary-always) is the only surface for this run.
- name: Update bundle size baseline
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
fail-on-alert: false
summary-always: true
- name: Save bundle size history
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
# Publishes the treemap to Netlify (the same host already used for
# superset-storybook.netlify.app and docs previews, reusing the
# existing NETLIFY_AUTH_TOKEN). Skipped until
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
# Netlify site named superset-bundle-analyzer and add its site ID as
# that secret to turn this on; nothing else in this workflow depends
# on it.
- name: Publish bundle analyzer report to Netlify
if: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID != '' }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
run: |
mkdir -p netlify-publish
cp superset/static/assets/report.html netlify-publish/index.html
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
# tool, not an application dependency; a global/npx install has no
# lockfile context. Version pinned above the floor set by other
# ad-hoc installs in this repo (bump deliberately when upgrading).
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish

View File

@@ -76,7 +76,10 @@ jobs:
distribution: "zulu"
java-version: "21"
- name: Install Graphviz
run: sudo apt-get install -y graphviz
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: graphviz
version: 1.0
- name: Compute Entity Relationship diagram (ERD)
env:
SUPERSET_SECRET_KEY: not-a-secret

View File

@@ -195,91 +195,3 @@ jobs:
run: |
docker run --rm $TAG bash -c \
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"
# Compares a PR's own bundle size against the last nightly-recorded
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
# persisting new baselines). PR-only: a push to master doesn't need this
# check re-run against itself, and re-persisting the baseline on every
# push to master -- which happens many times a day -- would burn a full
# production build for no benefit nightly refresh doesn't already cover.
bundle-size:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
runs-on: ubuntu-26.04
timeout-minutes: 15
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download Docker Image Artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: docker-image
- name: Load Docker Image
run: |
zstd -d < docker-image.tar.zst | docker load
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
# turns a warm production build into ~20s instead of several minutes,
# but GH-hosted runners are fresh VMs with nothing carried over between
# jobs -- without restoring it explicitly, every single PR would pay
# the full cold-build cost. Keyed on the same files webpack's own
# `buildDependencies` invalidates on, so a stale cache is never used.
- name: Restore webpack build cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: superset-frontend/.temp_cache
key: >-
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
'superset-frontend/webpack.config.js') }}
# Only ever pull the last recorded data point off the cache, keyed by
# run ID -- `restore-keys` prefix-matches the most recently created
# entry, which is always the latest nightly run. Absent before the
# first nightly run ever happens; benchmark-action starts a fresh
# history in that case.
- name: Restore bundle size history
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: bundle-size-history.json
key: bundle-size-history-${{ github.run_id }}
restore-keys: |
bundle-size-history-
- name: Build production bundle with stats
run: |
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
docker run \
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
--rm $TAG \
bash -c \
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
- name: Summarize bundle size
run: |
node superset-frontend/scripts/bundle-size-summary.js \
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
rm -rf superset-frontend/bundle-stats
# Comparison + alert only -- this job never persists. See
# frontend-bundle-size-nightly.yml for why.
- name: Compare bundle size against nightly baseline
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
with:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true
alert-threshold: "110%"
fail-on-alert: false
summary-always: true

View File

@@ -78,7 +78,10 @@ jobs:
- name: Install gettext tools
if: steps.check.outputs.python == 'true' || steps.check.outputs.frontend == 'true'
run: sudo apt-get update && sudo apt-get install -y gettext
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: gettext
version: 1.0
# Fetch the base ref so we can compare PR-introduced regressions
# against a fair baseline (also runs babel_update against the base

View File

@@ -31,8 +31,6 @@ under the License.
[![Open PRs](https://img.shields.io/github/issues-pr/apache/superset)](https://github.com/apache/superset/pulls)
[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](http://bit.ly/join-superset-slack)
[![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.apache.org)
[![Storybook](https://img.shields.io/badge/storybook-live-ff4785.svg)](https://superset-storybook.netlify.app)
[![Bundle Analyzer](https://img.shields.io/badge/bundle%20analyzer-nightly-8dd6f9.svg)](https://superset-bundle-analyzer.netlify.app)
<picture width="500">
<source

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
// Reduces a webpack `--json` stats file down to the handful of headline
// numbers worth tracking over time, in the flat array format
// benchmark-action/github-action-benchmark expects for its
// "customSmallerIsBetter" tool. The full stats file also includes a
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
// useful for this purpose, so we only ever read `entrypoints`.
//
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
const fs = require('fs');
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
// `preamble`, `theme`, and `service-worker` are small, low-variance
// infrastructure chunks, not where bundle bloat actually shows up.
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
function entrypointSizeByExt(entrypoint, ext) {
return (entrypoint.assets || [])
.filter(asset => asset.name.endsWith(ext))
.reduce((total, asset) => total + asset.size, 0);
}
function main() {
const statsPath = process.argv[2];
if (!statsPath) {
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
process.exit(1);
}
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
const { entrypoints } = stats;
if (!entrypoints) {
console.error(
'stats.json has no `entrypoints` key -- was it generated with ' +
'`--stats=normal` (or richer)? `minimal`/`errors-only` stats omit it.',
);
process.exit(1);
}
const results = [];
TRACKED_ENTRYPOINTS.forEach(name => {
const entrypoint = entrypoints[name];
if (!entrypoint) {
console.error(`stats.json is missing the "${name}" entrypoint`);
process.exit(1);
}
results.push({
name: `${name} entrypoint (JS)`,
unit: 'bytes',
value: entrypointSizeByExt(entrypoint, '.js'),
});
results.push({
name: `${name} entrypoint (CSS)`,
unit: 'bytes',
value: entrypointSizeByExt(entrypoint, '.css'),
});
});
console.log(JSON.stringify(results, null, 2));
}
if (require.main === module) {
main();
}
module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS };

View File

@@ -1,107 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const fs = require('fs');
const {
entrypointSizeByExt,
main,
} = require('../../scripts/bundle-size-summary');
function mockStats(entrypoints) {
jest
.spyOn(fs, 'readFileSync')
.mockReturnValue(JSON.stringify({ entrypoints }));
}
function mockExit() {
return jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
}
afterEach(() => {
jest.restoreAllMocks();
});
test('entrypointSizeByExt sums only assets matching the given extension', () => {
const entrypoint = {
assets: [
{ name: 'spa.entry.js', size: 100 },
{ name: 'spa.entry.js.map', size: 500 },
{ name: 'spa.entry.css', size: 20 },
],
};
expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100);
expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20);
});
test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => {
expect(entrypointSizeByExt({}, '.js')).toBe(0);
});
test('main prints byte totals for every tracked entrypoint', () => {
mockStats({
spa: { assets: [{ name: 'spa.js', size: 100 }] },
embedded: { assets: [{ name: 'embedded.js', size: 50 }] },
});
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
main();
const printed = JSON.parse(logSpy.mock.calls[0][0]);
expect(printed).toEqual([
{ name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 },
{ name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 },
{ name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 },
{ name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 },
]);
});
test('main exits with an error when a tracked entrypoint is missing from stats.json', () => {
mockStats({ spa: { assets: [] } });
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockExit();
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
expect(main).toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('missing the "embedded" entrypoint'),
);
});
test('main exits with an error when stats.json has no `entrypoints` key', () => {
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({}));
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockExit();
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
expect(main).toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('no `entrypoints` key'),
);
});
test('main prints a usage message and exits when no stats path is given', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockExit();
process.argv = ['node', 'bundle-size-summary.js'];
expect(main).toThrow('process.exit called');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
});

View File

@@ -738,14 +738,4 @@ const smp = new SpeedMeasurePlugin({
disable: !measure,
});
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
// above omits both). Not `normal`/`detailed` stats: those also serialize the
// full ~15k-module dependency graph, which is hundreds of MB for this app --
// large enough to exceed Node's max string length when read back with
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
if (process.env.BUNDLE_SIZE_STATS) {
config.stats = { all: false, assets: true, entrypoints: true };
}
module.exports = smp.wrap(config);

View File

@@ -397,8 +397,7 @@ class DashboardDAO(BaseDAO[Dashboard]):
md["color_namespace"] = data.get("color_namespace")
md["expanded_slices"] = data.get("expanded_slices", {})
if "refresh_frequency" in data:
md["refresh_frequency"] = data["refresh_frequency"]
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", [])

View File

@@ -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"
)

View File

@@ -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"]