mirror of
https://github.com/apache/superset.git
synced 2026-08-14 20:11:21 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81e431cd50 | ||
|
|
edfb009e1c | ||
|
|
e808fcbcad | ||
|
|
0a7ebe1dd1 | ||
|
|
dd1afb029f | ||
|
|
c068a8c09c | ||
|
|
b62ec512d2 | ||
|
|
1e65d93a83 | ||
|
|
856599027a | ||
|
|
c395b9a238 | ||
|
|
92728169de | ||
|
|
84c371d56e | ||
|
|
a4c47359e6 | ||
|
|
7c0c5283c3 | ||
|
|
9c5bde9491 | ||
|
|
3b99e092d0 | ||
|
|
acf39e3ef0 | ||
|
|
8523ea4d0a | ||
|
|
9bc3173e3a | ||
|
|
a6db0d1cde | ||
|
|
aec567f7d6 | ||
|
|
bacaf08a22 | ||
|
|
a188e9473a |
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
- **25–50 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
@@ -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
@@ -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"
|
||||
|
||||
Executable
+48
@@ -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"
|
||||
Generated
+94
-85
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+98
-1
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
+56
-1
@@ -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
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+68
-1
@@ -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 });
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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_ \\-\\.]*$",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user