Compare commits

..
Author SHA1 Message Date
Amin Ghadersohi 81e431cd50 fix(mcp): return all chart query results (#42824) 2026-08-14 16:10:26 -04:00
Amin Ghadersohi edfb009e1c fix(mcp): expose query context to Jinja macros (#42822) 2026-08-14 16:10:03 -04:00
Amin Ghadersohi e808fcbcad fix(mcp): bind all generated charts to dashboard time filters (#42490) 2026-08-14 16:07:50 -04:00
Evan RusackasandClaude Code 0a7ebe1dd1 fix(ci): stop superseded Docs Deployment runs from showing as cancelled (#42488)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-14 12:14:08 -07:00
Evan RusackasandClaude Opus 4.8 dd1afb029f fix(ci): stop py311/py312 docker builds from silently matching lean (#42509)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 12:07:23 -07:00
joeyandAmin Ghadersohi c068a8c09c fix(dashboard): preserve certification fields when Certification panel is closed (#42957)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-08-14 11:19:39 -07:00
Evan RusackasandClaude Code b62ec512d2 fix(explore): hide edit-properties menu item for non-owner/non-editor users (#38884) (#42737)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-14 11:15:10 -07:00
1e65d93a83 fix(sql): handle ORDER BY in embedded MSSQL queries (#43127)
Co-authored-by: Bexultan Mustafin <bexultan.mustafin@ffins.kz>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-08-14 14:07:10 -04:00
Gabriel Torres Ruiz 856599027a fix(mcp): harden the embedded-guest chart data-read path 2026-08-14 14:55:11 -03:00
Evan RusackasandClaude Code c395b9a238 fix(examples): migrate Life Expectancy VS Rural % example off the removed legacy bubble viz_type (#43152)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-14 10:27:14 -07:00
92728169de docs: document the zstd prerequisite for the frontend dev server (#43109)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-14 20:41:13 +07:00
DanielSwift1992 84c371d56e chore: fix stale developer_portal paths (#43143) 2026-08-14 20:12:08 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a4c47359e6 chore(deps): bump the storybook group in /docs with 2 updates (#43154)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7c0c5283c3 chore(deps-dev): bump the storybook group in /superset-frontend with 5 updates (#43155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:20 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9c5bde9491 chore(deps): bump mapbox-gl from 3.28.0 to 3.28.1 in /superset-frontend (#43156)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:16 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3b99e092d0 chore(deps-dev): bump tsx from 4.23.9 to 4.23.10 in /superset-frontend (#43157)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:13 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> acf39e3ef0 chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /superset-frontend (#43158)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8523ea4d0a chore(deps): bump caniuse-lite from 1.0.30001806 to 1.0.30001807 in /docs (#43159)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:06 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9bc3173e3a chore(deps): bump antd from 6.5.3 to 6.5.4 in /docs (#43160)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:03 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a6db0d1cde chore(deps): bump query-string from 9.4.1 to 9.5.0 in /superset-frontend (#43161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:34:00 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aec567f7d6 chore(deps): bump antd from 6.5.3 to 6.5.4 in /superset-frontend (#43162)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-14 02:33:56 -07:00
Evan RusackasandClaude Code bacaf08a22 docs(perf): add Dashboard Performance guide covering virtualization, lazy tabs, and chart-count guidance (#40238)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-13 23:41:00 -07:00
a188e9473a feat(chart): let Drill By scope to the clicked x-axis value (#42296)
Co-authored-by: Jacob Hartmann (BUVM-STIL) <jacob.hartmann@stil.dk>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-13 17:02:27 -07:00
67 changed files with 3974 additions and 527 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
# Notify PMC members of changes to extension-related files
/docs/developer_portal/extensions/ @michael-s-molina @villebro @rusackas
/docs/developer_docs/extensions/ @michael-s-molina @villebro @rusackas
/superset-extensions-cli/ @michael-s-molina @villebro @rusackas @sadpandajoe
/superset/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
/superset-frontend/src/extensions/ @michael-s-molina @villebro @rusackas @sadpandajoe
+56 -7
View File
@@ -51,6 +51,53 @@ jobs:
echo "matrix_config=${MATRIX_CONFIG}" >> $GITHUB_OUTPUT
echo $GITHUB_OUTPUT
# Runs unconditionally (no dependency on `changes`, and no build-preset
# matrix restriction) so a regression in the PY_VER override logic is
# always caught on PRs. Without this, the real docker-build job only runs
# when the change detector flags docker/python/frontend changes (a
# workflow-only edit like this one does not), and even then the PR build
# matrix never includes the "py311"/"py312" presets that logic protects -
# so a break here would otherwise first surface on a push to master.
pyver-override-check:
name: verify docker build PY_VER override
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Assert PY_VER override applies to every preset except py311/py312
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Asserts against the actual buildx command line `supersetbot docker
# --dry-run` would run, not just this repo's own extra-flags helper,
# so a regression in supersetbot itself (dropping the py311/py312
# PY_VER pin, or reordering args so our override no longer lands
# last) is caught here too, instead of only surfacing on master.
assert_effective_py_ver() {
local preset="$1" expected="$2" extra_flags command actual
extra_flags="$(scripts/docker-build-extra-flags.sh "$preset" dummy-tag)"
command="$(supersetbot docker --preset "$preset" --platform linux/amd64 --extra-flags "$extra_flags" --dry-run)"
# docker buildx keeps the LAST value of a repeated --build-arg key.
actual="$(grep -oE -- '--build-arg PY_VER=[^[:space:]]+' <<<"$command" | tail -1)"
if [ "$actual" != "--build-arg PY_VER=$expected" ]; then
echo "::error::preset '$preset' expected effective --build-arg PY_VER=$expected, got: ${actual:-<none>} (full command: $command)"
exit 1
fi
}
for preset in dev lean websocket dockerize; do
assert_effective_py_ver "$preset" "3.11.14-slim-trixie"
done
assert_effective_py_ver py311 "3.11-slim-bookworm"
assert_effective_py_ver py312 "3.12-slim-bookworm"
echo "PY_VER override logic verified against the assembled buildx command for all build presets"
docker-build:
name: docker-build
needs: [setup_matrix, changes]
@@ -124,19 +171,21 @@ jobs:
# the whole job. buildx reuses the buildkit layer cache from the
# failed attempt, so a retry mostly re-does just the failed push.
#
# supersetbot's "dev"/"lean" presets pin their own --build-arg
# PY_VER, which lands ahead of --extra-flags on the assembled
# buildx command line; docker/buildx keeps the last value for a
# repeated --build-arg key, so appending PY_VER here overrides
# supersetbot's pin and keeps the build on the Dockerfile's own
# supported Python version.
# See scripts/docker-build-extra-flags.sh for why "py311"/"py312"
# are excluded from the PY_VER override applied to every other
# preset; that logic is also exercised on every PR by the
# always-on pyver-override-check job below, since this job itself
# only runs when the change detector flags docker/python/frontend
# changes and the PR build matrix never includes py311/py312.
EXTRA_FLAGS="$(scripts/docker-build-extra-flags.sh "$BUILD_PRESET" "$IMAGE_TAG")"
for attempt in 1 2 3; do
if supersetbot docker \
$PUSH_OR_LOAD \
--preset "$BUILD_PRESET" \
--context "$EVENT" \
--context-ref "$RELEASE" $FORCE_LATEST \
--extra-flags "--build-arg PY_VER=3.11.14-slim-trixie --build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
--extra-flags "$EXTRA_FLAGS" \
$PLATFORM_ARG; then
break
fi
@@ -45,5 +45,8 @@ jobs:
- name: Run Script
run: bash .github/workflows/github-action-validator.sh
- name: Test docs-deploy freshness gate
run: bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
- name: Check for security issues on GHA workflows
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
#
# 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.
#
# Shared freshness gate used by the Docs Deployment workflow
# (superset-docs-deploy.yml) both up front (check-freshness) and again right
# before the deploy step (recheck-freshness). Writes an output declaring
# whether BUILD_SHA is still master's current tip, so a superseded run can
# skip cleanly instead of racing (and clobbering, or being force-cancelled
# by) a fresher run.
#
# Required env vars:
# BUILD_SHA - the commit SHA this run is building
# REPO - "owner/repo" to query, e.g. github.repository
# OUTPUT_NAME - the GITHUB_OUTPUT key to write, e.g. "is-current"
# GITHUB_OUTPUT - path to append outputs to (set by the Actions runner)
# Optional env vars:
# EVENT_NAME - if "workflow_dispatch", bypasses the check and always
# reports current, since a manual dispatch is a deliberate,
# one-off action rather than something racing other triggers
# GH_TOKEN - passed through to `gh`, needed to call the GitHub API
set -euo pipefail
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
exit 0
fi
latest_sha="$(gh api "repos/${REPO}/commits/master" --jq .sha)"
if [ "${latest_sha}" = "${BUILD_SHA}" ]; then
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
else
echo "${OUTPUT_NAME}=false" >>"$GITHUB_OUTPUT"
echo "::notice::master has moved on to ${latest_sha} since ${BUILD_SHA} was triggered — skipping this stale run."
fi
@@ -0,0 +1,100 @@
#!/bin/bash
#
# 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.
#
# Exercises check-docs-deploy-freshness.sh against a stubbed `gh`, covering
# the dispatch-bypass, current-tip and stale-tip branches so the output
# contract (is-current / still-current) can't silently regress. Run
# directly, no extra tooling required:
# bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
script_under_test="${script_dir}/check-docs-deploy-freshness.sh"
failures=0
# Runs the script under test with a stubbed `gh` reporting $1 as master's
# latest sha, asserting that GITHUB_OUTPUT ends up containing exactly $4.
run_case() {
local case_name="$1"
local latest_sha="$2"
local build_sha="$3"
local event_name="$4"
local expected_line="$5"
local workdir
workdir="$(mktemp -d)"
trap 'rm -rf "${workdir}"' RETURN
# Fake `gh` that just echoes back the requested "latest" sha regardless of
# arguments, so the script under test never touches the network.
cat >"${workdir}/gh" <<EOF
#!/bin/bash
echo '${latest_sha}'
EOF
chmod +x "${workdir}/gh"
local output_file="${workdir}/github_output"
: >"${output_file}"
if PATH="${workdir}:${PATH}" \
GITHUB_OUTPUT="${output_file}" \
OUTPUT_NAME="is-current" \
REPO="apache/superset" \
BUILD_SHA="${build_sha}" \
EVENT_NAME="${event_name}" \
GH_TOKEN="fake-token" \
bash "${script_under_test}"; then
:
else
echo "FAIL (${case_name}): script exited non-zero"
failures=$((failures + 1))
return
fi
local actual
actual="$(cat "${output_file}")"
if [ "${actual}" = "${expected_line}" ]; then
echo "PASS (${case_name})"
else
echo "FAIL (${case_name}): expected '${expected_line}', got '${actual}'"
failures=$((failures + 1))
fi
}
# `gh` prints "should-not-be-called" for the dispatch case above the trick:
# it's never actually invoked since the bypass short-circuits before the
# `gh api` call, but the fake still needs a body.
run_case "workflow_dispatch bypasses the check" \
"unused" "abc123" "workflow_dispatch" \
"is-current=true"
run_case "build sha matches master's tip" \
"abc123" "abc123" "push" \
"is-current=true"
run_case "build sha is stale" \
"def456" "abc123" "push" \
"is-current=false"
if [ "${failures}" -gt 0 ]; then
echo "${failures} case(s) failed"
exit 1
fi
echo "All cases passed"
+70 -11
View File
@@ -18,16 +18,6 @@ on:
workflow_dispatch: {}
# Serialize deploys: the action pushes to apache/superset-site without
# rebasing, so concurrent runs race on the final push and the loser fails
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
# in-progress run as soon as a newer one starts — the destination repo
# isn't touched until the final push step, so canceling mid-build is safe,
# and the freshest content always wins.
concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
permissions:
contents: read
actions: read
@@ -48,17 +38,69 @@ jobs:
env:
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
# Master gets frequent, sometimes bursty pushes, and each one can trigger a
# deploy attempt. Rather than let every superseded attempt get force-killed
# by the build-deploy concurrency group below (which shows up as a
# `cancelled` — i.e. red/failing-looking — check on that commit), have each
# run check up front whether it's still building master's current tip and,
# if not, skip cleanly. Deliberately outside the docs-deploy-asf-site
# concurrency group so it runs immediately for every trigger without
# blocking or being blocked by anything.
check-freshness:
runs-on: ubuntu-26.04
outputs:
is-current: ${{ steps.check.outputs.is-current }}
steps:
# Sparse checkout: this job's only job is to be fast, so it fetches
# nothing but the freshness-check script itself.
- name: Checkout freshness-check script
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
.github/workflows/scripts
sparse-checkout-cone-mode: false
- name: "Check whether this is still master's current commit"
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
EVENT_NAME: ${{ github.event_name }}
REPO: ${{ github.repository }}
OUTPUT_NAME: is-current
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
build-deploy:
needs: config
needs: [config, check-freshness]
# Only the run for master's current tip proceeds; anything superseded
# already skipped at check-freshness above instead of landing here.
# For workflow_run triggers, only deploy when the triggering run originated
# from this repository (not a fork), ensuring the checked-out code and any
# local actions executed with deploy credentials are trusted.
if: >-
needs.config.outputs.has-secrets &&
needs.check-freshness.outputs.is-current == 'true' &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.head_repository.full_name == github.repository)
name: Build & Deploy
runs-on: ubuntu-26.04
# Serialize deploys: the action pushes to apache/superset-site without
# rebasing, so concurrent runs race on the final push and the loser fails
# with `! [rejected] asf-site -> asf-site (fetch first)`. Queue instead of
# canceling: a run that already passed check-freshness can still be
# sitting in the queue for a runner when a newer run starts and finishes
# first. cancel-in-progress would let that stale, queued run kill the
# newer run's in-progress deploy the moment it's finally scheduled, and
# then skip itself at the re-check below — losing the deploy entirely.
# Queuing means the stale run just waits its turn and then no-ops at the
# re-check, so the fresher content that already deployed is never
# clobbered or lost. The check-freshness gate above means it should be
# rare for more than one run to reach this point, so the queue stays
# short in practice.
concurrency:
group: docs-deploy-asf-site
cancel-in-progress: false
steps:
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
@@ -130,7 +172,24 @@ jobs:
working-directory: docs
run: |
yarn build
# The check-freshness job above narrows the window but doesn't close it: an
# older run can observe is-current=true, then sit through this build while a
# newer run's own freshness check also passes and it deploys and finishes
# first. If this (stale) run then wins entry into the concurrency group, it
# would overwrite the newer content that already deployed. Re-check right
# before the one step that actually mutates superset-site, so a stale run
# skips deploying instead of clobbering a fresher one that already ran.
- name: "Re-check freshness immediately before deploying"
id: recheck-freshness
if: github.event_name != 'workflow_dispatch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
REPO: ${{ github.repository }}
OUTPUT_NAME: still-current
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
- name: deploy docs
if: github.event_name == 'workflow_dispatch' || steps.recheck-freshness.outputs.still-current == 'true'
uses: ./.github/actions/github-action-push-to-another-repository
env:
API_TOKEN_GITHUB: ${{ secrets.SUPERSET_SITE_BUILD }}
+1 -1
View File
@@ -167,7 +167,7 @@ The Developer Portal auto-generates MDX documentation from Storybook stories. **
### Generator Location
- Script: `docs/scripts/generate-superset-components.mjs`
- Wrapper: `docs/src/components/StorybookWrapper.jsx`
- Output: `docs/developer_portal/components/`
- Output: `docs/developer_docs/components/`
## Architecture Patterns
+1 -1
View File
@@ -35,4 +35,4 @@ The Developer Portal includes comprehensive guides for:
- [Code Review Process](https://superset.apache.org/developer_portal/contributing/code-review)
- [Development How-tos](https://superset.apache.org/developer_portal/contributing/howtos)
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_portal).
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_docs).
-2
View File
@@ -24,8 +24,6 @@ assists people when migrating to a new version.
## Next
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
### OAuth2 database callback metrics include their outcome
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
@@ -0,0 +1,184 @@
---
title: Dashboard Performance
hide_title: true
sidebar_position: 5
version: 1
---
<!--
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.
-->
# Dashboard Performance
A dashboard's perceived speed is determined by three independent things: how
many charts have to render, how many queries the backend can execute
concurrently, and how quickly the underlying data warehouse can return
results. Superset gives you levers for the first two; the third belongs to
your warehouse. This page covers the dashboard-side levers and the practical
guidance around them.
## Is there a maximum chart count per dashboard?
**No hard limit is enforced** — Superset has no configuration key that
caps the number of charts on a dashboard. In practice, dashboards behave
well up to a few dozen charts. Beyond that, you'll typically feel friction
on the initial load and during cross-filter / time-range updates, even with
the lazy-loading optimizations described below.
Rough thresholds to keep in mind:
- **Under ~25 charts**: usually no perceptible problem.
- **2550 charts**: still fine, but you start to want tabs to break the
page into chunks the user actually looks at.
- **Over ~50 charts**: split into multiple dashboards or use tabs
aggressively. The bottleneck is rarely Superset itself — it's the
warehouse executing dozens of queries in parallel and the browser
rendering dozens of chart frames.
These are guidelines, not guarantees. A dashboard of 100 sparkline-style
charts hitting a fast cache behaves very differently from a dashboard of
20 heavy aggregations against a cold warehouse.
## Lazy rendering — `DASHBOARD_VIRTUALIZATION`
Superset's dashboard layout is virtualized at the row level. Charts that
are far below the user's current scroll position render a placeholder
instead of their visualization until the user scrolls them into view, and
go back to a placeholder if scrolled well past. The chart component itself
stays mounted throughout — only the visualization is swapped for a
placeholder — so this alone does **not** reduce backend query load; see
[Deferred data fetch](#deferred-data-fetch--dashboard_virtualization_defer_data)
below for that. This is on by default.
**Feature flag**: `DASHBOARD_VIRTUALIZATION` (default: `True`)
The flag is `stable` and marked for path-to-deprecation — meaning the
behavior will eventually be non-optional, but the flag still exists so
operators can disable it if a specific layout misbehaves.
**Behavior** (from `superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx`):
- A chart's visualization is rendered when its row scrolls within **1
viewport height** of the visible area.
- A chart's visualization is swapped back for a placeholder when its row
scrolls more than **4 viewport heights** away from the visible area.
- Tabs that aren't currently selected don't render their content at all
(see below).
- The placeholder-swap-back is skipped in **embedded** mode (so an
embedded dashboard keeps its charts rendered once they've been seen,
which avoids re-rendering on scroll-up). Both halves are skipped for
**headless / bot** rendering (so screenshot / report jobs load every
chart).
## Deferred data fetch — `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
By default, `DASHBOARD_VIRTUALIZATION` only controls whether a chart's
*visualization* is rendered — the chart component still mounts and issues
its data request immediately, regardless of scroll position.
`DASHBOARD_VIRTUALIZATION_DEFER_DATA` is a supplementary flag that skips
the data request itself for charts that aren't currently in view, useful
for backends where opening a connection or compiling a query is expensive
even if the result would be thrown away. It only has an effect when
`DASHBOARD_VIRTUALIZATION` is also enabled — with virtualization off,
every chart is treated as in view, so there's nothing left to defer.
**Feature flag**: `DASHBOARD_VIRTUALIZATION_DEFER_DATA` (default: `False`)
Enable this if you see warehouse load spike on dashboard *open* even
though most charts are off-screen.
## Per-tab lazy loading
**This is on by default and has no flag.** A tab's content is not rendered
until the user activates that tab, so charts inside an unselected tab do
not fetch data on dashboard open. When the user clicks the tab, that
tab's charts mount and fetch in the normal way.
Practically: tabs are the single most effective tool for a large
dashboard. Splitting 60 charts across 4 tabs effectively turns dashboard
open into "load ~15 charts," and the remaining ones lazy-load only if the
user goes looking.
## Is there a switch to cap concurrent chart queries?
**No.** Superset does not implement a frontend-side concurrent-request
limiter. Each chart issues its own data request when it mounts, and the
browser handles parallelism — typically ~6 in-flight requests per origin
under HTTP/1.1, though HTTP/2 or HTTP/3 (if your deployment terminates
TLS that way) can multiplex considerably more over a single connection.
Backend throughput is bounded by your
Gunicorn worker count for synchronous query execution, or by your Celery
worker pool when [async queries](./async-queries-celery.mdx) are enabled.
If you need to throttle warehouse load, the right place is:
1. The warehouse itself (connection pool / concurrency limits).
2. Superset's Celery configuration (smaller worker pool when async
queries are on).
3. Splitting heavy charts across tabs or separate dashboards (each
dashboard load only fetches what's visible).
## Splitting strategies
When a dashboard outgrows comfortable performance, the options in order
of effort:
**1. Move sections into tabs.** Same dashboard, but only the active tab's
charts fetch. This is the cheapest change and often the only one needed.
**2. Cache aggressively.** A Redis cache backend (see
[Caching](./cache.mdx)) means repeat dashboard loads serve from cache
rather than re-hitting the warehouse. This is especially impactful for
dashboards opened by many users in close succession.
**3. Enable async queries.** [Async query execution](./async-queries-celery.mdx)
via Celery decouples query duration from request lifetime, so a slow
chart doesn't block the page. The user sees other charts come in as
their queries complete.
**4. Split into multiple dashboards.** Group related charts into purpose-
specific dashboards rather than one mega-dashboard. Link them from a
landing dashboard or a navigation menu.
**5. Pre-aggregate at the warehouse level.** If the same expensive
aggregation appears across many charts, materialize it as a view or
scheduled table in the warehouse so each chart query is a cheap lookup.
## Operational notes
- The feature flags above are set in `superset_config.py`, e.g.:
```python
FEATURE_FLAGS = {
"DASHBOARD_VIRTUALIZATION": True,
"DASHBOARD_VIRTUALIZATION_DEFER_DATA": True,
}
```
- See [Feature Flags](./feature-flags.mdx) for the full list of supported
flags and their lifecycle stages.
- Server-side screenshot jobs (alerts, scheduled reports, thumbnails)
render the dashboard in a headless, webdriver-controlled browser, which
intentionally bypasses row virtualization so the rendered artifact
includes every chart, not just the ones above the fold. User-triggered
"download as image/PDF" is different: it captures whatever's currently
rendered in the user's own browser, so it's still subject to
virtualization like any other page view. Metadata/YAML dashboard export
doesn't render the frontend at all, so virtualization doesn't apply to
it either.
@@ -519,6 +519,30 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install
For those interested, you may also try out [avn](https://github.com/nvm-sh/nvm#deeper-shell-integration) to automatically switch to the node version that is required to run Superset frontend.
##### zstd
`npm run dev-server` proxies requests to your local Superset server and rewrites the HTML it returns, so it has to decompress responses sent with `Content-Encoding: zstd`. It does that with [`simple-zstd`](https://www.npmjs.com/package/simple-zstd), which wraps the system `zstd` binary instead of bundling one. That binary has to be on your `PATH`:
```bash
# macOS
brew install zstd
# Ubuntu/Debian
sudo apt install zstd
# Windows
choco install zstd
```
`simple-zstd` looks for the binary when it is first imported, not when a response is decompressed, so a missing `zstd` stops the dev server at startup with:
```
Error: Can not access zstd! Is it installed?
at Object.<anonymous> (.../node_modules/simple-zstd/dist/src/index.js:102:11)
```
The message names the dependency, but it surfaces from inside `webpack.proxy-config.js` while the webpack config is loading, which reads like a build-tooling failure rather than a missing system package.
#### Install dependencies
Install third-party dependencies listed in `package.json` via:
@@ -198,7 +198,7 @@ Each component should come with its dedicated storybook file.
**One component per story:** Each storybook file should only contain one component unless substantially different variants are required
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/src/components/Select/Select.stories.tsx` as an example.
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/packages/superset-ui-core/src/components/Select/Select.stories.tsx` as an example.
**Isolated state:** The storybook should show how the component works in an isolated state and with as few dependencies as possible
+4 -4
View File
@@ -58,12 +58,12 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.6",
"@storybook/addon-docs": "^10.5.7",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.47",
"antd": "^6.5.3",
"antd": "^6.5.4",
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001806",
"caniuse-lite": "^1.0.30001807",
"docusaurus-plugin-openapi-docs": "^5.1.3",
"docusaurus-theme-openapi-docs": "^5.1.3",
"js-yaml": "^5.2.3",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.6",
"storybook": "^10.5.7",
"swagger-ui-react": "^5.32.12",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
+26 -26
View File
@@ -4095,23 +4095,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.6":
version "10.5.6"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.6.tgz#445d4e0992a0862a22bffcea321ee2cb034846b5"
integrity sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==
"@storybook/addon-docs@^10.5.7":
version "10.5.7"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.6"
"@storybook/csf-plugin" "10.5.7"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.6"
"@storybook/react-dom-shim" "10.5.7"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.6":
version "10.5.6"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz#9fca28f5fd7d545a32638bb4f08902f6887072b2"
integrity sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==
"@storybook/csf-plugin@10.5.7":
version "10.5.7"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
dependencies:
unplugin "^2.3.5"
@@ -4125,10 +4125,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.6":
version "10.5.6"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz#3685605c9dd27298fada7fef264801b2e7d62cbb"
integrity sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==
"@storybook/react-dom-shim@10.5.7":
version "10.5.7"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -6164,10 +6164,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.5.3:
version "6.5.3"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.3.tgz#3c7d2ec4a20be116f72b7fbbdb4497d65ffd6dae"
integrity sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==
antd@^6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.4.tgz#b41665e86a5f46ca761abd3b0abef7460116ca0d"
integrity sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6745,10 +6745,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001806:
version "1.0.30001806"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz#1bc8e502b723fa393455dfbedd5ccec0c29bb74e"
integrity sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001799, caniuse-lite@^1.0.30001807:
version "1.0.30001807"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz#a113854941fb45b4c1f51793f4636920489079b4"
integrity sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==
ccount@^2.0.0:
version "2.0.1"
@@ -14765,10 +14765,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.6:
version "10.5.6"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.6.tgz#c91f22f617f3718dd06c58c87b46ff66f4ce7bf5"
integrity sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==
storybook@^10.5.7:
version "10.5.7"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# 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.
# Computes the `--extra-flags` value passed to `supersetbot docker` for a
# given build preset. Factored out of .github/workflows/docker.yml so the
# PY_VER override logic below can be exercised by an always-on CI check
# (docker.yml's docker-build job only runs when the change detector's
# docker/python/frontend outputs are true, and the PR build matrix never
# includes py311/py312 at all, so a regression here would otherwise go
# unnoticed until the fix actually runs on master) without duplicating -
# and risking drift from - the logic used by the real build step.
#
# supersetbot's "py311"/"py312" presets pin their own --build-arg PY_VER,
# which lands ahead of --extra-flags on the assembled buildx command line;
# docker/buildx keeps the last value for a repeated --build-arg key, so
# appending PY_VER here would override supersetbot's pin and silently make
# "py311"/"py312" build the exact same image as "lean". Every other preset
# gets the override so its build lands on the Dockerfile's own supported
# Python version.
#
# Usage: docker-build-extra-flags.sh <build_preset> <image_tag>
set -euo pipefail
BUILD_PRESET="${1:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
IMAGE_TAG="${2:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
EXTRA_FLAGS="--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG"
if [ "$BUILD_PRESET" != "py311" ] && [ "$BUILD_PRESET" != "py312" ]; then
EXTRA_FLAGS="--build-arg PY_VER=3.11.14-slim-trixie $EXTRA_FLAGS"
fi
echo "$EXTRA_FLAGS"
+94 -85
View File
@@ -81,7 +81,7 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.5.3",
"antd": "^6.5.4",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
@@ -108,7 +108,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.28.1",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -116,7 +116,7 @@
"mustache": "^4.2.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"query-string": "9.4.1",
"query-string": "9.5.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react-arborist": "^3.16.0",
@@ -180,9 +180,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.6",
"@storybook/addon-links": "10.5.6",
"@storybook/react-webpack5": "10.5.6",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-links": "10.5.7",
"@storybook/react-webpack5": "10.5.7",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
@@ -235,7 +235,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.6",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -266,13 +266,13 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.6",
"storybook": "10.5.7",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.9",
"tsx": "^4.23.10",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -10741,16 +10741,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.6.tgz",
"integrity": "sha512-zyUJBrrpC9NTrmsREaVFNr+9WW6pikJtmRvo7GgZGqthEyhjQKSarHrW0aNWkwae2ep3jp1CZi8vIUVG1Dnp0w==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.6",
"@storybook/csf-plugin": "10.5.7",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.6",
"@storybook/react-dom-shim": "10.5.7",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10761,7 +10761,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6"
"storybook": "10.5.7"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10770,9 +10770,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.6.tgz",
"integrity": "sha512-PJLyOmcKe1OZDBw7RaGX/gjuiJuVfS5pVgc4W2RnHYOFpU6F5Bv9+9MqQwp0i7tWZBWc4fsCJudgVqwgjuTROA==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10785,7 +10785,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "10.5.6",
"storybook": "10.5.7",
"vite": "*",
"webpack": "*"
},
@@ -10805,9 +10805,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz",
"integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10819,7 +10819,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6"
"storybook": "10.5.7"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10831,9 +10831,9 @@
}
},
"node_modules/@storybook/addon-links": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.6.tgz",
"integrity": "sha512-pw+OS/wUZ4ijdVGOsE5QOt59+C2i4fwtFBs2ircB7KMlwEE7gslovZEjnrz8bbaznvmoLoYGWYZxXcLi+bYmzg==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.7.tgz",
"integrity": "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10846,7 +10846,7 @@
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6"
"storybook": "10.5.7"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10940,15 +10940,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.6.tgz",
"integrity": "sha512-UdsC+IrZHBAtEvvDkfCPhg5sy5jnJAHT4RS3I8wNHVJ+93gaUrZLElSIV+w5UB1u/yghoqmQm7/LfpPpZrjPZQ==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.7.tgz",
"integrity": "sha512-vvl07oXp2qfmHJHZ77Aw1F3LFOo7XubOta+lC8UmlEw3rDDJhQxJN3erJJVHavNhdA2jBTK6VUXQKdqQh7X7nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.5.6",
"@storybook/preset-react-webpack": "10.5.6",
"@storybook/react": "10.5.6"
"@storybook/builder-webpack5": "10.5.7",
"@storybook/preset-react-webpack": "10.5.7",
"@storybook/react": "10.5.7"
},
"funding": {
"type": "opencollective",
@@ -10957,7 +10957,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6",
"storybook": "10.5.7",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10967,13 +10967,13 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.6.tgz",
"integrity": "sha512-uWo/MzNC6HXMEpy8QQfbeYh1j6aOC6Ly0sAR6RE0LPvpyGEWC0VaVOBERIJJBjeuPuonDBVKfEjh1iUvZhJopg==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.7.tgz",
"integrity": "sha512-4n4c60LihFivZnjAcXGO5+XbgZthoUtKb/nPKVgypj3MpEetzjq6XR83A4UNnRsXYmjqfn6bsDWNgEJ/RvQg5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.6",
"@storybook/core-webpack": "10.5.7",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -10995,7 +10995,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.6"
"storybook": "10.5.7"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11004,9 +11004,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.6.tgz",
"integrity": "sha512-o5PP3K+NcJAitZF7Ywweow0d8dJrEA1jxV5T1LMGMiWHUrnpoaPTiK1HcYw39pOQkdKL88mMSfDDUipGurZthw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11017,17 +11017,17 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.6"
"storybook": "10.5.7"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.6.tgz",
"integrity": "sha512-QPUl2t+0VIp1Wy7JfqvV8cI1NrULUt+XFMKdIaNp39TuyMn3El4txvmxQWKhcYvnSOEzQ2SGNDqSWcJISwmeAA==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.7.tgz",
"integrity": "sha512-xwNRcoVlIDx1/YYCFBAxfh/91vFiOgrVI+0Ir4u9eO87SH2leehRnJh619QEOrlQEU5px487y2BmL2ZVtmTpYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.6",
"@storybook/core-webpack": "10.5.7",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -11044,7 +11044,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6"
"storybook": "10.5.7"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11053,9 +11053,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.6.tgz",
"integrity": "sha512-o5PP3K+NcJAitZF7Ywweow0d8dJrEA1jxV5T1LMGMiWHUrnpoaPTiK1HcYw39pOQkdKL88mMSfDDUipGurZthw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11066,18 +11066,18 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.6"
"storybook": "10.5.7"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.6.tgz",
"integrity": "sha512-dXSdNoc9yAvpa4hiegQhmZPXOKunAxkPX94DxvRw/kM6+wujVFAGlZjYygKrWw357KOjPRK7SO1LRTc70mgrhQ==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.6",
"@storybook/react-dom-shim": "10.5.7",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -11090,7 +11090,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6",
"storybook": "10.5.7",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -11106,9 +11106,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.6.tgz",
"integrity": "sha512-dV3oOHc5ImggxEqeIiUj4vvnQO5SKScFtqAkxgIWLju1wiSZSIqQ5Q4Mp12Rhs9hQrjF039DueH7f2xJJZfvSw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11120,7 +11120,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.6"
"storybook": "10.5.7"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -15214,9 +15214,9 @@
}
},
"node_modules/antd": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.5.3.tgz",
"integrity": "sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==",
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.5.4.tgz",
"integrity": "sha512-jchA6i0rEwHjLpgC+l6HeLHP0gL4Q4yjs6Mxqt6PlhGD5ArxCj3ZH+fKFbNquCtd6Rlzzi+emfNFpP2dGLwZzg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
@@ -18913,9 +18913,9 @@
}
},
"node_modules/decode-uri-component": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz",
"integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.5.0.tgz",
"integrity": "sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==",
"license": "MIT",
"engines": {
"node": ">=14.16"
@@ -20522,9 +20522,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz",
"integrity": "sha512-uOXhNkIH+iTdyViSmWnCrwtapasL57M3nq5yfST1H7y9djRLyuAIfNcf9cPBedc2G1oqI8jn3up/VHdN3y3Btw==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -20533,7 +20533,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "10.5.6"
"storybook": "10.5.7"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -28748,9 +28748,9 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.0.tgz",
"integrity": "sha512-WEbvl2ju0MUZ+R83HeCosmJBTyYdhmFcajeQ7kwLyJ0EHUw9YG/k2QLcMmAQ8sXZpkWq1BbmfjT5lh/oInOnCw==",
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
@@ -33668,12 +33668,12 @@
}
},
"node_modules/query-string": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.4.1.tgz",
"integrity": "sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA==",
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-9.5.0.tgz",
"integrity": "sha512-YlJmwNyi0RGYjlxYcuDncMsxFU7YyutbuI7gTm8ySxIGBlwx5yiBCOD5ig9ZNoHkawk/1Dey0N5mEfcUybMVAA==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.4.1",
"decode-uri-component": "^0.5.0",
"filter-obj": "^5.1.0",
"split-on-first": "^3.0.0"
},
@@ -37991,9 +37991,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.6",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.6.tgz",
"integrity": "sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==",
"version": "10.5.7",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -39966,9 +39966,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.9",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz",
"integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==",
"version": "4.23.10",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.10.tgz",
"integrity": "sha512-0Vb9eKU47njkxv/6B8CRZRDsxNDT/Pz+BIU+M5jw7xL3TdzAjSxlZUxu0xFL/kLpaG3sHZ0LH2wbK1T1yo7CUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43353,6 +43353,15 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
@@ -43936,7 +43945,7 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.28.1",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^8.0.1"
+9 -9
View File
@@ -158,7 +158,7 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.5.3",
"antd": "^6.5.4",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
@@ -185,7 +185,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.28.1",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -193,7 +193,7 @@
"mustache": "^4.2.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"query-string": "9.4.1",
"query-string": "9.5.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react-arborist": "^3.16.0",
@@ -257,9 +257,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.6",
"@storybook/addon-links": "10.5.6",
"@storybook/react-webpack5": "10.5.6",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-links": "10.5.7",
"@storybook/react-webpack5": "10.5.7",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
@@ -312,7 +312,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.6",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -343,13 +343,13 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.6",
"storybook": "10.5.7",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
"ts-jest": "^29.4.12",
"tscw-config": "^1.1.2",
"tsx": "^4.23.9",
"tsx": "^4.23.10",
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
@@ -45,6 +45,13 @@ export interface ContextMenuFilters {
filters: BinaryQueryObjectFilterClause[];
groupbyFieldName: string;
adhocFilterFieldName?: string;
/**
* Filters scoped to the clicked x-axis value (category or time bucket),
* as opposed to `filters`, which are scoped to the clicked series.
* When both are present, the Drill By UI lets the user choose which
* of the two (or both) to apply to the drilled chart.
*/
xAxisFilters?: BinaryQueryObjectFilterClause[];
};
}
@@ -21,7 +21,8 @@ import {
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import { AxisType, TimeGranularity } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
@@ -655,3 +656,99 @@ test('context menu cross-filter uses the category value for a horizontal categor
]);
}
});
// A category axis can still sit on a temporal column when the axis is
// forced categorical (xAxisForceCategorical); the drillBy x-axis filter must
// then bucket by the configured time grain rather than doing an exact match.
test('drillBy filters by time bucket when a categorical axis is forced onto a temporal column', async () => {
const onContextMenuMock = jest.fn();
const propsWithForcedCategoricalTemporalAxis: TimeseriesChartTransformedProps =
{
...defaultProps,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
xAxisForceCategorical: true,
timeGrainSqla: TimeGranularity.MONTH,
},
coltypeMapping: { order_date: GenericDataType.Temporal },
xAxis: {
label: 'order_date',
type: AxisType.Category,
},
};
render(<EchartsTimeseries {...propsWithForcedCategoricalTemporalAxis} />);
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
expect(contextMenuHandler).toBeDefined();
await contextMenuHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: ['2021-02-01T00:00:00', 100],
name: '2021-02-01T00:00:00',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenuMock).toHaveBeenCalled();
});
const { drillBy } = onContextMenuMock.mock.calls[0][2];
expect(drillBy.xAxisFilters).toEqual([
{
col: 'order_date',
op: 'TEMPORAL_RANGE',
val: '2021-02-01T00:00:00 : 2021-03-01T00:00:00',
formattedVal: '2021-02-01T00:00:00',
},
]);
});
// For horizontal orientation the [x, value] pair reported by ECharts is
// swapped, so the drillBy x-axis filter must read the clicked time value
// from the second element of the data tuple rather than the first.
test('drillBy uses the swapped data index for a horizontal time-based axis', async () => {
const onContextMenuMock = jest.fn();
const propsWithHorizontalTimeAxis: TimeseriesChartTransformedProps = {
...defaultProps,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
xAxis: {
label: 'order_date',
type: AxisType.Time,
},
};
render(<EchartsTimeseries {...propsWithHorizontalTimeAxis} />);
const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
expect(contextMenuHandler).toBeDefined();
await contextMenuHandler?.({
componentType: 'series',
seriesName: 'Sales',
// Horizontal: value first, x (time) value second
data: [100, '2021-02-01T00:00:00'],
name: '2021-02-01T00:00:00',
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
await waitFor(() => {
expect(onContextMenuMock).toHaveBeenCalled();
});
const { drillBy } = onContextMenuMock.mock.calls[0][2];
expect(drillBy.xAxisFilters).toEqual([
{
col: 'order_date',
op: '==',
val: '2021-02-01T00:00:00',
formattedVal: '2021-02-01T00:00:00',
},
]);
});
@@ -28,6 +28,7 @@ import {
ensureIsArray,
} from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import type {
ECElementEvent,
ViewRootGroup,
@@ -43,6 +44,7 @@ import {
} from './percentChange';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
const TIMER_DURATION = 300;
@@ -507,6 +509,55 @@ export default function EchartsTimeseries({
});
});
// Filters for the clicked x-axis value, so Drill By can subset the
// drilled data to the clicked bar/point rather than only the series
const xAxisFilters: BinaryQueryObjectFilterClause[] = [];
const xAxisCol =
// if the xAxis is '__timestamp', granularity_sqla will be the column of filter
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
if (data && xAxis.type === AxisType.Time && xAxisCol) {
// For horizontal orientation the [x, value] pair is swapped
const xValue = Array.isArray(data)
? data[categoryAxisValueIndex]
: data;
const xAxisFilter = getTemporalXAxisDrillByFilter(
xAxisCol,
xValue,
formData.timeGrainSqla,
String(xValueFormatter(xValue as number)),
);
if (xAxisFilter) {
xAxisFilters.push(xAxisFilter);
}
} else if (xAxis.type === AxisType.Category && xAxisCol) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
if (categoryAxisValue !== undefined) {
// A category axis can still sit on a temporal column when the
// axis is forced categorical; filter by time bucket in that case
const xAxisFilter =
coltypeMapping?.[getColumnLabel(xAxis.label)] ===
GenericDataType.Temporal
? getTemporalXAxisDrillByFilter(
xAxisCol,
categoryAxisValue,
formData.timeGrainSqla,
String(eventParams.name ?? categoryAxisValue),
)
: {
col: xAxisCol,
op: '==' as const,
val: categoryAxisValue,
formattedVal: String(categoryAxisValue),
};
if (xAxisFilter) {
xAxisFilters.push(xAxisFilter);
}
}
}
// Provide cross-filter for dimensions OR categorical X-axis (issue #25334)
let crossFilter;
if (hasDimensions) {
@@ -526,7 +577,11 @@ export default function EchartsTimeseries({
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' },
drillBy: {
filters: drillByFilters,
groupbyFieldName: 'groupby',
...(xAxisFilters.length > 0 && { xAxisFilters }),
},
crossFilter,
});
}
@@ -0,0 +1,164 @@
/**
* 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.
*/
import {
BinaryQueryObjectFilterClause,
QueryFormColumn,
TimeGranularity,
} from '@superset-ui/core';
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Format a Date as a naive ISO datetime string (UTC, no timezone suffix),
* the format Superset's time range parser expects, e.g. "2021-01-01T00:00:00".
* Sub-second precision is preserved when present (e.g.
* "2021-01-01T00:00:00.123") so exact-match filters on high-frequency
* timestamps don't get truncated to the containing second.
*/
export const formatNaiveDateTime = (date: Date): string => {
const iso = date.toISOString();
return date.getUTCMilliseconds() === 0 ? iso.slice(0, 19) : iso.slice(0, 23);
};
/**
* Given the start (label) of a time bucket and its time grain, return the
* [since, until) range covering the bucket, using calendar-aware UTC
* arithmetic. Week-ending grains are labeled by the last day of the bucket,
* so their range extends backwards from the label. Returns undefined for
* unknown grains.
*/
export const getTimeBucketRange = (
bucketLabel: Date,
grain: TimeGranularity,
): { since: Date; until: Date } | undefined => {
const until = new Date(bucketLabel.getTime());
switch (grain) {
case TimeGranularity.SECOND:
until.setUTCSeconds(until.getUTCSeconds() + 1);
break;
case TimeGranularity.MINUTE:
until.setUTCMinutes(until.getUTCMinutes() + 1);
break;
case TimeGranularity.FIVE_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 5);
break;
case TimeGranularity.TEN_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 10);
break;
case TimeGranularity.FIFTEEN_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 15);
break;
case TimeGranularity.THIRTY_MINUTES:
until.setUTCMinutes(until.getUTCMinutes() + 30);
break;
case TimeGranularity.HOUR:
until.setUTCHours(until.getUTCHours() + 1);
break;
case TimeGranularity.DATE:
case TimeGranularity.DAY:
until.setUTCDate(until.getUTCDate() + 1);
break;
case TimeGranularity.WEEK:
case TimeGranularity.WEEK_STARTING_SUNDAY:
case TimeGranularity.WEEK_STARTING_MONDAY:
until.setUTCDate(until.getUTCDate() + 7);
break;
case TimeGranularity.WEEK_ENDING_SATURDAY:
case TimeGranularity.WEEK_ENDING_SUNDAY:
// These buckets are labeled with their last day: the bucket spans
// the 6 days before the label plus the label day itself.
until.setUTCDate(until.getUTCDate() + 1);
return { since: new Date(bucketLabel.getTime() - 6 * DAY_MS), until };
case TimeGranularity.MONTH:
until.setUTCMonth(until.getUTCMonth() + 1);
break;
case TimeGranularity.QUARTER:
until.setUTCMonth(until.getUTCMonth() + 3);
break;
case TimeGranularity.YEAR:
until.setUTCFullYear(until.getUTCFullYear() + 1);
break;
default:
return undefined;
}
return { since: new Date(bucketLabel.getTime()), until };
};
// Matches an explicit timezone designator (Z or ±HH:MM/±HHMM) at the end of
// a datetime string.
const TIMEZONE_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
/**
* Parse a datetime string the same way regardless of the host's local
* timezone. Superset's backend returns naive datetime strings (no timezone
* designator) that represent UTC instants; passing those directly to `new
* Date()` would interpret them as local wall-clock time and shift the
* result by the browser's UTC offset. Strings that already carry an
* explicit timezone designator are parsed as-is.
*/
const parseAsUtc = (value: string): Date =>
new Date(TIMEZONE_SUFFIX_RE.test(value) ? value : `${value}Z`);
/**
* Build a drill-by filter clause matching the clicked value on a temporal
* x-axis. When a known time grain is active, the clause is a TEMPORAL_RANGE
* covering the clicked bucket; otherwise it falls back to an exact match on
* the timestamp.
*/
export const getTemporalXAxisDrillByFilter = (
col: QueryFormColumn,
value: unknown,
grain?: TimeGranularity,
formattedVal?: string,
): BinaryQueryObjectFilterClause | undefined => {
if (
!col ||
(typeof value !== 'number' &&
typeof value !== 'string' &&
!(value instanceof Date))
) {
return undefined;
}
let bucketLabel: Date;
if (value instanceof Date) {
bucketLabel = value;
} else if (typeof value === 'string') {
bucketLabel = parseAsUtc(value);
} else {
bucketLabel = new Date(value);
}
if (Number.isNaN(bucketLabel.getTime())) {
return undefined;
}
const range = grain ? getTimeBucketRange(bucketLabel, grain) : undefined;
if (!range) {
return {
col,
op: '==',
val: formatNaiveDateTime(bucketLabel),
formattedVal,
};
}
return {
col,
op: 'TEMPORAL_RANGE',
val: `${formatNaiveDateTime(range.since)} : ${formatNaiveDateTime(range.until)}`,
formattedVal,
};
};
@@ -0,0 +1,238 @@
/**
* 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.
*/
import { TimeGranularity } from '@superset-ui/core';
import {
formatNaiveDateTime,
getTemporalXAxisDrillByFilter,
getTimeBucketRange,
} from '../../src/utils/xAxisDrillByFilter';
const utc = (dateString: string) => new Date(`${dateString}Z`);
const expectRange = (
bucketLabel: string,
grain: TimeGranularity,
since: string,
until: string,
) => {
const range = getTimeBucketRange(utc(bucketLabel), grain);
expect(range).toBeDefined();
expect(formatNaiveDateTime(range!.since)).toEqual(since);
expect(formatNaiveDateTime(range!.until)).toEqual(until);
};
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
test('getTimeBucketRange computes sub-daily buckets', () => {
expectRange(
'2021-03-14T01:59:00',
TimeGranularity.MINUTE,
'2021-03-14T01:59:00',
'2021-03-14T02:00:00',
);
expectRange(
'2021-03-14T01:30:00',
TimeGranularity.THIRTY_MINUTES,
'2021-03-14T01:30:00',
'2021-03-14T02:00:00',
);
expectRange(
'2021-03-14T23:00:00',
TimeGranularity.HOUR,
'2021-03-14T23:00:00',
'2021-03-15T00:00:00',
);
});
test('getTimeBucketRange computes daily and weekly buckets', () => {
expectRange(
'2021-12-31T00:00:00',
TimeGranularity.DAY,
'2021-12-31T00:00:00',
'2022-01-01T00:00:00',
);
expectRange(
'2021-04-26T00:00:00',
TimeGranularity.WEEK,
'2021-04-26T00:00:00',
'2021-05-03T00:00:00',
);
expectRange(
'2021-04-25T00:00:00',
TimeGranularity.WEEK_STARTING_SUNDAY,
'2021-04-25T00:00:00',
'2021-05-02T00:00:00',
);
});
test('getTimeBucketRange extends week-ending buckets backwards from their label', () => {
expectRange(
'2021-05-01T00:00:00',
TimeGranularity.WEEK_ENDING_SATURDAY,
'2021-04-25T00:00:00',
'2021-05-02T00:00:00',
);
expectRange(
'2021-05-02T00:00:00',
TimeGranularity.WEEK_ENDING_SUNDAY,
'2021-04-26T00:00:00',
'2021-05-03T00:00:00',
);
});
test('getTimeBucketRange respects calendar month lengths', () => {
expectRange(
'2021-01-01T00:00:00',
TimeGranularity.MONTH,
'2021-01-01T00:00:00',
'2021-02-01T00:00:00',
);
expectRange(
'2021-02-01T00:00:00',
TimeGranularity.MONTH,
'2021-02-01T00:00:00',
'2021-03-01T00:00:00',
);
expectRange(
'2024-02-01T00:00:00',
TimeGranularity.MONTH,
'2024-02-01T00:00:00',
'2024-03-01T00:00:00',
);
expectRange(
'2021-12-01T00:00:00',
TimeGranularity.MONTH,
'2021-12-01T00:00:00',
'2022-01-01T00:00:00',
);
});
test('getTimeBucketRange computes quarter and year buckets', () => {
expectRange(
'2021-10-01T00:00:00',
TimeGranularity.QUARTER,
'2021-10-01T00:00:00',
'2022-01-01T00:00:00',
);
expectRange(
'2024-01-01T00:00:00',
TimeGranularity.YEAR,
'2024-01-01T00:00:00',
'2025-01-01T00:00:00',
);
});
test('getTimeBucketRange returns undefined for unknown grains', () => {
expect(
getTimeBucketRange(utc('2021-01-01T00:00:00'), 'P1D2H' as TimeGranularity),
).toBeUndefined();
});
test('getTemporalXAxisDrillByFilter builds a temporal range for known grains', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T00:00:00').getTime(),
TimeGranularity.MONTH,
'Jan 2021',
),
).toEqual({
col: 'ds',
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
formattedVal: 'Jan 2021',
});
});
test('getTemporalXAxisDrillByFilter falls back to exact match without a grain', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T12:34:56').getTime(),
undefined,
'2021-01-01 12:34:56',
),
).toEqual({
col: 'ds',
op: '==',
val: '2021-01-01T12:34:56',
formattedVal: '2021-01-01 12:34:56',
});
});
test('getTemporalXAxisDrillByFilter preserves sub-second precision in the exact-match fallback', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
new Date('2021-01-01T12:34:56.123Z').getTime(),
undefined,
'2021-01-01 12:34:56.123',
),
).toEqual({
col: 'ds',
op: '==',
val: '2021-01-01T12:34:56.123',
formattedVal: '2021-01-01 12:34:56.123',
});
});
test('getTemporalXAxisDrillByFilter falls back to exact match for unknown grains', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T00:00:00').getTime(),
'P1D2H' as TimeGranularity,
),
).toMatchObject({ op: '==', val: '2021-01-01T00:00:00' });
});
test('getTemporalXAxisDrillByFilter accepts parseable date strings and Dates', () => {
expect(
getTemporalXAxisDrillByFilter(
'ds',
'2021-01-01T00:00:00Z',
TimeGranularity.DAY,
),
).toMatchObject({
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
});
expect(
getTemporalXAxisDrillByFilter(
'ds',
utc('2021-01-01T00:00:00'),
TimeGranularity.DAY,
),
).toMatchObject({
op: 'TEMPORAL_RANGE',
val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
});
});
test('getTemporalXAxisDrillByFilter returns undefined for unusable input', () => {
expect(
getTemporalXAxisDrillByFilter('ds', 'not a date', TimeGranularity.DAY),
).toBeUndefined();
expect(
getTemporalXAxisDrillByFilter('ds', null, TimeGranularity.DAY),
).toBeUndefined();
expect(
getTemporalXAxisDrillByFilter('', 1609459200000, TimeGranularity.DAY),
).toBeUndefined();
});
@@ -30,7 +30,7 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.0",
"mapbox-gl": "^3.28.1",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^8.0.1"
@@ -17,7 +17,7 @@
* under the License.
*/
import { useRef, useState } from 'react';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { ContextMenuFilters, FeatureFlag, VizType } from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import mockState from 'spec/fixtures/mockState';
@@ -30,6 +30,35 @@ import ChartContextMenu, {
jest.mock('src/utils/cachedSupersetGet');
// The scope-selector behavior within the submenu (which filters get built
// for x-axis/series/both) is covered by DrillBySubmenu.test.tsx. Here we
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
// config, so we can assert ChartContextMenu wires it into the modal.
jest.mock('../DrillBy/DrillBySubmenu', () => ({
DrillBySubmenu: ({ onDrillBy }: any) => (
<button
type="button"
data-test="fake-drill-by-submenu"
onClick={() =>
onDrillBy(
{ column_name: 'city', groupby: true },
{ id: 1, columns: [], metrics: [] },
{ filters: [{ col: 'selected_scope' }], groupbyFieldName: 'groupby' },
)
}
>
Fake Drill By
</button>
),
}));
jest.mock('src/components/Chart/DrillBy/DrillByModal', () => ({
__esModule: true,
default: ({ drillByConfig }: any) => (
<div data-test="drill-by-modal">{JSON.stringify(drillByConfig)}</div>
),
}));
const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction<
typeof cachedSupersetGet
>;
@@ -39,7 +68,11 @@ const defaultFormData = {
viz_type: VizType.Pie,
};
const TestWrapper = () => {
const TestWrapper = ({
openFilters = {},
}: {
openFilters?: ContextMenuFilters;
}) => {
const contextMenuRef = useRef<ChartContextMenuRef>(null);
const [isTooltipVisible, setIsTooltipVisible] = useState(true);
@@ -51,7 +84,7 @@ const TestWrapper = () => {
<>
<button
type="button"
onClick={() => contextMenuRef.current?.open(100, 100, {})}
onClick={() => contextMenuRef.current?.open(100, 100, openFilters)}
data-test="open-context-menu"
>
Open Context Menu
@@ -71,8 +104,8 @@ const TestWrapper = () => {
);
};
const setup = () =>
render(<TestWrapper />, {
const setup = (openFilters?: ContextMenuFilters) =>
render(<TestWrapper openFilters={openFilters} />, {
useRedux: true,
initialState: {
...mockState,
@@ -150,3 +183,30 @@ test('tooltip is restored when user selects a menu item', async () => {
expect(screen.getByTestId('tooltip-visible')).toBeInTheDocument();
});
});
test('drill by modal uses the scope selected in the submenu over the raw context filters', async () => {
setup({
drillBy: {
filters: [{ col: 'raw_scope', op: '==', val: 'raw' }],
groupbyFieldName: 'groupby',
},
});
userEvent.click(screen.getByTestId('open-context-menu'));
await waitFor(() => {
expect(screen.getByTestId('chart-context-menu')).toBeInTheDocument();
});
const submenuButton = await screen.findByTestId('fake-drill-by-submenu');
userEvent.click(submenuButton);
await waitFor(() => {
expect(screen.getByTestId('drill-by-modal')).toBeInTheDocument();
});
const modalConfig = JSON.parse(
screen.getByTestId('drill-by-modal').textContent || '{}',
);
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
});
@@ -55,6 +55,7 @@ import { getMenuAdjustedY } from '../utils';
import { DrillBySubmenu } from '../DrillBy/DrillBySubmenu';
import DrillDetailModal from '../DrillDetail/DrillDetailModal';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { Dataset } from '../types';
export enum ContextMenuItem {
CrossFilter,
@@ -155,6 +156,10 @@ const ChartContextMenu = (
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [drillByColumn, setDrillByColumn] = useState<Column>();
// Drill by config as selected in the submenu (e.g. with the chosen
// x-axis/series filter scope applied), used over the raw context filters
const [selectedDrillByConfig, setSelectedDrillByConfig] =
useState<ContextMenuFilters['drillBy']>();
const [showDrillByModal, setShowDrillByModal] = useState(false);
const closeContextMenu = useCallback(() => {
@@ -162,10 +167,18 @@ const ChartContextMenu = (
onClose();
}, [onClose]);
const handleDrillBy = useCallback((column: Column) => {
setDrillByColumn(column);
setShowDrillByModal(true);
}, []);
const handleDrillBy = useCallback(
(
column: Column,
_dataset: Dataset,
drillByConfig?: ContextMenuFilters['drillBy'],
) => {
setDrillByColumn(column);
setSelectedDrillByConfig(drillByConfig);
setShowDrillByModal(true);
},
[],
);
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
'load.drillby.options',
@@ -175,6 +188,8 @@ const ChartContextMenu = (
setShowDrillByModal(false);
}, []);
const drillByModalConfig = selectedDrillByConfig ?? enhancedFilters?.drillBy;
const menuItems: MenuItem[] = [];
const showDrillToDetail =
@@ -459,10 +474,10 @@ const ChartContextMenu = (
{showDrillByModal &&
drillByColumn &&
filteredDataset &&
enhancedFilters?.drillBy && (
drillByModalConfig && (
<DrillByModal
column={drillByColumn}
drillByConfig={enhancedFilters?.drillBy}
drillByConfig={drillByModalConfig}
formData={formData}
onHideModal={handleCloseDrillByModal}
dataset={filteredDataset}
@@ -274,6 +274,140 @@ test('When menu item is clicked, call onSelection with clicked column and drill
);
});
const xAxisFilters = [
{
col: 'ds',
op: 'TEMPORAL_RANGE' as const,
val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
formattedVal: 'Jan 2021',
},
];
test('do not display scope selector without x-axis filters', async () => {
renderSubmenu({});
await expectDrillByEnabled();
await screen.findByText('col1');
expect(
screen.queryByTestId('drill-by-scope-selector'),
).not.toBeInTheDocument();
});
test('do not display scope selector with only x-axis filters', async () => {
renderSubmenu({
drillByConfig: { filters: [], xAxisFilters, groupbyFieldName: 'groupby' },
});
await expectDrillByEnabled();
await screen.findByText('col1');
expect(
screen.queryByTestId('drill-by-scope-selector'),
).not.toBeInTheDocument();
});
test('display scope selector when x-axis and series filters are present', async () => {
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
});
await expectDrillByEnabled();
await screen.findByText('col1');
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
expect(scopeSelector).toBeInTheDocument();
expect(within(scopeSelector).getByText('Jan 2021')).toBeInTheDocument();
expect(within(scopeSelector).getByText('val')).toBeInTheDocument();
expect(within(scopeSelector).getByText('Both')).toBeInTheDocument();
});
test('apply both x-axis and series filters by default', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
const col1Element = await screen.findByText('col1');
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{
filters: [...xAxisFilters, ...defaultFilters],
groupbyFieldName: 'groupby',
},
);
});
test('apply only x-axis filters when x-axis scope is selected', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
await screen.findByText('col1');
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
userEvent.click(within(scopeSelector).getByText('Jan 2021'));
userEvent.click(screen.getByText('col1'));
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{ filters: xAxisFilters, groupbyFieldName: 'groupby' },
);
});
test('apply only series filters when series scope is selected', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: {
filters: defaultFilters,
xAxisFilters,
groupbyFieldName: 'groupby',
},
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
await screen.findByText('col1');
const scopeSelector = screen.getByTestId('drill-by-scope-selector');
userEvent.click(within(scopeSelector).getByText('val'));
userEvent.click(screen.getByText('col1'));
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{ filters: defaultFilters, groupbyFieldName: 'groupby' },
);
});
test('apply x-axis filters when only x-axis filters are present', async () => {
const onSelectionMock = jest.fn();
renderSubmenu({
drillByConfig: { filters: [], xAxisFilters, groupbyFieldName: 'groupby' },
onSelection: onSelectionMock,
});
await expectDrillByEnabled();
const col1Element = await screen.findByText('col1');
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(
{ column_name: 'col1', groupby: true },
{ filters: xAxisFilters, groupbyFieldName: 'groupby' },
);
});
test('matrixify_mode_rows enabled should not render component', () => {
const { container } = renderSubmenu({
formData: {
@@ -29,6 +29,7 @@ import { t } from '@apache-superset/core/translation';
import {
BaseFormData,
Behavior,
BinaryQueryObjectFilterClause,
Column,
ContextMenuFilters,
ensureIsArray,
@@ -42,6 +43,7 @@ import {
Popover,
Icons,
} from '@superset-ui/core/components';
import { Radio } from '@superset-ui/core/components/Radio';
import { debounce } from 'lodash-es';
import { List, type RowComponentProps } from 'react-window';
import { InputRef } from 'antd';
@@ -80,6 +82,15 @@ function DrillByColumnRow({
);
}
enum DrillByFilterScope {
XAxis = 'x-axis',
Series = 'series',
All = 'all',
}
const formatFilterValues = (filters: BinaryQueryObjectFilterClause[]) =>
filters.map(filter => filter.formattedVal ?? String(filter.val)).join(', ');
export interface DrillBySubmenuProps {
drillByConfig?: ContextMenuFilters['drillBy'];
formData: BaseFormData & { [key: string]: any };
@@ -88,7 +99,11 @@ export interface DrillBySubmenuProps {
onCloseMenu?: () => void;
openNewModal?: boolean;
excludedColumns?: Column[];
onDrillBy?: (column: Column, dataset: Dataset) => void;
onDrillBy?: (
column: Column,
dataset: Dataset,
drillByConfig?: ContextMenuFilters['drillBy'],
) => void;
dataset?: Dataset;
isLoadingDataset?: boolean;
}
@@ -110,6 +125,7 @@ export const DrillBySubmenu = ({
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [popoverOpen, setPopoverOpen] = useState(false);
const [filterScope, setFilterScope] = useState(DrillByFilterScope.All);
const ref = useRef<InputRef>(null);
const menuItemRef = useRef<HTMLButtonElement>(null);
@@ -119,18 +135,54 @@ export const DrillBySubmenu = ({
);
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const seriesFilters = useMemo(
() => ensureIsArray(drillByConfig?.filters),
[drillByConfig?.filters],
);
const xAxisFilters = useMemo(
() => ensureIsArray(drillByConfig?.xAxisFilters),
[drillByConfig?.xAxisFilters],
);
// Both the clicked x-axis value and the clicked series can scope the
// drilled data; when both are available the user picks which to apply
const showScopeSelector = seriesFilters.length > 0 && xAxisFilters.length > 0;
const effectiveDrillByConfig = useMemo(():
| ContextMenuFilters['drillBy']
| undefined => {
if (!drillByConfig) {
return undefined;
}
let filters = [...xAxisFilters, ...seriesFilters];
if (showScopeSelector && filterScope === DrillByFilterScope.XAxis) {
filters = xAxisFilters;
} else if (showScopeSelector && filterScope === DrillByFilterScope.Series) {
filters = seriesFilters;
}
const config = { ...drillByConfig, filters };
// the x-axis filters have been folded into `filters` above
delete config.xAxisFilters;
return config;
}, [
drillByConfig,
filterScope,
seriesFilters,
showScopeSelector,
xAxisFilters,
]);
const handleSelection = useCallback(
(event: React.MouseEvent, column: Column) => {
onClick(event);
onSelection(column, drillByConfig);
onSelection(column, effectiveDrillByConfig);
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
onDrillBy(column, dataset, effectiveDrillByConfig);
}
setPopoverOpen(false);
onCloseMenu();
},
[
drillByConfig,
effectiveDrillByConfig,
onClick,
onSelection,
openNewModal,
@@ -148,9 +200,10 @@ export const DrillBySubmenu = ({
ref.current?.input?.focus({ preventScroll: true });
}, 100);
} else {
// Reset search input when menu is closed
// Reset search input and filter scope when menu is closed
setSearchInput('');
setDebouncedSearchInput('');
setFilterScope(DrillByFilterScope.All);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
@@ -237,6 +290,55 @@ export const DrillBySubmenu = ({
`}
onClick={e => e.stopPropagation()}
>
{showScopeSelector && (
<div
data-test="drill-by-scope-selector"
css={css`
margin-bottom: ${theme.sizeUnit * 2}px;
padding-bottom: ${theme.sizeUnit * 2}px;
border-bottom: 1px solid ${theme.colorSplit};
`}
>
<div
css={css`
color: ${theme.colorTextSecondary};
margin-bottom: ${theme.sizeUnit}px;
`}
>
{t('Filter by')}
</div>
<Radio.Group
value={filterScope}
onChange={e => setFilterScope(e.target.value)}
css={css`
display: flex;
flex-direction: column;
.ant-radio-wrapper {
margin-inline-end: 0;
span:last-of-type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
`}
>
<Radio
value={DrillByFilterScope.XAxis}
title={formatFilterValues(xAxisFilters)}
>
{formatFilterValues(xAxisFilters)}
</Radio>
<Radio
value={DrillByFilterScope.Series}
title={formatFilterValues(seriesFilters)}
>
{formatFilterValues(seriesFilters)}
</Radio>
<Radio value={DrillByFilterScope.All}>{t('Both')}</Radio>
</Radio.Group>
</div>
)}
{showSearch && (
<Input
ref={ref}
@@ -528,6 +528,57 @@ describe('PropertiesModal', () => {
});
});
test('preserves certification fields on save without opening Certification section', async () => {
// Accordion Collapse only mounts the active panel, so certifiedBy /
// certificationDetails FormItems are unregistered until Certification is
// opened. onFinish must use getFieldsValue(true) to read store values for
// unregistered fields; otherwise save clears certified_by to null.
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');
// Only General information fields are mounted; Certification inputs are not.
expect(screen.getAllByRole('textbox')).toHaveLength(3);
expect(screen.queryByText('Certified by')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledTimes(1);
});
const submitCall = props.onSubmit.mock.calls[0][0];
expect(submitCall.certifiedBy).toBe('John Doe');
expect(submitCall.certificationDetails).toBe('Sample certification');
expect(put).toHaveBeenCalled();
const putRequest = put.mock.calls[0][0];
expect(typeof putRequest.body).toBe('string');
const putBody = JSON.parse(putRequest.body as string);
expect(putBody.certified_by).toBe('John Doe');
expect(putBody.certification_details).toBe('Sample certification');
});
test('submitting with onlyApply:true', async () => {
mockedIsFeatureEnabled.mockReturnValue(false);
const props = createProps();
@@ -307,7 +307,7 @@ const PropertiesModal = ({
slug,
certifiedBy,
certificationDetails,
} = form.getFieldsValue();
} = form.getFieldsValue(true);
let currentJsonMetadata = jsonMetadata;
// validate currentJsonMetadata
@@ -43,6 +43,10 @@ import path from 'path';
const chartEndpoint = 'glob:*api/v1/chart/*';
const EDIT_PROPERTIES_INITIAL_STATE = {
explore: { can_overwrite: true, can_add: true },
};
fetchMock.get(chartEndpoint, { json: 'foo' });
window.featureFlags = {
@@ -170,7 +174,10 @@ describe('ExploreChartHeader', () => {
test('Cancelling changes to the properties should reset previous properties', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: EDIT_PROPERTIES_INITIAL_STATE,
});
const newChartName = 'New chart name';
const prevChartName = props.sliceName;
@@ -626,6 +633,7 @@ describe('Additional actions tests', () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: EDIT_PROPERTIES_INITIAL_STATE,
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
@@ -720,6 +728,7 @@ describe('Additional actions tests', () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: EDIT_PROPERTIES_INITIAL_STATE,
});
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByLabelText('Menu actions trigger'));
@@ -279,6 +279,7 @@ interface ExploreState {
chartStates?: Record<number, JsonObject>;
can_export_image?: boolean;
can_overwrite?: boolean;
can_add?: boolean;
};
common?: {
conf?: {
@@ -335,17 +336,30 @@ export const useExploreAdditionalActionsMenu = (
const canOverwrite = useSelector<ExploreState, boolean>(
state => state.explore?.can_overwrite ?? false,
);
// Mirrors the `can_write` permission on the `Chart` view, the same
// permission `ChartRestApi.put` (and `restore_version`) require. An editor
// who satisfies `canOverwriteSlice` but lacks it would still be turned away
// by the API, so the properties editor stays hidden for them too.
const canWriteChart = useSelector<ExploreState, boolean>(
state => state.explore?.can_add ?? false,
);
const user = useSelector<
ExploreState,
UserWithPermissionsAndRoles | undefined
>(state => state.user);
// `can_overwrite` alone hides version history on any chart without explicit
// editors — every seeded chart — even from admins. Same predicate SaveModal
// uses, so a user who can save a chart can also see its history.
// `can_overwrite` alone hides version history (and edit-properties) on any
// chart without explicit editors — every seeded chart — even from admins.
// Same predicate SaveModal uses, so a user who can save a chart can also
// see its history and edit its properties.
const canModifySlice = useMemo(
() => canOverwriteSlice({ slice, user, canOverwrite }),
[slice, user, canOverwrite],
);
// `canModifySlice` alone governs version history, whose own read-only
// listing needs no write permission (only its restore action does, and
// that's gated server-side). Editing properties, however, always PUTs the
// chart, so it additionally needs the write permission above.
const canEditProperties = canModifySlice && canWriteChart;
const dataExportDisabled = !canDownloadCSV;
const imageExportDisabled = !canExportImage;
@@ -601,7 +615,7 @@ export const useExploreAdditionalActionsMenu = (
const menuItems = [];
// Edit chart properties
if (slice) {
if (slice && canEditProperties) {
menuItems.push({
key: MENU_KEYS.EDIT_PROPERTIES,
label: t('Edit chart properties'),
@@ -1084,6 +1098,7 @@ export const useExploreAdditionalActionsMenu = (
}, [
addDangerToast,
canDownloadCSV,
canEditProperties,
canModifySlice,
copyLink,
dashboards,
@@ -27,6 +27,7 @@ import {
getExportScreenshotMenuItems,
} from './index';
import * as exploreUtils from 'src/explore/exploreUtils';
import { Slice } from 'src/types/Chart';
jest.mock('src/explore/exploreUtils', () => ({
__esModule: true,
@@ -74,13 +75,22 @@ jest.mock('@superset-ui/core', () => ({
})),
}));
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: jest.fn(() => ({
common: {
user_subjects: [1],
},
})),
}));
const defaultProps = {
latestQueryFormData: {
datasource: '1__table',
viz_type: 'pivot_table_v2',
},
canDownloadCSV: true,
slice: { slice_id: 1, slice_name: 'Test Chart' },
slice: { slice_id: 1, slice_name: 'Test Chart' } as unknown as Slice,
ownState: {},
dashboards: [],
onOpenInEditor: jest.fn(),
@@ -113,6 +123,63 @@ beforeEach(() => {
mockExportChart.mockResolvedValue(undefined);
});
test('hides Edit chart properties from a user who is not an owner/editor of the chart (regression #38884)', async () => {
render(
<TestComponent
{...defaultProps}
slice={
{
slice_id: 1,
slice_name: 'Test Chart',
editors: [2],
} as unknown as Slice
}
/>,
{ useRedux: true },
);
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
});
test('shows Edit chart properties for a chart editor with chart write permission', async () => {
render(
<TestComponent
{...defaultProps}
slice={
{
slice_id: 1,
slice_name: 'Test Chart',
editors: [1],
} as unknown as Slice
}
/>,
{ useRedux: true, initialState: { explore: { can_add: true } } },
);
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
expect(screen.getByText('Edit chart properties')).toBeInTheDocument();
});
test('hides Edit chart properties from a chart editor lacking chart write permission', async () => {
render(
<TestComponent
{...defaultProps}
slice={
{
slice_id: 1,
slice_name: 'Test Chart',
editors: [1],
} as unknown as Slice
}
/>,
{ useRedux: true, initialState: { explore: { can_add: false } } },
);
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
});
test('shows 413 error toast when exportCSV fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
+4 -3
View File
@@ -22,7 +22,7 @@ import re
from datetime import datetime
from typing import Any, Callable, TYPE_CHECKING
from flask import current_app as app, g, make_response, request, Response
from flask import current_app as app, make_response, request, Response
from flask_appbuilder.api import expose, protect
from flask_babel import gettext as _
from marshmallow import ValidationError
@@ -37,6 +37,7 @@ from superset.charts.data.dashboard_filter_context import (
DashboardFilterContext,
get_dashboard_filter_context,
)
from superset.charts.data.form_data import set_form_data
from superset.charts.data.query_context_cache_loader import QueryContextCacheLoader
from superset.charts.schemas import ChartDataQueryContextSchema
from superset.commands.chart.data.create_async_job_command import (
@@ -214,7 +215,7 @@ class ChartDataRestApi(ChartRestApi):
# templating pulls form data from the request globally, so this
# fallback ensures it has the filters and extra_form_data applied
# when used in get_sqla_query which constructs the final query.
g.form_data = json_body
set_form_data(json_body)
try:
query_context = self._create_query_context_from_form(json_body)
@@ -410,7 +411,7 @@ class ChartDataRestApi(ChartRestApi):
cached_data = self._load_query_context_form_from_cache(cache_key)
# Set form_data in Flask Global as it is used as a fallback
# for async queries with jinja context
g.form_data = cached_data
set_form_data(cached_data)
query_context = self._create_query_context_from_form(cached_data)
command = ChartDataCommand(query_context)
command.validate()
+63
View File
@@ -0,0 +1,63 @@
# 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.
from __future__ import annotations
from typing import Any, TYPE_CHECKING
from flask import g
if TYPE_CHECKING:
from superset.common.query_context import QueryContext
from superset.common.query_object import QueryObject
def set_form_data(form_data: dict[str, Any]) -> None:
"""Expose chart data request fields to Jinja template macros."""
g.form_data = form_data
def _serialize_query(
query: QueryObject,
form_data: dict[str, Any],
) -> dict[str, Any]:
"""Serialize query fields consumed by the Jinja form-data fallback."""
query_data = dict(query.to_dict())
query_data["filters"] = query.filter
if query.time_range is not None:
query_data["time_range"] = query.time_range
if url_params := form_data.get("url_params"):
query_data["url_params"] = url_params
return query_data
def set_query_context_form_data(
query_context: QueryContext,
datasource_id: int,
datasource_type: str,
) -> None:
"""Expose a programmatically-created query like a chart data API request."""
form_data = query_context.form_data or {}
set_form_data(
{
"datasource": {"id": datasource_id, "type": datasource_type},
"queries": [
_serialize_query(query, form_data) for query in query_context.queries
],
"form_data": form_data,
}
)
+2 -2
View File
@@ -34,11 +34,11 @@ from superset.commands.dashboard.exceptions import (
DashboardUpdateFailedError,
)
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
from superset.dashboards.filters import DashboardAccessFilter, is_uuid
from superset.dashboards.filters import DashboardAccessFilter
from superset.exceptions import SupersetSecurityException
from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
from superset.models.dashboard import Dashboard, id_or_slug_filter
from superset.models.dashboard import Dashboard, id_or_slug_filter, is_uuid
from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.models.helpers import skip_visibility_filter
from superset.models.slice import Slice
+18 -101
View File
@@ -16,26 +16,26 @@
# under the License.
from typing import Any
from flask import current_app, g
from flask import current_app
from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_
from sqlalchemy.orm.query import Query
from superset import db, is_feature_enabled, security_manager
from superset import db, security_manager
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.dashboard import Dashboard, is_uuid
from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.security.guest_token import GuestTokenResourceType, GuestUser
from superset.subjects.filters import (
EditableFilter,
subject_relation_exists_for_current_user,
)
from superset.subjects.models import dashboard_editors, dashboard_viewers
from superset.tags.filters import BaseTagIdFilter, BaseTagNameFilter
from superset.utils.core import get_user_id
from superset.utils.filters import get_dataset_access_filters
from superset.utils.filters import (
get_dataset_access_filters,
guest_embedded_dashboard_filter,
)
from superset.views.base import BaseFilter
from superset.views.base_api import BaseFavoriteFilter
from superset.views.filters import BaseDeletedRecencyFilter, BaseDeletedStateFilter
@@ -110,20 +110,20 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
"""
List dashboards with the following criteria:
When ``ENABLE_VIEWERS`` is on:
1. Those where the user is an editor (published or not)
2. Those where the user is a viewer (published only)
3. Those with no viewers fall back to dataset-based access (published only)
4. Embedded dashboard access (preserved as-is)
When ``ENABLE_VIEWERS`` is off (legacy):
1. Those which the user is an editor of
2. Those which have been published (if they have access to at least one slice)
If the user is an admin then show all dashboards.
1. Embedded guests: only the dashboards in their token, nothing else
2. Admins: all dashboards
3. Editors: their dashboards (published or not)
4. Viewers: published dashboards
5. Dashboards with no viewers fall back to dataset-based access
(published only)
"""
def apply(self, query: Query, value: Any) -> Query:
# Guests are scoped to their token's dashboards only, never widened by
# the role paths below (mirrors ChartFilter).
if (guest_condition := guest_embedded_dashboard_filter()) is not None:
return query.filter(guest_condition)
if security_manager.is_admin():
return query
@@ -192,91 +192,8 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
if user_id:
filters.append(Dashboard.id.in_(extra_dashboards_filter(user_id)))
# (D) Embedded: preserved as-is
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
g.user
):
guest_user: GuestUser = g.user
embedded_dashboard_ids = [
r["id"]
for r in guest_user.resources
if r["type"] == GuestTokenResourceType.DASHBOARD.value
]
condition = (
Dashboard.embedded.any(
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
)
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
else Dashboard.id.in_(embedded_dashboard_ids)
)
filters.append(condition)
return query.filter(or_(*filters)) if filters else query
def _apply_legacy(self, query: Query) -> Query:
datasource_perm_query = (
db.session.query(Dashboard.id)
.join(Dashboard.slices, isouter=True)
.join(SqlaTable, Slice.datasource_id == SqlaTable.id)
.join(Database, SqlaTable.database_id == Database.id)
.filter(
and_(
Dashboard.published.is_(True),
get_dataset_access_filters(
Slice,
security_manager.can_access_all_datasources(),
),
)
)
)
# Editors query
editor_ids_query = db.session.query(dashboard_editors.c.dashboard_id).filter(
subject_relation_exists_for_current_user(dashboard_editors)
)
feature_flagged_filters = []
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
g.user
):
guest_user: GuestUser = g.user
embedded_dashboard_ids = [
r["id"]
for r in guest_user.resources
if r["type"] == GuestTokenResourceType.DASHBOARD.value
]
# TODO (embedded): only use uuid filter once uuids are rolled out
condition = (
Dashboard.embedded.any(
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
)
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
else Dashboard.id.in_(embedded_dashboard_ids)
)
feature_flagged_filters.append(condition)
extra_access_filters = []
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
if extra_dashboards_filter := extra_filters.get("dashboards"):
user_id = get_user_id()
if user_id:
extra_access_filters.append(
Dashboard.id.in_(extra_dashboards_filter(user_id))
)
query = query.filter(
or_(
Dashboard.id.in_(editor_ids_query),
Dashboard.id.in_(datasource_perm_query),
*feature_flagged_filters,
*extra_access_filters,
)
)
return query
class DashboardEditableFilter(EditableFilter): # pylint: disable=too-few-public-methods
"""Filter for dashboards the user can edit."""
@@ -41,27 +41,44 @@ params:
filterOptionName: 2745eae5
operator: NOT IN
subject: country_code
compare_lag: '10'
compare_suffix: o10Y
country_fieldtype: cca3
color_scheme: supersetColors
entity: country_name
granularity_sqla: year
groupby: []
limit: 0
markup_type: markdown
legendOrientation: top
legendType: scroll
max_bubble_size: '50'
row_limit: 50000
opacity: 0.6
order_desc: true
row_limit: 500
series: region
show_bubbles: true
since: '2011-01-01'
size: sum__SP_POP_TOTL
show_legend: true
size:
aggregate: SUM
column:
column_name: SP_POP_TOTL
expressionType: SIMPLE
label: SUM(SP_POP_TOTL)
optionName: metric_size_life_expectancy_vs_rural
time_range: '2014-01-01 : 2014-01-02'
until: '2011-01-02'
viz_type: bubble
x: sum__SP_RUR_TOTL_ZS
y: sum__SP_DYN_LE00_IN
tooltipSizeFormat: SMART_NUMBER
truncateXAxis: true
viz_type: bubble_v2
x:
aggregate: SUM
column:
column_name: SP_RUR_TOTL_ZS
expressionType: SIMPLE
label: SUM(SP_RUR_TOTL_ZS)
optionName: metric_x_life_expectancy_vs_rural
y:
aggregate: SUM
column:
column_name: SP_DYN_LE00_IN
expressionType: SIMPLE
label: SUM(SP_DYN_LE00_IN)
optionName: metric_y_life_expectancy_vs_rural
query_context: null
slice_name: Life Expectancy VS Rural %
uuid: c18faec9-ec43-4d36-8b66-4c8b1372020f
version: 1.0.0
viz_type: bubble
viz_type: bubble_v2
+4 -15
View File
@@ -60,6 +60,7 @@ from superset.mcp_service.guest_token_verifier import GUEST_TOKEN_CLAIM
from superset.mcp_service.mcp_config import (
default_user_resolver,
get_mcp_api_key_enabled,
MCP_GUEST_ALLOWED_TOOLS,
validate_multi_issuer_user_resolver,
)
from superset.mcp_service.session_scope import _mcp_session_token
@@ -244,21 +245,9 @@ def _log_scope_denial(
# Default-deny allow-list for embedded guests: a guest may call only these tools,
# regardless of MCP_RBAC_ENABLED or how the guest role (PUBLIC_ROLE_LIKE) is
# configured. Everything else is denied, including newly added tools until listed
# here. Sync with mcp_config.py.
_DEFAULT_GUEST_ALLOWED_TOOLS: frozenset[str] = frozenset(
{
# Dashboard structure, scoped to the token's embedded dashboards.
"get_dashboard_info",
"get_dashboard_layout",
"list_dashboards",
# Chart read + data, scoped by ChartFilter; data-model fields redacted.
"list_charts",
"get_chart_info",
"get_chart_data",
"get_chart_preview",
}
)
# configured. Everything else is denied, including newly added tools until listed.
# Single source of truth: MCP_GUEST_ALLOWED_TOOLS in mcp_config.py.
_DEFAULT_GUEST_ALLOWED_TOOLS: frozenset[str] = frozenset(MCP_GUEST_ALLOWED_TOOLS)
def _guest_allowed_tools() -> frozenset[str]:
+112 -62
View File
@@ -38,6 +38,7 @@ from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
BoxPlotChartConfig,
ChartCapabilities,
ChartConfig,
ChartSemantics,
ColumnRef,
CurrencyFormat,
@@ -52,12 +53,17 @@ from superset.mcp_service.chart.schemas import (
WaterfallChartConfig,
XYChartConfig,
)
from superset.mcp_service.chart.validation.dataset_validator import (
is_dataset_column_temporal,
)
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
from superset.utils.core import FilterOperator
logger = logging.getLogger(__name__)
MCP_DASHBOARD_TIME_FILTER_SUBJECT = "_mcp_dashboard_time_filter_subject"
@dataclass
class DatasetValidationResult:
@@ -91,8 +97,6 @@ def validate_chart_dataset(
Returns:
DatasetValidationResult with validation status and any warnings
"""
from sqlalchemy.exc import SQLAlchemyError
from superset.daos.dataset import DatasetDAO
from superset.mcp_service.auth import has_dataset_access
@@ -108,9 +112,12 @@ def validate_chart_dataset(
error="Chart has no dataset reference (datasource_id is None)",
)
# Try to look up the dataset
# Skip the DatasourceFilter base filter when not checking access, so the
# lookup is a true existence check (it otherwise denies a guest outright).
try:
dataset = DatasetDAO.find_by_id(datasource_id)
dataset = DatasetDAO.find_by_id(
datasource_id, skip_base_filter=not check_access
)
if dataset is None:
return DatasetValidationResult(
@@ -186,8 +193,6 @@ def generate_explore_link(
this skips the permalink path and returns an ``/explore/?form_data_key=...``
URL directly.
"""
from sqlalchemy.exc import SQLAlchemyError
from superset.commands.exceptions import CommandException
from superset.commands.explore.form_data.parameters import CommandParameters
from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand
@@ -295,51 +300,6 @@ def _find_dataset_by_id_or_uuid(dataset_id: int | str | None) -> "SqlaTable | No
return DatasetDAO.find_by_id_or_uuid(str(dataset_id))
def _is_dataset_column_temporal(
col: Any, column_name: str, db_engine_spec: Any
) -> bool:
"""Decide temporality for a single dataset column, mirroring
TableColumn.is_temporal: native temporal SQL types are always
temporal, and is_dttm=True is otherwise trusted over the raw SQL
type -- this is the standard, supported way to mark a non-temporal
column (e.g. a VARCHAR "ds" partition column on Hive/Presto/Trino)
as a date.
The one case guarded against is a plain NUMERIC column (e.g. an
integer "year"/"month" column) that Superset's column-name
heuristics may have mis-flagged as is_dttm=True with no
python_date_format to parse it -- applying DATE_TRUNC/time_grain to
that would fail at query time.
"""
from superset.utils.core import GenericDataType
is_dttm = bool(getattr(col, "is_dttm", False))
col_type = col.type
if not col_type:
return is_dttm # No type info, trust is_dttm flag
column_spec = db_engine_spec.get_column_spec(col_type)
generic_type = column_spec.generic_type if column_spec else None
if generic_type == GenericDataType.TEMPORAL:
return True
if not is_dttm:
return False
if generic_type != GenericDataType.NUMERIC or getattr(
col, "python_date_format", None
):
return True
logger.debug(
"Column '%s' is marked is_dttm=True but has numeric type '%s' with "
"no python_date_format; treating as non-temporal to avoid an "
"invalid DATE_TRUNC on a numeric column",
column_name,
col_type,
)
return False
def is_column_truly_temporal(
column_name: str,
dataset_id: int | str | None,
@@ -347,7 +307,7 @@ def is_column_truly_temporal(
) -> bool:
"""
Check if a column is truly temporal, mirroring TableColumn.is_temporal
(see ``_is_dataset_column_temporal`` for the precedence rules).
using the shared dataset temporal predicate.
Args:
column_name: Name of the column to check
@@ -373,7 +333,7 @@ def is_column_truly_temporal(
for col in dataset.columns:
if col.column_name.lower() == column_lower:
db_engine_spec = dataset.database.db_engine_spec
return _is_dataset_column_temporal(col, column_name, db_engine_spec)
return is_dataset_column_temporal(col, column_name, db_engine_spec)
return True # Default if column not found
@@ -388,13 +348,7 @@ def is_column_truly_temporal(
def map_config_to_form_data(
config: TableChartConfig
| XYChartConfig
| PieChartConfig
| PivotTableChartConfig
| MixedTimeseriesChartConfig
| HandlebarsChartConfig
| BigNumberChartConfig,
config: ChartConfig,
dataset_id: int | str | None = None,
) -> Dict[str, Any]:
"""Map chart config to Superset form_data via the plugin registry.
@@ -434,6 +388,7 @@ def map_config_to_form_data(
parts.append("Suggestions: " + "; ".join(error.suggestions))
raise ValueError(" ".join(parts))
_bind_dashboard_time_range_filter(form_data, config, dataset_id)
return form_data
@@ -802,6 +757,98 @@ def _ensure_temporal_adhoc_filter(form_data: Dict[str, Any], column: str) -> Non
form_data["adhoc_filters"] = existing
def _has_generated_temporal_filter(form_data: Dict[str, Any], column: str) -> bool:
"""Return whether form data contains the neutral generated time binding."""
return any(
isinstance(filter_, dict)
and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
and filter_.get("subject") == column
and filter_.get("comparator") == NO_TIME_RANGE
for filter_ in form_data.get("adhoc_filters", [])
)
def _ensure_generated_temporal_binding(form_data: Dict[str, Any], column: str) -> None:
"""Add a neutral time filter and record its generated provenance."""
_ensure_temporal_adhoc_filter(form_data, column)
if _has_generated_temporal_filter(form_data, column):
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = column
def _bind_dashboard_time_range_filter(
form_data: Dict[str, Any],
config: ChartConfig,
dataset_id: int | str | None,
) -> None:
"""Bind charts without time configuration to a temporal filter subject."""
if temporal_column := getattr(config, "temporal_column", None):
if _is_temporal_for_dashboard_binding(temporal_column, dataset_id):
granularity = form_data.get("granularity_sqla")
if isinstance(granularity, str) and granularity != temporal_column:
# QueryContextFactory gives granularity precedence over a temporal
# filter, so a different granularity would bind both columns.
form_data["granularity_sqla"] = None
_ensure_temporal_adhoc_filter(form_data, temporal_column)
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = temporal_column
return
dataset = None
if dataset_id:
try:
dataset = _find_dataset_by_id_or_uuid(dataset_id)
except (AttributeError, RuntimeError, ValueError, SQLAlchemyError) as ex:
logger.debug(
"Could not resolve dataset %s for dashboard time binding: %s",
dataset_id,
ex,
)
return
granularity = form_data.get("granularity_sqla")
if isinstance(granularity, str) and _is_temporal_for_dashboard_binding(
granularity, dataset_id, dataset
):
# Temporal XY mappers create the neutral filter before this binding pass.
# Record its provenance so preview updates can replace it if the subject
# changes, without treating user-authored temporal ranges as generated.
if _has_generated_temporal_filter(form_data, granularity):
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = granularity
return
x_axis = form_data.get("x_axis")
if isinstance(x_axis, str) and _is_temporal_for_dashboard_binding(
x_axis, dataset_id, dataset
):
_ensure_temporal_adhoc_filter(form_data, x_axis)
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = x_axis
return
main_dttm_col = getattr(dataset, "main_dttm_col", None)
if isinstance(main_dttm_col, str) and _is_temporal_for_dashboard_binding(
main_dttm_col, dataset_id, dataset
):
_ensure_temporal_adhoc_filter(form_data, main_dttm_col)
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = main_dttm_col
def _is_temporal_for_dashboard_binding(
column: str,
dataset_id: int | str | None,
dataset: "SqlaTable | None" = None,
) -> bool:
"""Check temporal metadata without making chart mapping fail on lookup errors."""
try:
return is_column_truly_temporal(column, dataset_id, dataset=dataset)
except (AttributeError, RuntimeError, ValueError, SQLAlchemyError) as ex:
logger.debug(
"Could not validate temporal column %s for dataset %s: %s",
column,
dataset_id,
ex,
)
return False
def _resolve_default_x_axis(
config: XYChartConfig, dataset_id: int | str | None
) -> tuple[XYChartConfig, "SqlaTable | None"]:
@@ -908,7 +955,10 @@ def map_xy_config( # noqa: C901
_add_adhoc_filters(form_data, config.filters)
if x_is_temporal:
# A shared explicit temporal_column is the dashboard binding source of truth.
# Defer to _bind_dashboard_time_range_filter instead of also binding the
# temporal x-axis, which would apply the dashboard range to both columns.
if x_is_temporal and not config.temporal_column:
_ensure_temporal_adhoc_filter(form_data, config.x.name)
_add_xy_limits(form_data, config)
@@ -1104,7 +1154,7 @@ def map_big_number_config(
# panel, which exposes an `adhoc_filters` control even though there's no
# dedicated time-column control for the total variant.
if temporal_column := _resolve_big_number_temporal_column(config, dataset_id):
_ensure_temporal_adhoc_filter(form_data, temporal_column)
_ensure_generated_temporal_binding(form_data, temporal_column)
return form_data
@@ -248,6 +248,8 @@ def get_chart_configs_resource() -> str:
],
"general": [
"Always verify column names with get_dataset_info before charting",
"Set temporal_column when dashboard time filters should use a column "
"other than the dataset's main temporal column",
"Use generate_explore_link for preview, generate_chart for saving",
"Each column label must be unique across the entire configuration",
"Column names must match: ^[a-zA-Z0-9_][a-zA-Z0-9_ \\-\\.]*$",
+54 -33
View File
@@ -772,6 +772,33 @@ class UnknownFieldCheckMixin(BaseModel):
return _check_unknown_fields(data, cls)
class BaseChartConfig(UnknownFieldCheckMixin):
"""Fields shared by every MCP chart configuration."""
temporal_column: str | None = Field(
None,
description=(
"Temporal column used to bind dashboard time-range filters. "
"When omitted, charts without a temporal axis use the dataset's "
"main temporal column."
),
min_length=1,
max_length=255,
)
@field_validator("temporal_column")
@classmethod
def sanitize_temporal_column(cls, v: str | None) -> str | None:
"""Sanitize temporal column names to prevent SQL injection."""
return sanitize_user_input(
v,
"Temporal column",
max_length=255,
check_sql_keywords=True,
allow_empty=True,
)
class ColumnRef(UnknownFieldCheckMixin):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -1024,7 +1051,7 @@ class SortByConfig(UnknownFieldCheckMixin):
# Actual chart types
class PieChartConfig(UnknownFieldCheckMixin):
class PieChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
chart_type: Literal["pie"] = "pie"
@@ -1098,7 +1125,7 @@ class PieChartConfig(UnknownFieldCheckMixin):
return self
class PivotTableChartConfig(UnknownFieldCheckMixin):
class PivotTableChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
chart_type: Literal["pivot_table"] = "pivot_table"
@@ -1162,7 +1189,7 @@ class PivotTableChartConfig(UnknownFieldCheckMixin):
return self
class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
class MixedTimeseriesChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
chart_type: Literal["mixed_timeseries"] = "mixed_timeseries"
@@ -1255,7 +1282,7 @@ class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
return self
class HandlebarsChartConfig(UnknownFieldCheckMixin):
class HandlebarsChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore")
chart_type: Literal["handlebars"] = Field(
@@ -1371,7 +1398,7 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
return self
class BigNumberChartConfig(UnknownFieldCheckMixin):
class BigNumberChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore")
chart_type: Literal["big_number"] = Field(
@@ -1391,17 +1418,6 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
"Must include an aggregate function (e.g., SUM, COUNT)."
),
)
temporal_column: str | None = Field(
None,
description=(
"Temporal column for the trendline x-axis. Required when "
"show_trendline is True. Also used (whether or not a trendline is "
"shown) to bind the chart's dashboard time-range filter; when "
"omitted, the dataset's default temporal column is used instead."
),
min_length=1,
max_length=255,
)
time_grain: TimeGrain | None = Field(
None,
description=(
@@ -1495,18 +1511,6 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
description="Filters to apply",
)
@field_validator("temporal_column")
@classmethod
def sanitize_temporal_column(cls, v: str | None) -> str | None:
"""Sanitize temporal column name to prevent SQL injection."""
return sanitize_user_input(
v,
"Temporal column",
max_length=255,
check_sql_keywords=True,
allow_empty=True,
)
@model_validator(mode="after")
def validate_trendline_fields(self) -> Self:
"""Validate trendline requires temporal column."""
@@ -1576,7 +1580,7 @@ class TableColumnConfig(UnknownFieldCheckMixin):
)
class TableChartConfig(UnknownFieldCheckMixin):
class TableChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
chart_type: Literal["table"] = "table"
@@ -1707,7 +1711,7 @@ def _metric_display_label(col: ColumnRef) -> str:
return col.label or col.name or ""
class XYChartConfig(UnknownFieldCheckMixin):
class XYChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
chart_type: Literal["xy"] = "xy"
@@ -1878,7 +1882,7 @@ class XYChartConfig(UnknownFieldCheckMixin):
return self
class HistogramChartConfig(UnknownFieldCheckMixin):
class HistogramChartConfig(BaseChartConfig):
"""Config for histogram charts (viz_type ``histogram_v2``)."""
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -1921,7 +1925,7 @@ class HistogramChartConfig(UnknownFieldCheckMixin):
return self
class BoxPlotChartConfig(UnknownFieldCheckMixin):
class BoxPlotChartConfig(BaseChartConfig):
"""Config for box plot charts (viz_type ``box_plot``)."""
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2036,7 +2040,7 @@ class BoxPlotChartConfig(UnknownFieldCheckMixin):
return self
class WaterfallChartConfig(UnknownFieldCheckMixin):
class WaterfallChartConfig(BaseChartConfig):
"""Config for waterfall charts (viz_type ``waterfall``)."""
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2574,6 +2578,16 @@ class DataColumn(BaseModel):
)
class ChartQueryResult(BaseModel):
"""Data returned by one query in a chart's query context."""
query_index: int = Field(description="Zero-based query position")
columns: list[str] = Field(description="Column names returned by the query")
data: list[dict[str, Any]] = Field(description="Actual data rows")
row_count: int = Field(description="Rows returned")
total_rows: int | None = Field(None, description="Total available rows")
class ChartData(BaseModel):
"""Rich chart data response with statistical insights."""
@@ -2585,6 +2599,13 @@ class ChartData(BaseModel):
# Enhanced data description
columns: List[DataColumn] = Field(description="Rich column metadata")
data: List[Dict[str, Any]] = Field(description="Actual data rows")
query_results: list[ChartQueryResult] | None = Field(
None,
description=(
"All query results for multi-query charts. The top-level columns and data "
"fields remain aliases for the first query for backward compatibility."
),
)
# Data insights
row_count: int = Field(description="Rows returned")
@@ -32,6 +32,7 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.slice import Slice
from superset.charts.data.form_data import set_query_context_form_data
from superset.commands.exceptions import CommandException
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
from superset.extensions import event_logger
@@ -47,6 +48,7 @@ from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.schemas import (
ChartData,
ChartError,
ChartQueryResult,
DataColumn,
GetChartDataRequest,
PerformanceMetadata,
@@ -112,12 +114,14 @@ _MAX_RECOMMENDATIONS = 4
def _coerce_row_limit(value: Any, default: int) -> int:
"""Coerce a row_limit (which may arrive as a str from chart.params) to int,
falling back to ``default`` when it is missing or non-numeric downstream
apply_max_row_limit compares it against an int."""
falling back to ``default`` when it is missing, non-numeric, or non-positive.
A non-positive limit would otherwise flow through apply_max_row_limit as-is
and emit ``LIMIT -1`` (unbounded on some engines, an error on others)."""
try:
return int(value)
coerced = int(value)
except (TypeError, ValueError):
return default
return coerced if coerced > 0 else default
def _recommend_visualizations(
@@ -264,6 +268,12 @@ def _sanitize_chart_data_for_llm_context(chart_data: ChartData) -> ChartData:
field_path=("data",),
excluded_field_names=frozenset(),
)
for query_index, query_result in enumerate(payload.get("query_results") or []):
query_result["data"] = sanitize_for_llm_context(
query_result.get("data", []),
field_path=("query_results", str(query_index), "data"),
excluded_field_names=frozenset(),
)
payload["columns"] = [
{
**column,
@@ -279,6 +289,29 @@ def _sanitize_chart_data_for_llm_context(chart_data: ChartData) -> ChartData:
return ChartData.model_validate(payload)
def _build_query_results(
query_results: list[dict[str, Any]], limit: int | None
) -> list[ChartQueryResult] | None:
"""Preserve every result when a chart executes more than one query."""
if len(query_results) <= 1:
return None
results = []
for index, query_result in enumerate(query_results):
data = query_result.get("data", [])
returned_data = data[:limit] if limit else data
results.append(
ChartQueryResult(
query_index=index,
columns=query_result.get("colnames", []),
data=returned_data,
row_count=len(returned_data),
total_rows=query_result.get("rowcount"),
)
)
return results
@tool(
tags=["data"],
class_permission_name="Chart",
@@ -335,6 +368,13 @@ async def get_chart_data( # noqa: C901
# Handle unsaved chart (form_data_key only, no identifier)
if not request.identifier and request.form_data_key:
# The unsaved-chart cache is not dashboard-scoped, so guests can't
# use it.
if guest_scope.is_guest_read():
return ChartError(
error="No accessible chart found for this request.",
error_type="NotFound",
)
with event_logger.log_context(
action="mcp.get_chart_data.unsaved_chart_from_cache"
):
@@ -433,30 +473,30 @@ async def get_chart_data( # noqa: C901
)
logger.info("Getting data for chart %s: %s", chart.id, chart.slice_name)
# Skip the dataset RBAC pre-check for guests (see guest_scope.is_guest_read).
if not guest_scope.is_guest_read():
validation_result = validate_chart_dataset(
chart.datasource_id, check_access=True
# Guests skip the RBAC check (authorize_query covers it) but keep the
# existence check, so a deleted dataset still returns
# DatasetNotAccessible.
validation_result = validate_chart_dataset(
chart.datasource_id, check_access=not guest_scope.is_guest_read()
)
if not validation_result.is_valid:
await ctx.warning(
"Chart found but dataset is not accessible: %s"
% (validation_result.error,)
)
if not validation_result.is_valid:
await ctx.warning(
"Chart found but dataset is not accessible: %s"
% (validation_result.error,)
)
logger.warning(
"get_chart_data: dataset not accessible for chart_id=%s: %s",
chart.id,
validation_result.error,
)
return ChartError(
error=validation_result.error
or "Chart's dataset is not accessible. "
"Dataset may have been deleted.",
error_type="DatasetNotAccessible",
)
# Log any warnings (e.g., virtual dataset warnings)
for warning in validation_result.warnings:
await ctx.warning("Dataset warning: %s" % (warning,))
logger.warning(
"get_chart_data: dataset not accessible for chart_id=%s: %s",
chart.id,
validation_result.error,
)
return ChartError(
error=validation_result.error
or "Chart's dataset is not accessible. Dataset may have been deleted.",
error_type="DatasetNotAccessible",
)
# Log any warnings (e.g., virtual dataset warnings)
for warning in validation_result.warnings:
await ctx.warning("Dataset warning: %s" % (warning,))
start_time = time.time()
@@ -469,8 +509,10 @@ async def get_chart_data( # noqa: C901
from superset.charts.schemas import ChartDataQueryContextSchema
from superset.commands.chart.data.get_data_command import ChartDataCommand
# Check if form_data_key is provided - use cached form_data instead
if request.form_data_key:
# Guests always read the saved chart config: the cache isn't
# dashboard-scoped and its payload could point the query at another
# datasource.
if request.form_data_key and not guest_scope.is_guest_read():
with event_logger.log_context(
action="mcp.get_chart_data.unsaved_state_override"
):
@@ -513,11 +555,13 @@ async def get_chart_data( # noqa: C901
# If using cached form_data, we need to build query_context from it
if using_unsaved_state and cached_form_data_dict is not None:
# Build query context from cached form_data (unsaved state)
row_limit = (
# row_limit may arrive as a str. The trailing fallback keeps a
# falsy 0 resolving to ROW_LIMIT.
row_limit = _coerce_row_limit(
request.limit
or cached_form_data_dict.get("row_limit")
or current_app.config["ROW_LIMIT"]
or current_app.config["ROW_LIMIT"],
current_app.config["ROW_LIMIT"],
)
query_context = build_query_context_from_form_data(
@@ -628,8 +672,8 @@ async def get_chart_data( # noqa: C901
# Apply request overrides to the saved query_context
query_context_json["force"] = request.force_refresh
# Apply row limit if specified (respects chart's configured limits)
if request.limit:
# Ignore a non-positive limit so it can't emit LIMIT -1 downstream.
if request.limit and request.limit > 0:
for query in query_context_json.get("queries", []):
query["row_limit"] = request.limit
@@ -663,6 +707,12 @@ async def get_chart_data( # noqa: C901
if guest_dashboard_id is not None:
guest_scope.authorize_query(query_context, guest_dashboard_id, chart)
set_query_context_form_data(
query_context,
chart.datasource_id,
chart.datasource_type,
)
# Execute the query
with event_logger.log_context(action="mcp.get_chart_data.query_execution"):
command = ChartDataCommand(query_context)
@@ -703,7 +753,7 @@ async def get_chart_data( # noqa: C901
)
# Check if we have data to work with
if not data:
if not any(query.get("data") for query in result["queries"]):
await ctx.warning("No data in query results: chart_id=%s" % (chart.id,))
logger.warning(
"get_chart_data: no data in query results for chart_id=%s",
@@ -883,6 +933,9 @@ async def get_chart_data( # noqa: C901
chart_type=chart.viz_type or "unknown",
columns=columns,
data=data[: request.limit] if request.limit else data,
query_results=_build_query_results(
result["queries"], request.limit
),
row_count=len(data),
total_rows=query_result.get("rowcount"),
summary=summary,
@@ -994,8 +1047,11 @@ async def _query_from_form_data(
error_type="InvalidFormData",
)
row_limit = (
request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"]
# row_limit may arrive as a str. The trailing fallback keeps a falsy 0
# resolving to ROW_LIMIT.
row_limit = _coerce_row_limit(
request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"],
current_app.config["ROW_LIMIT"],
)
viz_type = form_data.get("viz_type", "unknown")
@@ -1029,7 +1085,7 @@ async def _query_from_form_data(
data = query_result.get("data", [])
raw_columns = query_result.get("colnames", [])
if not data:
if not any(query.get("data") for query in result["queries"]):
logger.warning(
"get_chart_data: no data for unsaved chart (form_data_key=%s)",
request.form_data_key,
@@ -1078,6 +1134,7 @@ async def _query_from_form_data(
chart_type=viz_type,
columns=columns,
data=data[: request.limit] if request.limit else data,
query_results=_build_query_results(result["queries"], request.limit),
row_count=len(data),
total_rows=query_result.get("rowcount"),
summary=summary,
@@ -29,6 +29,7 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.exceptions import CommandException
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
from superset.extensions import db, event_logger
from superset.mcp_service import guest_scope
from superset.mcp_service.chart.ascii_charts import (
generate_ascii_chart,
generate_ascii_table,
@@ -276,8 +277,6 @@ class PreviewFormatStrategy:
def _authorize_guest_query(self, query_context: Any) -> None:
"""For a guest, attach the dashboard context so raise_for_access
authorizes the preview query."""
from superset.mcp_service import guest_scope
if (dashboard_id := guest_scope.guest_dashboard_id(self.chart)) is not None:
guest_scope.authorize_query(query_context, dashboard_id, self.chart)
@@ -1293,13 +1292,11 @@ async def _get_chart_preview_internal( # noqa: C901
logger.info("Generating preview for chart %s", getattr(chart, "id", "NO_ID"))
logger.info("Chart datasource_id: %s", getattr(chart, "datasource_id", "NONE"))
# Skip the dataset pre-check for transient charts (no ID) and for guests
# (authorized via the dashboard context, not dataset RBAC).
from superset.mcp_service import guest_scope
if getattr(chart, "id", None) is not None and not guest_scope.is_guest_read():
# Transient charts have a falsy id of 0, so skip the pre-check for them.
# Guests keep the existence check but skip RBAC (dashboard-authorized).
if getattr(chart, "id", None):
validation_result = validate_chart_dataset(
chart.datasource_id, check_access=True
chart.datasource_id, check_access=not guest_scope.is_guest_read()
)
if not validation_result.is_valid:
await ctx.warning(
@@ -38,7 +38,9 @@ from superset.mcp_service.chart.chart_utils import (
generate_chart_name,
generate_explore_link,
map_config_to_form_data,
MCP_DASHBOARD_TIME_FILTER_SUBJECT,
merge_table_column_config,
NO_TIME_RANGE,
)
from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.preview_utils import (
@@ -103,6 +105,50 @@ def _get_previous_form_data(form_data_key: str) -> dict[str, Any] | None:
return None
def _preserve_previous_adhoc_filters(
new_form_data: dict[str, Any], previous_form_data: dict[str, Any]
) -> None:
"""Preserve cached filters without dropping mapper-generated bindings."""
previous_filters = previous_form_data.get("adhoc_filters")
if not isinstance(previous_filters, list) or not previous_filters:
return
generated_filters = new_form_data.get("adhoc_filters", [])
previous_binding = previous_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
merged_filters = [
filter_
for filter_ in previous_filters
if not (
previous_binding
and previous_binding != new_binding
and isinstance(filter_, dict)
and filter_.get("operator") == "TEMPORAL_RANGE"
and filter_.get("subject") == previous_binding
and filter_.get("comparator") == NO_TIME_RANGE
)
]
for generated_filter in generated_filters:
if not isinstance(generated_filter, dict):
if generated_filter not in merged_filters:
merged_filters.append(generated_filter)
continue
is_same_filter = any(
isinstance(previous_filter, dict)
and previous_filter.get("clause") == generated_filter.get("clause")
and previous_filter.get("expressionType")
== generated_filter.get("expressionType")
and previous_filter.get("subject") == generated_filter.get("subject")
and previous_filter.get("operator") == generated_filter.get("operator")
for previous_filter in merged_filters
)
if not is_same_filter:
merged_filters.append(generated_filter)
new_form_data["adhoc_filters"] = merged_filters
@tool(
tags=["mutate"],
class_permission_name="Chart",
@@ -184,9 +230,10 @@ def update_chart_preview( # noqa: C901
# Preserve adhoc filters from the previous cached form_data
# when the new config doesn't explicitly specify filters
if getattr(config, "filters", None) is None and previous_form_data:
old_adhoc_filters = previous_form_data.get("adhoc_filters")
if old_adhoc_filters:
new_form_data["adhoc_filters"] = old_adhoc_filters
_preserve_previous_adhoc_filters(
new_form_data,
previous_form_data,
)
if previous_form_data:
merge_table_column_config(previous_form_data, new_form_data)
@@ -39,6 +39,37 @@ _C = TypeVar("_C", bound=ChartConfig)
logger = logging.getLogger(__name__)
def is_dataset_column_temporal(
column: Any, column_name: str, db_engine_spec: Any
) -> bool:
"""Return whether a dataset column is safe for temporal operations."""
from superset.utils.core import GenericDataType
is_dttm = bool(getattr(column, "is_dttm", False))
column_type = column.type
if not column_type:
return is_dttm
column_spec = db_engine_spec.get_column_spec(column_type)
generic_type = column_spec.generic_type if column_spec else None
if generic_type == GenericDataType.TEMPORAL:
return True
if not is_dttm:
return False
if generic_type != GenericDataType.NUMERIC or getattr(
column, "python_date_format", None
):
return True
logger.debug(
"Column '%s' is marked is_dttm=True but has numeric type '%s' with "
"no python_date_format; treating it as non-temporal",
column_name,
column_type,
)
return False
def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
"""Construct a :class:`DatasetContext` from an already-fetched ORM dataset.
@@ -48,13 +79,19 @@ def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
if dataset is None:
return None
database = getattr(dataset, "database", None)
db_engine_spec = getattr(database, "db_engine_spec", None)
columns: List[Dict[str, Any]] = []
for col in getattr(dataset, "columns", []) or []:
columns.append(
{
"name": col.column_name,
"type": str(col.type) if col.type else "UNKNOWN",
"is_temporal": getattr(col, "is_temporal", False),
"is_temporal": (
is_dataset_column_temporal(col, col.column_name, db_engine_spec)
if db_engine_spec
else getattr(col, "is_temporal", False)
),
"is_numeric": getattr(col, "is_numeric", False),
}
)
@@ -69,7 +106,6 @@ def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
}
)
database = getattr(dataset, "database", None)
database_name = getattr(database, "database_name", None) or ""
return DatasetContext(
id=dataset.id,
@@ -123,6 +159,12 @@ class DatasetValidator:
return False, ChartErrorBuilder.dataset_not_found_error(dataset_id)
temporal_error = DatasetValidator._validate_temporal_column(
config, dataset_context
)
if temporal_error:
return False, temporal_error
# Collect all column references
column_refs = DatasetValidator._extract_column_references(config)
@@ -153,6 +195,51 @@ class DatasetValidator:
return True, None
@staticmethod
def _validate_temporal_column(
config: ChartConfig, dataset_context: DatasetContext
) -> ChartGenerationError | None:
"""Require an explicitly selected dashboard time column to be temporal."""
temporal_column = getattr(config, "temporal_column", None)
if not temporal_column:
return None
matching_column = next(
(
column
for column in dataset_context.available_columns
if column["name"].lower() == temporal_column.lower()
),
None,
)
if matching_column is None:
return ChartGenerationError(
error_type="missing_temporal_column",
message=f"Temporal column '{temporal_column}' does not exist",
details="The temporal_column must reference a physical dataset column.",
suggestions=[
"Choose a temporal column from the dataset",
"Remove temporal_column to use the dataset's default time column",
],
error_code="MISSING_TEMPORAL_COLUMN",
)
if matching_column.get("is_temporal", False):
return None
return ChartGenerationError(
error_type="invalid_temporal_column",
message=f"Column '{temporal_column}' is not temporal",
details=(
"The temporal_column must reference a dataset column marked as "
"temporal so dashboard time-range filters can bind to the chart."
),
suggestions=[
"Choose a temporal column from the dataset",
"Remove temporal_column to use the dataset's default time column",
],
error_code="NON_TEMPORAL_COLUMN",
)
@staticmethod
def _validate_columns_exist( # noqa: C901
column_refs: List[ColumnRef], dataset_context: DatasetContext
@@ -290,7 +377,16 @@ class DatasetValidator:
logger.warning("No plugin registered for chart_type=%r", chart_type)
return []
return plugin.extract_column_refs(config)
refs = plugin.extract_column_refs(config)
temporal_column = getattr(config, "temporal_column", None)
if temporal_column and not any(
not ref.saved_metric
and ref.name
and ref.name.lower() == temporal_column.lower()
for ref in refs
):
refs.append(ColumnRef(name=temporal_column))
return refs
@staticmethod
def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
@@ -424,7 +520,16 @@ class DatasetValidator:
)
return config
return plugin.normalize_column_refs(config, dataset_context)
normalized_config = plugin.normalize_column_refs(config, dataset_context)
if temporal_column := getattr(normalized_config, "temporal_column", None):
canonical_temporal_column = DatasetValidator.get_canonical_column_name(
temporal_column, dataset_context
)
if canonical_temporal_column != temporal_column:
normalized_config = normalized_config.model_copy(
update={"temporal_column": canonical_temporal_column}
)
return normalized_config
@staticmethod
def _get_column_suggestions(
@@ -31,6 +31,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload, subqueryload
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.charts.data.form_data import set_query_context_form_data
from superset.commands.exceptions import CommandException
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
from superset.extensions import event_logger
@@ -310,6 +311,8 @@ async def query_dataset( # noqa: C901
custom_cache_timeout=request.cache_timeout,
)
set_query_context_form_data(query_context, dataset.id, "table")
command = ChartDataCommand(query_context)
command.validate()
result = command.run()
+1
View File
@@ -790,6 +790,7 @@ def get_mcp_config(app_config: dict[str, Any] | None = None) -> dict[str, Any]:
"MCP_CHART_PLUGIN_ENABLED_FUNC": MCP_CHART_PLUGIN_ENABLED_FUNC,
"MCP_EMBEDDED_GUEST_AUTH_ENABLED": MCP_EMBEDDED_GUEST_AUTH_ENABLED,
"MCP_GUEST_ALLOWED_TOOLS": set(MCP_GUEST_ALLOWED_TOOLS),
"MCP_RESTRICTED_TOOL_POLICY": MCP_RESTRICTED_TOOL_POLICY,
**MCP_SESSION_CONFIG,
**MCP_CSRF_CONFIG,
}
+61 -1
View File
@@ -802,6 +802,64 @@ def _truncate_rows_field(
return None
def _truncate_chart_query_results(
data: Dict[str, Any], token_limit: int, advice: str
) -> list[str] | None:
"""Apply one response-wide row cap to every result of a multi-query chart."""
from superset.utils import json as utils_json
query_results = data.get("query_results")
if not isinstance(query_results, list) or not query_results:
return None
row_lists = [data.get("data", [])]
row_lists.extend(
result.get("data", [])
for result in query_results
if isinstance(result, dict) and isinstance(result.get("data"), list)
)
originals = [list(rows) for rows in row_lists]
if not any(originals):
return None
original_count = sum(len(rows) for rows in originals[1:])
data["_response_truncated"] = True
data["_truncation_notes"] = [
f"Result truncated: {original_count} of {original_count} rows returned "
f"across multiple queries "
f"(limit ~{token_limit:,} tokens). {advice}"
]
lo, hi = 0, max(len(rows) for rows in originals)
while lo < hi:
cap = (lo + hi + 1) // 2
for rows, original in zip(row_lists, originals, strict=False):
rows[:] = original[:cap]
if estimate_token_count(utils_json.dumps(data)) <= token_limit:
lo = cap
else:
hi = cap - 1
cap = max(lo, 1)
for rows, original in zip(row_lists, originals, strict=False):
rows[:] = original[:cap]
kept_count = sum(len(rows) for rows in row_lists[1:])
if kept_count >= original_count:
del data["_response_truncated"]
del data["_truncation_notes"]
return None
data["row_count"] = len(row_lists[0])
for result in query_results:
if isinstance(result, dict) and isinstance(result.get("data"), list):
result["row_count"] = len(result["data"])
data["_truncation_notes"] = [
f"Result truncated: {kept_count} of {original_count} rows returned "
f"across multiple queries (limit ~{token_limit:,} tokens). {advice}"
]
return data["_truncation_notes"]
def _truncate_csv_data_field(
data: Dict[str, Any],
token_limit: int,
@@ -900,7 +958,9 @@ def truncate_query_result(
if estimate_token_count(utils_json.dumps(data)) <= token_limit:
return data, False, []
notes = _truncate_rows_field(data, row_field, token_limit, advice)
notes = _truncate_chart_query_results(data, token_limit, advice)
if notes is None:
notes = _truncate_rows_field(data, row_field, token_limit, advice)
if notes is None:
notes = _truncate_csv_data_field(data, token_limit, advice)
+22
View File
@@ -181,6 +181,24 @@ OFFSET_JOIN_COLUMN_SUFFIX = "__offset_join_column_"
R_SUFFIX = "__right_suffix"
def _normalize_mssql_virtual_dataset_sql(
sql: str, parsed_script: SQLScript, engine: str
) -> str:
"""Remove SQL Server ordering that is invalid inside a derived table."""
if engine != "mssql" or not parsed_script.statements:
return sql
statement = parsed_script.statements[0]
if not isinstance(statement, SQLStatement):
return sql
return (
parsed_script.format()
if statement.remove_unbounded_top_level_order_by()
else sql
)
def _as_wall_clock(series: pd.Series) -> pd.Series:
"""
Return a datetime series as local wall-clock readings, dropping any
@@ -3174,6 +3192,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
ex,
)
from_sql = _normalize_mssql_virtual_dataset_sql(
from_sql, parsed_script, self.db_engine_spec.engine
)
cte = self.db_engine_spec.get_cte_query(from_sql)
from_clause = (
sa.table(self.db_engine_spec.cte_alias)
+12
View File
@@ -1451,6 +1451,18 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
"""
return bool(self._parsed.args.get("with_"))
def remove_unbounded_top_level_order_by(self) -> bool:
"""Drop ordering that becomes invalid when this query is embedded."""
if (
self._parsed.args.get("order")
and not self._parsed.args.get("limit")
and not self._parsed.args.get("offset")
and not self._parsed.args.get("for_")
):
self._parsed.set("order", None)
return True
return False
def as_cte(self, alias: str = "__cte") -> SQLStatement:
"""
Rewrite the statement as a CTE.
+2 -5
View File
@@ -21,10 +21,11 @@ import logging
from typing import Any, TYPE_CHECKING
from celery.exceptions import SoftTimeLimitExceeded
from flask import current_app, g
from flask import current_app
from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.charts.data.form_data import set_form_data
from superset.charts.schemas import ChartDataQueryContextSchema
from superset.exceptions import (
SupersetErrorException,
@@ -47,10 +48,6 @@ query_timeout = current_app.config[
] # TODO: new config key
def set_form_data(form_data: dict[str, Any]) -> None:
g.form_data = form_data
def _create_query_context_from_form(form_data: dict[str, Any]) -> QueryContext:
"""
Create the query context from the form data.
+4 -2
View File
@@ -76,9 +76,11 @@ def guest_embedded_dashboard_filter() -> Optional[ColumnElement[bool]]:
# TODO (embedded): only use the uuid filter once uuids are rolled out
# A guest token may mix uuid and int dashboard ids during the uuid rollout.
# Route each id kind to its own column and OR them — a plain int sent to the
# uuid-typed column would raise a bind/type error.
# uuid-typed column would raise a bind/type error. Match only the id kinds
# has_guest_access authorizes (uuid, decimal id); a slug is fail-closed on
# the data path, so matching it here would be moot.
uuid_ids = [id_ for id_ in ids if is_uuid(id_)]
int_ids = [id_ for id_ in ids if not is_uuid(id_)]
int_ids = [id_ for id_ in ids if not is_uuid(id_) and str(id_).isdigit()]
conditions: list[Any] = []
if uuid_ids:
conditions.append(Dashboard.embedded.any(EmbeddedDashboard.uuid.in_(uuid_ids)))
+2 -8
View File
@@ -28,11 +28,7 @@ from superset.common.query_context_factory import QueryContextFactory
from superset.common.utils.query_cache_manager import QueryCacheManager
from superset.constants import CacheRegion
from superset.daos.datasource import DatasourceDAO
from superset.utils.core import (
apply_max_row_limit,
extract_dataframe_dtypes,
QueryStatus,
)
from superset.utils.core import extract_dataframe_dtypes, QueryStatus
from superset.views.datasource.schemas import SamplesPayloadSchema
if TYPE_CHECKING:
@@ -49,11 +45,9 @@ def get_limit_clause(page: Optional[int], per_page: Optional[int]) -> dict[str,
if isinstance(page, int) and isinstance(per_page, int):
limit = int(per_page)
if limit < 0:
if limit < 0 or limit > samples_row_limit:
# reset limit value if input is invalid
limit = samples_row_limit
elif limit:
limit = apply_max_row_limit(limit)
offset = max((int(page) - 1) * limit, 0)
@@ -0,0 +1,32 @@
# 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.
from typing import Any
from flask import current_app, g
from superset.charts.data.form_data import set_form_data
def test_set_form_data_exposes_payload_on_flask_global() -> None:
"""The shared helper publishes form data for request-independent queries."""
payload: dict[str, Any] = {"queries": [{"filters": []}]}
with current_app.test_request_context():
set_form_data(payload)
assert g.form_data is payload
@@ -434,6 +434,7 @@ class TestMapBigNumberConfig:
assert form_data["adhoc_filters"][0]["subject"] == "order_date"
assert form_data["adhoc_filters"][0]["operator"] == "TEMPORAL_RANGE"
assert form_data["_mcp_dashboard_time_filter_subject"] == "order_date"
mock_find_by_id_or_uuid.assert_called_once_with("42")
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
@@ -2049,6 +2049,25 @@ class TestValidateChartDataset:
assert result.dataset_id == 7
assert result.dataset_name == "my_table"
assert result.warnings == []
# check_access=True keeps the DatasourceFilter base filter and the RBAC check.
mock_find.assert_called_once_with(7, skip_base_filter=False)
mock_access.assert_called_once_with(dataset)
@patch("superset.mcp_service.auth.has_dataset_access")
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
def test_validate_chart_dataset_no_access_check_is_existence_only(
self, mock_find: MagicMock, mock_access: MagicMock
) -> None:
"""check_access=False (embedded guests) resolves the dataset with the base
filter skipped and never runs the RBAC check. DatasetDAO's DatasourceFilter
returns nothing for a principal without datasource grants, so leaving it on
would turn the existence check into an access check and deny every guest."""
dataset = MagicMock(table_name="orders", sql=None)
mock_find.return_value = dataset
result = validate_chart_dataset(7, check_access=False)
assert result.is_valid
mock_find.assert_called_once_with(7, skip_base_filter=True)
mock_access.assert_not_called()
@patch("superset.mcp_service.auth.has_dataset_access", return_value=True)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@@ -0,0 +1,567 @@
# 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.
"""Regression tests for MCP chart dashboard time-range binding."""
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
import pytest
from superset.common.query_context_factory import QueryContextFactory
from superset.common.query_object import QueryObject
from superset.mcp_service.chart.chart_utils import (
_bind_dashboard_time_range_filter,
adhoc_filters_to_query_filters,
map_config_to_form_data,
)
from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
BoxPlotChartConfig,
ChartConfig,
ColumnRef,
HandlebarsChartConfig,
HistogramChartConfig,
MixedTimeseriesChartConfig,
PieChartConfig,
PivotTableChartConfig,
TableChartConfig,
WaterfallChartConfig,
XYChartConfig,
)
from superset.mcp_service.chart.validation.dataset_validator import (
build_dataset_context_from_orm,
DatasetValidator,
)
from superset.mcp_service.common.error_schemas import DatasetContext
from superset.utils.core import GenericDataType, merge_extra_form_data
METRIC = ColumnRef(name="revenue", aggregate="SUM")
CATEGORY = ColumnRef(name="region")
def _chart_configs() -> list[ChartConfig]:
return [
BigNumberChartConfig(chart_type="big_number", metric=METRIC),
BoxPlotChartConfig(metrics=[METRIC], distribute_across=[CATEGORY]),
HandlebarsChartConfig(
chart_type="handlebars",
handlebars_template="{{#each data}}{{region}}{{/each}}",
groupby=[CATEGORY],
metrics=[METRIC],
),
HistogramChartConfig(column=ColumnRef(name="duration")),
MixedTimeseriesChartConfig(
x=CATEGORY,
y=[METRIC],
y_secondary=[ColumnRef(name="orders", aggregate="COUNT")],
),
PieChartConfig(dimension=CATEGORY, metric=METRIC),
PivotTableChartConfig(rows=[CATEGORY], metrics=[METRIC]),
TableChartConfig(columns=[CATEGORY, METRIC]),
WaterfallChartConfig(
x_axis=ColumnRef(name="event_time"),
metric=METRIC,
),
XYChartConfig(x=CATEGORY, y=[METRIC]),
]
@pytest.mark.parametrize("config", _chart_configs())
def test_every_chart_config_accepts_temporal_column(config: ChartConfig) -> None:
updated = type(config).model_validate(
{**config.model_dump(), "temporal_column": "created_at"}
)
assert updated.temporal_column == "created_at"
@pytest.mark.parametrize(
("config", "expected_subject"),
[
(
BoxPlotChartConfig(metrics=[METRIC], distribute_across=[CATEGORY]),
"order_date",
),
(
HandlebarsChartConfig(
chart_type="handlebars",
handlebars_template="{{region}}",
groupby=[CATEGORY],
metrics=[METRIC],
),
"order_date",
),
(HistogramChartConfig(column=ColumnRef(name="duration")), "order_date"),
(
MixedTimeseriesChartConfig(
x=CATEGORY,
y=[METRIC],
y_secondary=[ColumnRef(name="orders", aggregate="COUNT")],
),
"order_date",
),
(PieChartConfig(dimension=CATEGORY, metric=METRIC), "order_date"),
(PivotTableChartConfig(rows=[CATEGORY], metrics=[METRIC]), "order_date"),
(TableChartConfig(columns=[CATEGORY, METRIC]), "order_date"),
(
WaterfallChartConfig(
x_axis=ColumnRef(name="event_time"),
metric=METRIC,
),
"event_time",
),
(XYChartConfig(x=CATEGORY, y=[METRIC]), "order_date"),
],
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
def test_unbound_charts_get_dashboard_temporal_filter(
mock_is_temporal: MagicMock,
mock_find_dataset: MagicMock,
config: ChartConfig,
expected_subject: str,
) -> None:
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
mock_is_temporal.side_effect = lambda column, dataset_id, dataset=None: column in {
"created_at",
"event_time",
"order_date",
}
form_data = map_config_to_form_data(config, dataset_id=42)
temporal_filters = [
filter_
for filter_ in form_data["adhoc_filters"]
if filter_["operator"] == "TEMPORAL_RANGE"
]
assert temporal_filters == [
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"subject": expected_subject,
"operator": "TEMPORAL_RANGE",
"comparator": "No filter",
}
]
@pytest.mark.parametrize(
"config",
[
TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="created_at",
),
WaterfallChartConfig(
x_axis=CATEGORY,
metric=METRIC,
time_grain="P1D",
temporal_column="created_at",
),
BigNumberChartConfig(
chart_type="big_number",
metric=METRIC,
temporal_column="created_at",
),
],
)
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
def test_explicit_temporal_column_takes_precedence(
mock_is_temporal: MagicMock,
config: ChartConfig,
) -> None:
mock_is_temporal.return_value = True
form_data = map_config_to_form_data(config, dataset_id=42)
assert form_data["adhoc_filters"][0]["subject"] == "created_at"
@patch(
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
return_value=True,
)
@patch(
"superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
return_value=SimpleNamespace(main_dttm_col="event_time"),
)
def test_temporal_xy_binding_records_generated_subject(
mock_find_dataset: MagicMock,
mock_is_temporal: MagicMock,
) -> None:
form_data = map_config_to_form_data(
XYChartConfig(x=ColumnRef(name="event_time"), y=[METRIC]),
dataset_id=42,
)
assert form_data["_mcp_dashboard_time_filter_subject"] == "event_time"
@pytest.mark.parametrize(
"config",
[
XYChartConfig(
x=ColumnRef(name="event_time"),
y=[METRIC],
temporal_column="created_at",
),
MixedTimeseriesChartConfig(
x=ColumnRef(name="event_time"),
y=[METRIC],
y_secondary=[ColumnRef(name="orders", aggregate="COUNT")],
temporal_column="created_at",
),
WaterfallChartConfig(
x_axis=CATEGORY,
metric=METRIC,
time_grain="P1D",
temporal_column="created_at",
),
],
)
@patch(
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
return_value=True,
)
def test_explicit_temporal_column_overrides_temporal_granularity(
mock_is_temporal: MagicMock,
config: ChartConfig,
) -> None:
form_data = map_config_to_form_data(config, dataset_id=42)
# QueryContextFactory gives granularity precedence over temporal filters, so
# retaining event_time here would silently bind the range to both columns.
assert form_data["granularity_sqla"] is None
assert form_data["adhoc_filters"] == [
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"subject": "created_at",
"operator": "TEMPORAL_RANGE",
"comparator": "No filter",
}
]
query_object = QueryObject(
columns=[form_data.get("x_axis") or "event_time"],
filters=cast(Any, adhoc_filters_to_query_filters(form_data["adhoc_filters"])),
granularity=form_data["granularity_sqla"],
time_range="Last week",
)
datasource = SimpleNamespace(
columns=[
SimpleNamespace(column_name="event_time", is_dttm=True),
SimpleNamespace(column_name="created_at", is_dttm=True),
],
main_dttm_col="event_time",
currency_code_column=None,
)
processed = QueryContextFactory()._process_query_object(
datasource, form_data, query_object
)
assert processed.granularity is None
assert processed.filter == [
{"col": "created_at", "op": "TEMPORAL_RANGE", "val": "Last week"}
]
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
def test_non_temporal_waterfall_granularity_falls_back_to_dataset_time_column(
mock_is_temporal: MagicMock,
mock_find_dataset: MagicMock,
) -> None:
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
mock_is_temporal.side_effect = (
lambda column, dataset_id, dataset=None: column == "order_date"
)
config = WaterfallChartConfig(
x_axis=CATEGORY,
metric=METRIC,
time_grain="P1D",
)
form_data = map_config_to_form_data(config, dataset_id=42)
assert form_data["granularity_sqla"] == "region"
assert form_data["adhoc_filters"] == [
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"subject": "order_date",
"operator": "TEMPORAL_RANGE",
"comparator": "No filter",
}
]
@pytest.mark.parametrize(
"config",
[
TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="fiscal_year",
),
BigNumberChartConfig(
chart_type="big_number",
metric=METRIC,
temporal_column="fiscal_year",
),
],
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
def test_explicit_non_temporal_column_does_not_fall_back(
mock_is_temporal: MagicMock,
mock_find_dataset: MagicMock,
config: ChartConfig,
) -> None:
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
mock_is_temporal.return_value = False
form_data = map_config_to_form_data(config, dataset_id=42)
assert "adhoc_filters" not in form_data
mock_find_dataset.assert_not_called()
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
def test_dataset_without_main_temporal_column_remains_unbound(
mock_find_dataset: MagicMock,
) -> None:
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col=None)
form_data = map_config_to_form_data(
TableChartConfig(columns=[CATEGORY, METRIC]),
dataset_id=42,
)
assert "adhoc_filters" not in form_data
@patch(
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
return_value=True,
)
def test_explicit_temporal_column_binds_alongside_different_temporal_filter(
mock_is_temporal: MagicMock,
) -> None:
form_data = {
"adhoc_filters": [
{
"clause": "WHERE",
"comparator": "Last year",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "processed_at",
}
]
}
config = TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="created_at",
)
_bind_dashboard_time_range_filter(form_data, config, dataset_id=42)
assert [filter_["subject"] for filter_ in form_data["adhoc_filters"]] == [
"processed_at",
"created_at",
]
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
@patch(
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
return_value=True,
)
def test_dashboard_time_range_updates_generated_filter(
mock_is_temporal: MagicMock,
mock_find_dataset: MagicMock,
) -> None:
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
form_data = map_config_to_form_data(
TableChartConfig(columns=[CATEGORY, METRIC]),
dataset_id=42,
)
form_data["extra_form_data"] = {"time_range": "Last week"}
merge_extra_form_data(form_data)
assert form_data["adhoc_filters"][0]["subject"] == "order_date"
assert form_data["adhoc_filters"][0]["comparator"] == "Last week"
assert adhoc_filters_to_query_filters(form_data["adhoc_filters"]) == [
{
"col": "order_date",
"op": "TEMPORAL_RANGE",
"val": "Last week",
}
]
def test_temporal_column_is_included_in_dataset_validation() -> None:
config = TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="created_at",
)
refs = DatasetValidator._extract_column_references(config)
assert [ref.name for ref in refs].count("created_at") == 1
def test_dataset_validation_rejects_non_temporal_time_column() -> None:
config = TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="fiscal_year",
)
dataset_context = DatasetContext(
id=42,
table_name="orders",
schema="public",
database_name="examples",
available_columns=[
{"name": "region", "type": "VARCHAR", "is_temporal": False},
{"name": "revenue", "type": "NUMERIC", "is_temporal": False},
{"name": "fiscal_year", "type": "INTEGER", "is_temporal": False},
],
available_metrics=[],
)
is_valid, error = DatasetValidator.validate_against_dataset(
config,
dataset_id=42,
dataset_context=dataset_context,
)
assert not is_valid
assert error is not None
assert error.error_code == "NON_TEMPORAL_COLUMN"
assert "fiscal_year" in error.message
def test_dataset_validation_rejects_missing_explicit_time_column() -> None:
config = TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="missing_at",
)
dataset_context = DatasetContext(
id=42,
table_name="orders",
schema="public",
database_name="examples",
available_columns=[
{"name": "region", "type": "VARCHAR", "is_temporal": False},
{"name": "revenue", "type": "NUMERIC", "is_temporal": False},
],
available_metrics=[],
)
is_valid, error = DatasetValidator.validate_against_dataset(
config, dataset_id=42, dataset_context=dataset_context
)
assert not is_valid
assert error is not None
assert error.error_code == "MISSING_TEMPORAL_COLUMN"
def test_saved_metric_name_does_not_hide_explicit_temporal_column_reference() -> None:
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="created_at", saved_metric=True),
temporal_column="created_at",
)
refs = DatasetValidator._extract_column_references(config)
assert [(ref.name, ref.saved_metric) for ref in refs] == [
("created_at", True),
("created_at", False),
]
def test_dataset_context_uses_binding_temporal_predicate() -> None:
engine_spec = MagicMock()
engine_spec.get_column_spec.return_value = SimpleNamespace(
generic_type=GenericDataType.NUMERIC
)
column = SimpleNamespace(
column_name="fiscal_year",
type="INTEGER",
is_dttm=True,
is_temporal=True,
is_numeric=True,
python_date_format=None,
)
dataset = SimpleNamespace(
id=42,
table_name="orders",
schema="public",
columns=[column],
metrics=[],
database=SimpleNamespace(database_name="examples", db_engine_spec=engine_spec),
)
context = build_dataset_context_from_orm(dataset)
assert context is not None
assert context.available_columns[0]["is_temporal"] is False
@pytest.mark.parametrize(
"config",
[
TableChartConfig(
columns=[CATEGORY, METRIC],
temporal_column="created_at",
),
BigNumberChartConfig(
chart_type="big_number",
metric=METRIC,
temporal_column="created_at",
),
],
)
def test_temporal_column_is_normalized_to_dataset_casing(
config: ChartConfig,
) -> None:
dataset_context = DatasetContext(
id=42,
table_name="orders",
schema="public",
database_name="examples",
available_columns=[
{"name": "region", "type": "VARCHAR"},
{"name": "revenue", "type": "NUMERIC"},
{"name": "Created_At", "type": "TIMESTAMP"},
],
available_metrics=[],
)
normalized = DatasetValidator.normalize_column_names(
config,
dataset_id=42,
dataset_context=dataset_context,
)
assert normalized.temporal_column == "Created_At"
@@ -23,16 +23,19 @@ import importlib
from contextlib import nullcontext
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from superset.mcp_service.chart.schemas import (
ChartData,
ChartError,
DataColumn,
GetChartDataRequest,
PerformanceMetadata,
)
from superset.mcp_service.chart.tool.get_chart_data import (
_build_query_results,
_coerce_row_limit,
_GENERIC_TYPE_MAP,
_MAX_RECOMMENDATIONS,
@@ -132,6 +135,37 @@ def _extract_metrics_and_groupby(
return metrics, groupby_columns
def test_query_context_form_data_supports_request_dependent_jinja_macros() -> None:
"""Chart queries expose filters, URL parameters, and the datasource to Jinja."""
from flask import current_app
from superset.charts.data.form_data import set_query_context_form_data
from superset.common.query_object import QueryObject
from superset.jinja_context import ExtraCache, get_dataset_id_from_context
query = QueryObject(
filters=[{"col": "region", "op": "IN", "val": ["North"]}],
time_range="Last week",
)
query_context: Any = SimpleNamespace(
queries=[query],
form_data={"url_params": {"tenant": "acme"}},
)
with current_app.test_request_context():
set_query_context_form_data(query_context, 7, "table")
extra_cache = ExtraCache()
assert extra_cache.filter_values("region") == ["North"]
assert extra_cache.get_filters("region") == [
{"col": "region", "op": "IN", "val": ["North"]}
]
assert extra_cache.url_param("tenant") == "acme"
assert extra_cache.get_time_filter().time_range == "Last week"
# metric() without an explicit dataset ID performs this lookup.
assert get_dataset_id_from_context("count") == 7
class TestBigNumberChartFallback:
"""Tests for big_number chart fallback query construction."""
@@ -1047,7 +1081,6 @@ class TestChartDataCommandValidation:
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.mcp_service.chart.schemas import ChartError
security_error = SupersetSecurityException(
SupersetError(
@@ -1378,7 +1411,7 @@ class TestOAuthErrorRouting:
class QueryContextFactory:
def create(self, **kwargs: Any) -> object:
return object()
return SimpleNamespace(queries=[], form_data={})
class RaisingChartDataCommand:
def __init__(self, query_context: object) -> None:
@@ -1678,14 +1711,149 @@ def test_bool_isinstance_check_before_int():
(None, 500, 500), # missing -> default
("", 500, 500), # empty string -> default
("abc", 500, 500), # non-numeric -> default
(0, 500, 0), # explicit zero preserved
(0, 500, 500), # non-positive zero -> default (no LIMIT 0)
(-1, 500, 500), # negative -> default (no LIMIT -1 downstream)
("-1", 500, 500), # negative string -> default
],
)
def test_coerce_row_limit(value: Any, default: int, expected: int) -> None:
"""_coerce_row_limit tolerates str/None row_limits from chart.params."""
"""_coerce_row_limit tolerates str/None and rejects non-positive row_limits."""
assert _coerce_row_limit(value, default) == expected
def test_mixed_timeseries_preserves_both_query_results(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Mixed Timeseries data includes both its primary and secondary queries."""
from superset.mcp_service.chart.chart_helpers import (
build_query_dicts_from_form_data,
)
monkeypatch.setattr(
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
lambda *_args: "sqlite",
)
form_data = {
"viz_type": "mixed_timeseries",
"metrics": ["primary_metric"],
"metrics_b": ["secondary_metric"],
"groupby": [],
"groupby_b": [],
}
queries = build_query_dicts_from_form_data(form_data, 1, "table")
assert len(queries) == 2
results = _build_query_results(
[
{"colnames": ["primary"], "data": [{"primary": 1}], "rowcount": 1},
{
"colnames": ["secondary"],
"data": [{"secondary": 2}],
"rowcount": 1,
},
],
limit=None,
)
assert results is not None
assert [result.query_index for result in results] == [0, 1]
assert results[0].data == [{"primary": 1}]
assert results[1].data == [{"secondary": 2}]
def test_single_query_chart_keeps_legacy_shape() -> None:
"""Single-query charts do not gain a redundant query_results payload."""
assert (
_build_query_results([{"colnames": ["metric"], "data": [{"metric": 1}]}], None)
is None
)
def test_multi_query_row_count_reflects_limit() -> None:
"""Nested row counts describe returned rows rather than source rows."""
results = _build_query_results(
[
{"colnames": ["metric"], "data": [{"metric": 1}, {"metric": 2}]},
{"colnames": ["metric"], "data": [{"metric": 3}, {"metric": 4}]},
],
limit=1,
)
assert results is not None
assert [result.row_count for result in results] == [1, 1]
assert [result.data for result in results] == [
[{"metric": 1}],
[{"metric": 3}],
]
@pytest.mark.asyncio
async def test_unsaved_mixed_timeseries_returns_nonempty_secondary_query(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The tool response must not collapse a multi-query command result."""
from unittest.mock import AsyncMock
get_data_command_module = importlib.import_module(
"superset.commands.chart.data.get_data_command"
)
chart_data_module = importlib.import_module(
"superset.mcp_service.chart.tool.get_chart_data"
)
class MultiQueryChartDataCommand:
def __init__(self, query_context: object) -> None:
self.query_context = query_context
def validate(self) -> None:
pass
def run(self) -> dict[str, Any]:
return {
"queries": [
{
"colnames": ["primary"],
"data": [],
"rowcount": 0,
},
{
"colnames": ["secondary"],
"data": [{"secondary": 2}],
"rowcount": 1,
},
]
}
monkeypatch.setattr(
chart_data_module,
"build_query_context_from_form_data",
lambda *_args, **_kwargs: object(),
)
monkeypatch.setattr(
get_data_command_module, "ChartDataCommand", MultiQueryChartDataCommand
)
request = GetChartDataRequest(form_data_key="mixed-chart")
response = await _query_from_form_data(
{
"datasource_id": 1,
"datasource_type": "table",
"viz_type": "mixed_timeseries",
"row_limit": 10,
},
request,
AsyncMock(),
)
assert isinstance(response, ChartData)
assert response.data == []
assert response.query_results is not None
assert [result.data for result in response.query_results] == [
[],
[{"secondary": 2}],
]
def _make_chart_data(**overrides: Any) -> ChartData:
"""Build a minimal valid ChartData for testing."""
from superset.mcp_service.common.cache_schemas import CacheStatus
@@ -1742,3 +1910,279 @@ class TestChartDataTotalRowsCoercion:
chart_data = _make_chart_data(total_rows=5.9)
assert chart_data.total_rows == 5
assert isinstance(chart_data.total_rows, int)
class TestGuestScoping:
"""Tool-level guest coverage for get_chart_data (the highest-value guest
tool): the data query is pinned to the token's dashboard, the dataset
existence check runs without the RBAC access check, and requests that a
guest cannot be scoped for are denied cleanly."""
@pytest.mark.asyncio
async def test_guest_query_pinned_to_dashboard_with_existence_only_check(
self, mcp_server, mock_auth
) -> None:
from unittest.mock import patch
from fastmcp import Client
module = importlib.import_module(
"superset.mcp_service.chart.tool.get_chart_data"
)
chart = SimpleNamespace(
id=9,
slice_name="Sales",
viz_type="table",
datasource_id=1,
datasource_type="table",
query_context='{"queries": []}',
params=None,
)
validate_calls: dict[str, Any] = {}
def fake_validate(datasource_id: Any, check_access: bool = True) -> Any:
validate_calls["check_access"] = check_access
return SimpleNamespace(is_valid=True, warnings=[], error=None)
class _Command:
def __init__(self, query_context: Any) -> None: ...
def validate(self) -> None: ...
def run(self) -> dict[str, Any]:
return {
"queries": [{"data": [{"a": 1}], "colnames": ["a"], "rowcount": 1}]
}
mock_authorize = MagicMock()
with (
patch.object(module, "find_chart_by_identifier", return_value=chart),
patch.object(module, "validate_chart_dataset", side_effect=fake_validate),
patch.object(module.guest_scope, "is_guest_read", return_value=True),
patch.object(module.guest_scope, "guest_dashboard_id", return_value=6),
patch.object(module.guest_scope, "authorize_query", mock_authorize),
patch(
"superset.commands.chart.data.get_data_command.ChartDataCommand",
_Command,
),
patch(
"superset.charts.schemas.ChartDataQueryContextSchema.load",
lambda self, data: object(),
),
):
async with Client(mcp_server) as client:
await client.call_tool(
"get_chart_data", {"request": {"identifier": "9"}}
)
# F: the dataset existence check runs, but without the RBAC access check.
assert validate_calls["check_access"] is False
# Scoping: the data query is pinned to the token's dashboard, so
# raise_for_access authorizes against a dashboard the guest can see.
mock_authorize.assert_called_once()
assert mock_authorize.call_args.args[1] == 6
@pytest.mark.asyncio
async def test_guest_out_of_scope_chart_is_not_found(
self, mcp_server, mock_auth
) -> None:
from unittest.mock import patch
from fastmcp import Client
from superset.utils import json
module = importlib.import_module(
"superset.mcp_service.chart.tool.get_chart_data"
)
# ChartFilter scopes an out-of-scope chart out, so the lookup returns None.
with (
patch.object(module, "find_chart_by_identifier", return_value=None),
patch.object(module.guest_scope, "is_guest_read", return_value=True),
):
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_chart_data", {"request": {"identifier": "123"}}
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "NotFound"
@pytest.mark.asyncio
async def test_guest_form_data_key_only_path_is_denied(
self, mcp_server, mock_auth
) -> None:
from unittest.mock import patch
from fastmcp import Client
from superset.utils import json
module = importlib.import_module(
"superset.mcp_service.chart.tool.get_chart_data"
)
# A valid cached blob is available, so without the guest-denial branch
# the code would proceed to query rather than 404 on a cache miss. The
# NotFound here therefore pins the denial itself, not a cache miss.
with (
patch.object(module.guest_scope, "is_guest_read", return_value=True),
patch.object(
module,
"get_cached_form_data",
return_value='{"datasource_id": 1, "datasource_type": "table"}',
),
):
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_chart_data", {"request": {"form_data_key": "cached-key"}}
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "NotFound"
assert "No accessible chart found for this request." in data["error"]
@pytest.mark.asyncio
async def test_guest_form_data_key_ignored_when_identifier_present(
self, mcp_server, mock_auth
) -> None:
"""A guest supplying identifier + form_data_key never reads the cache: the
cached payload could point the query at a foreign datasource, so a guest
always falls through to the saved chart config."""
from unittest.mock import patch
from fastmcp import Client
module = importlib.import_module(
"superset.mcp_service.chart.tool.get_chart_data"
)
chart = SimpleNamespace(
id=9,
slice_name="Sales",
viz_type="table",
datasource_id=1,
datasource_type="table",
query_context='{"queries": []}',
params=None,
)
class _Command:
def __init__(self, query_context: Any) -> None: ...
def validate(self) -> None: ...
def run(self) -> dict[str, Any]:
return {
"queries": [{"data": [{"a": 1}], "colnames": ["a"], "rowcount": 1}]
}
cached_spy = MagicMock(
return_value='{"datasource_id": 999, "datasource_type": "table"}'
)
with (
patch.object(module, "find_chart_by_identifier", return_value=chart),
patch.object(
module,
"validate_chart_dataset",
return_value=SimpleNamespace(is_valid=True, warnings=[], error=None),
),
patch.object(module.guest_scope, "is_guest_read", return_value=True),
patch.object(module.guest_scope, "guest_dashboard_id", return_value=6),
patch.object(module.guest_scope, "authorize_query", MagicMock()),
patch.object(module, "get_cached_form_data", cached_spy),
patch(
"superset.commands.chart.data.get_data_command.ChartDataCommand",
_Command,
),
patch(
"superset.charts.schemas.ChartDataQueryContextSchema.load",
lambda self, data: object(),
),
):
async with Client(mcp_server) as client:
await client.call_tool(
"get_chart_data",
{"request": {"identifier": "9", "form_data_key": "foreign-key"}},
)
# The gate short-circuits before the cache is ever consulted for a guest.
cached_spy.assert_not_called()
@pytest.mark.asyncio
async def test_query_from_form_data_zero_row_limit_falls_back_to_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A falsy int 0 hits the ``or ROW_LIMIT`` fallback and resolves to the
configured default before coercion runs, so the coercion leaves the cached
0 case unchanged."""
from flask import current_app
module = importlib.import_module("superset.mcp_service.chart.tool.get_chart_data")
captured: dict[str, Any] = {}
def fake_build(form_data: Any, **kwargs: Any) -> Any:
captured["row_limit"] = kwargs.get("row_limit")
return object()
class _Command:
def __init__(self, query_context: Any) -> None: ...
def validate(self) -> None: ...
def run(self) -> dict[str, Any]:
return {"queries": [{"data": [], "colnames": [], "rowcount": 0}]}
monkeypatch.setattr(module, "build_query_context_from_form_data", fake_build)
monkeypatch.setattr(
module,
"event_logger",
SimpleNamespace(log_context=lambda **kwargs: nullcontext()),
)
get_data_command_module = importlib.import_module(
"superset.commands.chart.data.get_data_command"
)
monkeypatch.setattr(get_data_command_module, "ChartDataCommand", _Command)
await _query_from_form_data(
{"datasource_id": 1, "datasource_type": "table", "row_limit": 0},
GetChartDataRequest(form_data_key="k"),
_AsyncContext(),
)
assert captured["row_limit"] == current_app.config["ROW_LIMIT"]
assert captured["row_limit"] != 0
@pytest.mark.asyncio
async def test_query_from_form_data_string_row_limit_is_coerced(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A row_limit arriving as a str (as it can from chart.params) is coerced to
int before it reaches build_query_context_from_form_data (which compares it
against an int downstream). Deleting the _coerce_row_limit call fails this."""
module = importlib.import_module("superset.mcp_service.chart.tool.get_chart_data")
captured: dict[str, Any] = {}
def fake_build(form_data: Any, **kwargs: Any) -> Any:
captured["row_limit"] = kwargs.get("row_limit")
return object()
class _Command:
def __init__(self, query_context: Any) -> None: ...
def validate(self) -> None: ...
def run(self) -> dict[str, Any]:
return {"queries": [{"data": [], "colnames": [], "rowcount": 0}]}
monkeypatch.setattr(module, "build_query_context_from_form_data", fake_build)
monkeypatch.setattr(
module,
"event_logger",
SimpleNamespace(log_context=lambda **kwargs: nullcontext()),
)
get_data_command_module = importlib.import_module(
"superset.commands.chart.data.get_data_command"
)
monkeypatch.setattr(get_data_command_module, "ChartDataCommand", _Command)
await _query_from_form_data(
{"datasource_id": 1, "datasource_type": "table", "row_limit": "250"},
GetChartDataRequest(form_data_key="k"),
_AsyncContext(),
)
assert captured["row_limit"] == 250
assert isinstance(captured["row_limit"], int)
@@ -20,14 +20,17 @@ Unit tests for update_chart_preview MCP tool
"""
import importlib
from typing import Any
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
from superset.mcp_service.chart.chart_utils import map_big_number_config
from superset.mcp_service.chart.schemas import (
AxisConfig,
BigNumberChartConfig,
ColumnRef,
FilterConfig,
LegendConfig,
@@ -586,6 +589,268 @@ class TestUpdateChartPreview:
assert result is None
def test_preserves_generated_temporal_filter_with_cached_filters(self) -> None:
"""Cached filters are merged without replacing the temporal binding."""
new_form_data = {
"adhoc_filters": [
{
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
]
}
previous_form_data = {
"adhoc_filters": [
{
"clause": "WHERE",
"comparator": "North",
"expressionType": "SIMPLE",
"operator": "==",
"subject": "region",
}
]
}
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
previous_form_data,
)
assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
"region",
"ds",
]
def test_cached_temporal_filter_takes_precedence_over_generated_default(
self,
) -> None:
"""A cached chart-specific time range is not duplicated or reset."""
new_form_data = {
"adhoc_filters": [
{
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
]
}
cached_temporal_filter = {
"clause": "WHERE",
"comparator": "Last month",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
{"adhoc_filters": [cached_temporal_filter]},
)
assert new_form_data["adhoc_filters"] == [cached_temporal_filter]
def test_replaces_cached_temporal_filter_when_column_changes(self) -> None:
"""A newly selected temporal column replaces the cached binding."""
new_temporal_filter = {
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "created_at",
}
region_filter = {
"clause": "WHERE",
"comparator": "North",
"expressionType": "SIMPLE",
"operator": "==",
"subject": "region",
}
previous_temporal_filter = {
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
new_form_data: dict[str, Any] = {"adhoc_filters": [new_temporal_filter]}
new_form_data["_mcp_dashboard_time_filter_subject"] = "created_at"
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
{
"adhoc_filters": [region_filter, previous_temporal_filter],
"_mcp_dashboard_time_filter_subject": "ds",
},
)
assert new_form_data["adhoc_filters"] == [
region_filter,
new_temporal_filter,
]
def test_replaces_temporal_xy_binding_when_subject_changes(self) -> None:
"""A temporal XY binding does not survive rebinding to a new subject."""
previous_binding = {
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "event_time",
}
new_binding = {
**previous_binding,
"subject": "created_at",
}
new_form_data = {
"adhoc_filters": [new_binding],
"_mcp_dashboard_time_filter_subject": "created_at",
}
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
{
"adhoc_filters": [previous_binding],
"_mcp_dashboard_time_filter_subject": "event_time",
},
)
assert new_form_data["adhoc_filters"] == [new_binding]
def test_replaces_big_number_fallback_binding_when_subject_changes(self) -> None:
"""A Big Number fallback binding is replaced by a selected subject."""
dataset = Mock(
main_dttm_col=None,
columns=[Mock(column_name="order_date")],
)
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
)
rebound_config = config.model_copy(update={"temporal_column": "created_at"})
with (
patch(
"superset.daos.dataset.DatasetDAO.find_by_id_or_uuid",
return_value=dataset,
),
patch(
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
return_value=True,
),
):
previous_form_data = map_big_number_config(config, dataset_id=42)
new_form_data = map_big_number_config(rebound_config, dataset_id=42)
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
previous_form_data,
)
assert previous_form_data["_mcp_dashboard_time_filter_subject"] == "order_date"
assert new_form_data["_mcp_dashboard_time_filter_subject"] == "created_at"
assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
"created_at"
]
def test_removes_cached_temporal_filter_without_new_binding(self) -> None:
"""A mapping without a temporal subject drops the cached binding."""
region_filter = {
"clause": "WHERE",
"comparator": "North",
"expressionType": "SIMPLE",
"operator": "==",
"subject": "region",
}
previous_temporal_filter = {
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
new_form_data: dict[str, Any] = {}
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
{
"adhoc_filters": [region_filter, previous_temporal_filter],
"_mcp_dashboard_time_filter_subject": "ds",
},
)
assert new_form_data["adhoc_filters"] == [region_filter]
def test_preserves_user_temporal_filter_on_generated_subject(self) -> None:
"""A user-authored range on the binding subject is not generated state."""
generated_binding = {
"clause": "WHERE",
"comparator": "No filter",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
user_filter = {
"clause": "WHERE",
"comparator": "Last month",
"expressionType": "SIMPLE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
}
new_form_data: dict[str, Any] = {}
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
{
"adhoc_filters": [generated_binding, user_filter],
"_mcp_dashboard_time_filter_subject": "ds",
},
)
assert new_form_data["adhoc_filters"] == [user_filter]
def test_rebinding_preserves_unrelated_cached_temporal_filter(self) -> None:
"""Only the generated binding is replaced; user filters retain provenance."""
previous_binding = {
"expressionType": "SIMPLE",
"clause": "WHERE",
"operator": "TEMPORAL_RANGE",
"subject": "ds",
"comparator": "No filter",
}
unrelated_filter = {
"expressionType": "SIMPLE",
"clause": "WHERE",
"operator": "TEMPORAL_RANGE",
"subject": "processed_at",
"comparator": "Last year",
}
new_binding = {
"expressionType": "SIMPLE",
"clause": "WHERE",
"operator": "TEMPORAL_RANGE",
"subject": "created_at",
"comparator": "No filter",
}
new_form_data = {
"adhoc_filters": [new_binding],
"_mcp_dashboard_time_filter_subject": "created_at",
}
update_chart_preview_module._preserve_previous_adhoc_filters(
new_form_data,
{
"adhoc_filters": [previous_binding, unrelated_filter],
"_mcp_dashboard_time_filter_subject": "ds",
},
)
assert new_form_data["adhoc_filters"] == [unrelated_filter, new_binding]
@patch.object(update_chart_preview_module, "validate_and_compile")
@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@@ -21,6 +21,7 @@ from __future__ import annotations
import importlib
from collections.abc import Generator
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, Mock, patch
@@ -181,6 +182,73 @@ async def test_query_dataset_success(mcp_server: FastMCP) -> None:
assert data["data"][0]["category"] == "Electronics"
@pytest.mark.asyncio
async def test_query_dataset_exposes_filters_to_jinja_macros(
mcp_server: FastMCP,
) -> None:
"""The MCP query path populates the form data read by dataset Jinja macros."""
from superset.common.query_object import QueryObject
dataset = _make_dataset()
query = QueryObject(
filters=[{"col": "category", "op": "IN", "val": ["Electronics"]}],
columns=["category"],
metrics=["count"],
)
query_context = SimpleNamespace(queries=[query], form_data={})
observed: dict[str, Any] = {}
def run_query() -> dict[str, Any]:
from superset.jinja_context import ExtraCache, get_dataset_id_from_context
extra_cache = ExtraCache()
observed["filter_values"] = extra_cache.filter_values("category")
observed["get_filters"] = extra_cache.get_filters("category")
# metric() without an explicit dataset ID uses this same context lookup.
observed["metric_dataset_id"] = get_dataset_id_from_context("count")
return _mock_command_result()
with (
patch.object(query_dataset_module, "resolve_dataset", return_value=dataset),
patch(
"superset.common.query_context_factory.QueryContextFactory.create",
return_value=query_context,
),
patch(
"superset.commands.chart.data.get_data_command.ChartDataCommand.validate",
),
patch(
"superset.commands.chart.data.get_data_command.ChartDataCommand.run",
side_effect=run_query,
),
):
async with Client(mcp_server) as client:
result = await client.call_tool(
"query_dataset",
{
"request": {
"dataset_id": 1,
"metrics": ["count"],
"columns": ["category"],
"filters": [
{
"col": "category",
"op": "IN",
"val": ["Electronics"],
}
],
}
},
)
assert not result.is_error
assert observed["filter_values"] == ["Electronics"]
assert observed["get_filters"] == [
{"col": "category", "op": "IN", "val": ["Electronics"]}
]
assert observed["metric_dataset_id"] == 1
@pytest.mark.asyncio
async def test_query_dataset_not_found(mcp_server: FastMCP) -> None:
"""Dataset ID that doesn't exist returns error."""
@@ -334,13 +334,6 @@ def test_tool_denied_for_principal_helper(app: SupersetApp) -> None:
assert _tool_denied_for_principal(allowed) is False
def test_default_allow_list_matches_config() -> None:
"""The auth.py default and the mcp_config.py default must stay in sync."""
from superset.mcp_service.mcp_config import MCP_GUEST_ALLOWED_TOOLS
assert set(_DEFAULT_GUEST_ALLOWED_TOOLS) == set(MCP_GUEST_ALLOWED_TOOLS)
@pytest.mark.parametrize("tool_name", sorted(_DEFAULT_GUEST_ALLOWED_TOOLS))
def test_allow_listed_tools_permitted_for_guest(
app: SupersetApp, tool_name: str
@@ -45,6 +45,8 @@ from superset.mcp_service.middleware import (
ResponseSizeGuardMiddleware,
StructuredContentStripperMiddleware,
)
from superset.mcp_service.utils.token_utils import estimate_token_count
from superset.utils import json as utils_json
from superset.utils.log import DBEventLogger
@@ -471,6 +473,71 @@ class TestResponseSizeGuardMiddleware:
assert result["_response_truncated"] is True
assert len(result["data"]) < 200
@pytest.mark.asyncio
async def test_truncates_multi_query_chart_rows_across_whole_response(self) -> None:
"""All query results share the response's token budget."""
middleware = ResponseSizeGuardMiddleware(token_limit=500)
context = MagicMock()
context.message.name = "get_chart_data"
context.message.arguments = {}
row = {f"col_{i}": f"value_{i}" for i in range(10)}
large_response = {
"chart_id": 1,
"data": [row] * 200,
"row_count": 200,
"query_results": [
{"query_index": 0, "data": [row] * 200, "row_count": 200},
{"query_index": 1, "data": [row] * 200, "row_count": 200},
],
}
call_next = AsyncMock(return_value=large_response)
with (
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
patch("superset.mcp_service.middleware.event_logger"),
):
result = await middleware.on_call_tool(context, call_next)
assert isinstance(result, dict)
assert result["_response_truncated"] is True
assert all(query["data"] for query in result["query_results"])
returned_counts = [len(query["data"]) for query in result["query_results"]]
assert len(set(returned_counts)) == 1
assert returned_counts[0] < 200
assert [
query["row_count"] for query in result["query_results"]
] == returned_counts
@pytest.mark.asyncio
async def test_multi_query_truncation_result_fits_budget(self) -> None:
"""The final multi-query truncation note stays within the token budget."""
middleware = ResponseSizeGuardMiddleware(token_limit=700)
context = MagicMock()
context.message.name = "get_chart_data"
context.message.arguments = {}
row = {f"col_{i}": f"value_{i}" for i in range(10)}
response = {
"chart_id": 1,
"data": [row] * 200,
"row_count": 200,
"query_results": [
{"query_index": 0, "data": [row] * 200, "row_count": 200},
{"query_index": 1, "data": [row] * 200, "row_count": 200},
],
}
with (
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
patch("superset.mcp_service.middleware.event_logger"),
):
result = await middleware.on_call_tool(
context, AsyncMock(return_value=response)
)
assert isinstance(result, dict)
assert estimate_token_count(utils_json.dumps(result)) <= 700
assert " of 400 rows returned" in result["_truncation_notes"][0]
@pytest.mark.asyncio
async def test_data_query_truncation_updates_row_count(self) -> None:
"""row_count should reflect the truncated count, not the original."""
@@ -164,6 +164,64 @@ class TestVirtualDatasetNoRLS:
inner_sql = _get_subquery_sql(virtual_datasource)
assert "::varchar(256)" in inner_sql
@patch("superset.models.helpers.apply_rls", return_value=False)
def test_mssql_unbounded_order_by_removed_when_embedded(
self,
mock_apply_rls: MagicMock,
virtual_datasource: MagicMock,
app: Flask,
) -> None:
"""MSSQL derived tables omit an unbounded top-level ordering."""
virtual_datasource.db_engine_spec.engine = "mssql"
_set_virtual_sql(
virtual_datasource,
"SELECT category, amount FROM sample_events ORDER BY category, amount",
)
assert "ORDER BY" not in _get_subquery_sql(virtual_datasource)
@patch("superset.models.helpers.apply_rls", return_value=False)
def test_mssql_hint_survives_order_by_rewrite(
self,
mock_apply_rls: MagicMock,
virtual_datasource: MagicMock,
app: Flask,
) -> None:
"""Required T-SQL syntax survives the unavoidable AST round trip."""
virtual_datasource.db_engine_spec.engine = "mssql"
_set_virtual_sql(
virtual_datasource,
"SELECT [category] FROM [dbo].[sample_events] WITH (NOLOCK) "
"ORDER BY [category]",
)
inner_sql = _get_subquery_sql(virtual_datasource)
assert "ORDER BY" not in inner_sql
assert "WITH (NOLOCK)" in inner_sql
assert "[category]" in inner_sql
@pytest.mark.parametrize(
"sql",
[
"SELECT TOP 10 PERCENT category FROM sample_events ORDER BY category",
"SELECT TOP 1 WITH TIES category FROM sample_events ORDER BY category",
"SELECT category FROM sample_events ORDER BY category FOR XML PATH('')",
],
)
@patch("superset.models.helpers.apply_rls", return_value=False)
def test_mssql_required_order_by_preserved_when_embedded(
self,
mock_apply_rls: MagicMock,
virtual_datasource: MagicMock,
app: Flask,
sql: str,
) -> None:
"""TOP and serialization clauses retain their semantic ordering."""
virtual_datasource.db_engine_spec.engine = "mssql"
_set_virtual_sql(virtual_datasource, sql)
assert "ORDER BY" in _get_subquery_sql(virtual_datasource)
class TestVirtualDatasetWithRLS:
"""
+16
View File
@@ -2824,6 +2824,22 @@ def test_as_cte_called_twice() -> None:
stmt.as_cte()
@pytest.mark.parametrize(
("sql", "removed"),
[
("SELECT value FROM source ORDER BY value", True),
("SELECT TOP 1 value FROM source ORDER BY value", False),
("SELECT value FROM source ORDER BY value OFFSET 0 ROWS", False),
("SELECT value FROM source ORDER BY value FOR JSON AUTO", False),
],
)
def test_remove_unbounded_top_level_order_by(sql: str, removed: bool) -> None:
statement = SQLStatement(sql, "mssql")
assert statement.remove_unbounded_top_level_order_by() is removed
assert ("ORDER BY" not in statement.format()) is removed
@pytest.mark.parametrize(
"sql, rules, expected",
[
+27
View File
@@ -71,6 +71,33 @@ def test_dashboard_access_filter_uses_viewers_path():
m.assert_called_once()
def test_dashboard_access_filter_scopes_guest_to_token_dashboards():
"""A guest's dashboard list is scoped solely to its token dashboards, never
widened by the role-based paths (mirrors ChartFilter)."""
from superset.dashboards.filters import DashboardAccessFilter
sentinel = object()
sm = _make_sm()
with (
patch("superset.dashboards.filters.security_manager", sm),
patch(
"superset.dashboards.filters.guest_embedded_dashboard_filter",
return_value=sentinel,
),
):
f = DashboardAccessFilter.__new__(DashboardAccessFilter)
query = MagicMock()
with patch.object(f, "_apply_viewers") as viewers:
result = f.apply(query, None)
# The guest branch returns early with the token scoping as the sole
# predicate: no role-based widening, and it precedes even the admin bypass.
query.filter.assert_called_once_with(sentinel)
assert result is query.filter.return_value
viewers.assert_not_called()
sm.is_admin.assert_not_called()
# ---------------------------------------------------------------------------
# DashboardEditableFilter
# ---------------------------------------------------------------------------
+39 -2
View File
@@ -19,6 +19,7 @@ from types import SimpleNamespace
from pytest_mock import MockerFixture
from sqlalchemy import create_engine
from sqlalchemy.sql.elements import False_
from superset.extensions import security_manager
from superset.utils.filters import (
@@ -89,8 +90,6 @@ def test_guest_embedded_dashboard_filter_no_dashboard_resources(
) -> None:
"""A guest with no dashboard resources is denied all charts (fail closed),
not left to fall back to the role-based access path (which None would do)."""
from sqlalchemy.sql.elements import False_
mocker.patch("superset.is_feature_enabled", return_value=True)
guest = SimpleNamespace(resources=[{"type": "dataset", "id": 1}])
mocker.patch.object(
@@ -170,3 +169,41 @@ def test_guest_embedded_dashboard_filter_mixed_uuid_and_int_ids(
assert "embedded_dashboards" in compiled
assert "dashboards.id IN" in compiled
assert " OR " in compiled
def test_guest_embedded_dashboard_filter_slug_only_denies(
mocker: MockerFixture,
) -> None:
"""A slug-only token yields deny-all: has_guest_access authorizes the data
path only by dashboard id/uuid, never slug, so the list filter must not widen
past it (else a slug dashboard would list but its data would be denied)."""
mocker.patch("superset.is_feature_enabled", return_value=True)
mocker.patch.object(
security_manager,
"get_current_guest_user_if_guest",
return_value=_guest_with_dashboards("sales-overview"),
)
clause = guest_embedded_dashboard_filter()
assert isinstance(clause, False_)
def test_guest_embedded_dashboard_filter_ignores_slug_in_mixed_token(
mocker: MockerFixture,
) -> None:
"""A token mixing uuid, int, and slug ids matches only the uuid and int ids;
the slug is dropped (fail-closed on the data path, so never surfaced)."""
mocker.patch("superset.is_feature_enabled", return_value=True)
uuid = "51e44e1c-ffd1-425d-8993-919177955270"
mocker.patch.object(
security_manager,
"get_current_guest_user_if_guest",
return_value=_guest_with_dashboards(uuid, 7, "sales-overview"),
)
clause = guest_embedded_dashboard_filter()
assert clause is not None
compiled = str(clause.compile(create_engine("sqlite://", future=True)))
assert "embedded_dashboards" in compiled
assert "dashboards.id IN" in compiled
assert "dashboards.slug" not in compiled
@@ -22,7 +22,6 @@ import pytest
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.views.datasource.utils import get_limit_clause
@patch("superset.views.datasource.utils.get_limit_clause")
@@ -216,43 +215,3 @@ def test_get_samples_count_star_access_denied(mock_get_limit_clause: MagicMock):
mock_samples_context.raise_for_access.assert_called_once()
# Verify count context was also checked
mock_count_context.raise_for_access.assert_called_once()
@pytest.mark.parametrize("per_page", [5000, 10000])
def test_get_limit_clause_honors_per_page_above_samples_row_limit(
per_page: int,
) -> None:
"""Regression guard: the Explore Data panel "Samples" tab silently caps at
``SAMPLES_ROW_LIMIT`` (config default 1000).
The samples row-limit dropdown offers 5k/10k options and the samples
endpoint's ``SamplesRequestSchema`` accepts ``per_page`` up to 10000, yet
``get_limit_clause`` resets any ``per_page`` above ``SAMPLES_ROW_LIMIT``
back down to it. A user who selects 5k/10k therefore silently receives at
most 1000 rows, with no signal that the requested limit was overridden.
The rows a user is allowed to request and the rows actually returned must
stay consistent: a ``per_page`` the endpoint accepts must not be silently
reduced below the request.
"""
assert get_limit_clause(page=1, per_page=per_page) == {
"row_offset": 0,
"row_limit": per_page,
}
@pytest.mark.parametrize(
"per_page,expected_row_limit",
[
(0, 0),
(-1, 1000),
],
)
def test_get_limit_clause_preserves_zero_and_negative_per_page(
per_page: int,
expected_row_limit: int,
) -> None:
assert get_limit_clause(page=1, per_page=per_page) == {
"row_offset": 0,
"row_limit": expected_row_limit,
}