mirror of
https://github.com/apache/superset.git
synced 2026-08-15 20:41:28 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b29ae4320 | ||
|
|
2e8a8031f8 | ||
|
|
cece082eed | ||
|
|
22dde07a8e | ||
|
|
93cc520482 | ||
|
|
7679c5641a | ||
|
|
f7c70568c3 | ||
|
|
69c4011a95 | ||
|
|
4684851336 | ||
|
|
e7139a7ac2 | ||
|
|
21ae918656 | ||
|
|
ca94026e97 | ||
|
|
a3bc2d908c | ||
|
|
738d12677a | ||
|
|
2eedc609a8 | ||
|
|
f4587218dd | ||
|
|
8967e6c2d3 | ||
|
|
81e431cd50 | ||
|
|
edfb009e1c | ||
|
|
e808fcbcad | ||
|
|
0a7ebe1dd1 | ||
|
|
dd1afb029f | ||
|
|
c068a8c09c | ||
|
|
b62ec512d2 | ||
|
|
1e65d93a83 | ||
|
|
856599027a | ||
|
|
c395b9a238 | ||
|
|
92728169de | ||
|
|
84c371d56e | ||
|
|
a4c47359e6 | ||
|
|
7c0c5283c3 | ||
|
|
9c5bde9491 | ||
|
|
3b99e092d0 | ||
|
|
acf39e3ef0 | ||
|
|
8523ea4d0a | ||
|
|
9bc3173e3a | ||
|
|
a6db0d1cde | ||
|
|
aec567f7d6 | ||
|
|
bacaf08a22 |
+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
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
name: Frontend bundle size (nightly baseline + analyzer)
|
||||
|
||||
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
|
||||
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
|
||||
# report of the same build. Deliberately NOT triggered on every push to
|
||||
# master: a day-old baseline/report is fine for catching relative
|
||||
# regressions on PRs and for browsing what's actually in the bundle, and
|
||||
# building the production bundle on every one of the many pushes master
|
||||
# gets per day would burn CI time for no benefit a nightly refresh doesn't
|
||||
# already cover.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh-baseline:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
|
||||
steps:
|
||||
- name: "Checkout master"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t $TAG \
|
||||
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
|
||||
--target superset-node-ci \
|
||||
.
|
||||
|
||||
# Same cache the PR-time bundle-size job restores/writes -- webpack's
|
||||
# persistent filesystem cache turns a warm production build into ~20s
|
||||
# instead of several minutes. See superset-frontend.yml for the
|
||||
# matching restore step and why it's keyed this way.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry. Absent on the very first run ever; benchmark-action starts a
|
||||
# fresh history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
|
||||
# they're independent env-gated additions in webpack.config.js (one
|
||||
# sets `config.stats`, the other pushes plugins), so one production
|
||||
# build produces both the numeric stats.json and the analyzer's
|
||||
# report.html. Only report.html is mounted out, not
|
||||
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
|
||||
# documented in webpack.config.js as routinely exceeding 100MB for
|
||||
# this app (it's .gitignore'd for exactly that reason), too large to
|
||||
# publish as a static site page.
|
||||
- name: Build production bundle with stats and analyzer report
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
mkdir -p ${{ github.workspace }}/superset/static/assets
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# No PR to comment on here, so comment-on-alert is off -- the job
|
||||
# summary (summary-always) is the only surface for this run.
|
||||
- name: Update bundle size baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
- name: Save bundle size history
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
|
||||
# Publishes the treemap to Netlify (the same host already used for
|
||||
# superset-storybook.netlify.app and docs previews, reusing the
|
||||
# existing NETLIFY_AUTH_TOKEN). Skipped until
|
||||
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
|
||||
# Netlify site named superset-bundle-analyzer and add its site ID as
|
||||
# that secret to turn this on; nothing else in this workflow depends
|
||||
# on it.
|
||||
- name: Publish bundle analyzer report to Netlify
|
||||
if: ${{ env.NETLIFY_SITE_ID != '' }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: |
|
||||
mkdir -p netlify-publish
|
||||
cp superset/static/assets/report.html netlify-publish/index.html
|
||||
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
|
||||
# tool, not an application dependency; a global/npx install has no
|
||||
# lockfile context. Version pinned above the floor set by other
|
||||
# ad-hoc installs in this repo (bump deliberately when upgrading).
|
||||
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish
|
||||
@@ -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 }}
|
||||
|
||||
@@ -212,3 +212,100 @@ jobs:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
# Compares a PR's own bundle size against the last nightly-recorded
|
||||
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
|
||||
# persisting new baselines). PR-only: a push to master doesn't need this
|
||||
# check re-run against itself, and re-persisting the baseline on every
|
||||
# push to master -- which happens many times a day -- would burn a full
|
||||
# production build for no benefit nightly refresh doesn't already cover.
|
||||
bundle-size:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
- name: Load Docker Image
|
||||
run: |
|
||||
zstd -d < docker-image.tar.zst | docker load
|
||||
|
||||
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
|
||||
# turns a warm production build into ~20s instead of several minutes,
|
||||
# but GH-hosted runners are fresh VMs with nothing carried over between
|
||||
# jobs -- without restoring it explicitly, every single PR would pay
|
||||
# the full cold-build cost. Keyed on the same files webpack's own
|
||||
# `buildDependencies` invalidates on, so a stale cache is never used.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry, which is always the latest nightly run. Absent before the
|
||||
# first nightly run ever happens; benchmark-action starts a fresh
|
||||
# history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
- name: Build production bundle with stats
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# Comparison + alert only -- this job never persists. See
|
||||
# frontend-bundle-size-nightly.yml for why.
|
||||
#
|
||||
# comment-on-alert is gated to same-repo PRs: on a fork PR,
|
||||
# GITHUB_TOKEN is forced read-only regardless of the `permissions`
|
||||
# block above, so once the alert threshold is crossed the action's
|
||||
# `pulls.createReview` call 403s. That error isn't gated by
|
||||
# fail-on-alert (which only governs the deliberate alert-threshold
|
||||
# failure) -- it propagates and fails the job outright. Fork PRs
|
||||
# still get the comparison via the job summary (summary-always).
|
||||
- name: Compare bundle size against nightly baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-on-alert: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
alert-threshold: "110%"
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -31,6 +31,8 @@ under the License.
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](https://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
[](https://superset-storybook.netlify.app)
|
||||
[](https://superset-bundle-analyzer.netlify.app)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.6, <8",
|
||||
"cachetools>=7.1.7, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click-option-group",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.2.2",
|
||||
"Mako>=1.4.1",
|
||||
"markdown>=3.10.3",
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
@@ -221,7 +221,7 @@ ocient = [
|
||||
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
|
||||
# unverified. Lower confidence than the other bumps in this PR.
|
||||
"sqlalchemy-ocient>=3.0.0, <4",
|
||||
"pyocient>=1.0.15, <4",
|
||||
"pyocient>=3.9.0, <4",
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
|
||||
@@ -28,7 +28,7 @@ numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.3.11,<2.0.0
|
||||
mako>=1.4.1,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
pyarrow>=24.0.0,<26.0.0
|
||||
# Security: CVE-2026-27459 - pyopenssl certificate validation
|
||||
|
||||
@@ -44,7 +44,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.7
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -207,7 +207,7 @@ kombu==5.6.2
|
||||
# via celery
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.12
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
|
||||
@@ -99,7 +99,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.7
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -510,7 +510,7 @@ limits==5.1.0
|
||||
# flask-limiter
|
||||
lz4==4.4.5
|
||||
# via trino
|
||||
mako==1.3.12
|
||||
mako==1.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
|
||||
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
+506
@@ -19,6 +19,7 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
@@ -26,6 +27,27 @@
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz",
|
||||
@@ -1656,6 +1678,121 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz",
|
||||
@@ -2501,6 +2638,16 @@
|
||||
"acorn": "^8.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -2868,6 +3015,34 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2886,6 +3061,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2915,6 +3097,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/envinfo": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz",
|
||||
@@ -3316,6 +3511,60 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
|
||||
@@ -3438,6 +3687,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3498,6 +3754,46 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3977,6 +4273,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4033,6 +4336,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4142,6 +4458,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -4314,6 +4640,33 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
@@ -4447,6 +4800,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -4592,6 +4952,26 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -4605,6 +4985,32 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tscw-config": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tscw-config/-/tscw-config-1.1.2.tgz",
|
||||
@@ -4931,6 +5337,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -4944,6 +5363,16 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.105.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
|
||||
@@ -5070,6 +5499,44 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5114,6 +5581,45 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Switchboard } from "@superset-ui/switchboard";
|
||||
import { embedDashboard } from "./index";
|
||||
|
||||
vi.mock("@superset-ui/switchboard");
|
||||
|
||||
function makeFakeJWT(claims: any) {
|
||||
// not a valid jwt, but close enough for this code
|
||||
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
|
||||
"base64",
|
||||
);
|
||||
return `abc.${tokenifiedClaims}.xyz`;
|
||||
}
|
||||
|
||||
describe("embedDashboard", () => {
|
||||
let mountPoint: HTMLElement;
|
||||
let mockSwitchboard: Switchboard;
|
||||
|
||||
beforeEach(() => {
|
||||
mountPoint = document.createElement("div");
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
mockSwitchboard = {
|
||||
emit: vi.fn(),
|
||||
get: vi.fn(),
|
||||
start: vi.fn(),
|
||||
defineMethod: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Constructor mocks must use `function`, since arrow functions cannot be
|
||||
// invoked with `new`.
|
||||
vi.mocked(Switchboard).mockImplementation(function () {
|
||||
return mockSwitchboard;
|
||||
} as any);
|
||||
|
||||
// Mock MessageChannel API
|
||||
globalThis.MessageChannel = vi.fn(function (this: any) {
|
||||
this.port1 = {};
|
||||
this.port2 = {};
|
||||
}) as any;
|
||||
|
||||
// Mock iframe load event and sandbox
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(tagName => {
|
||||
const element = originalCreateElement(tagName);
|
||||
if (tagName === "iframe") {
|
||||
// Mock sandbox DOMTokenList
|
||||
(element as any).sandbox = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
// Mock contentWindow for postMessage
|
||||
Object.defineProperty(element, "contentWindow", {
|
||||
writable: true,
|
||||
value: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
element.dispatchEvent(new Event("load"));
|
||||
}, 0);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(mountPoint);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("setDataMask sends dataMask to iframe", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const testDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["value1", "value2"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(testDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: testDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends empty dataMask", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const emptyDataMask = {};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(emptyDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: emptyDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask drops the change-trigger flags observeDataMask adds", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const observedMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA"],
|
||||
},
|
||||
},
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(observedMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: {
|
||||
"NATIVE_FILTER-1": observedMask["NATIVE_FILTER-1"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends complex dataMask with multiple filters", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const complexDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA", "NY"],
|
||||
},
|
||||
},
|
||||
"NATIVE_FILTER-2": {
|
||||
filterState: {
|
||||
value: [2023, 2024],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(complexDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: complexDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask rejects when the embedded page does not support it", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
vi.mocked(mockSwitchboard.get).mockRejectedValue(
|
||||
new Error('Method "setDataMask" is not defined'),
|
||||
);
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
await expect(dashboard.setDataMask({})).rejects.toThrow(
|
||||
'Method "setDataMask" is not defined',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,12 @@ export type EmbeddedDashboard = {
|
||||
getActiveTabs: () => Promise<string[]>;
|
||||
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
|
||||
getDataMask: () => Promise<Record<string, any>>;
|
||||
/**
|
||||
* Applies a data mask to the dashboard.
|
||||
* Rejects if the embedded Superset page does not support `setDataMask`,
|
||||
* so a version mismatch surfaces instead of silently doing nothing.
|
||||
*/
|
||||
setDataMask: (dataMask: Record<string, any>) => Promise<void>;
|
||||
getChartStates: () => Promise<Record<string, any>>;
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
@@ -355,6 +361,21 @@ export async function embedDashboard({
|
||||
ourPort.get<string>("getDashboardPermalink", { anchor });
|
||||
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
|
||||
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
|
||||
// `observeDataMask` hands the host a mask with the change-trigger booleans
|
||||
// mixed in, so feeding that payload straight back into `setDataMask` is a
|
||||
// natural thing for a host to do. Keep only the entries that look like a
|
||||
// filter's mask, so those flags never reach the dashboard as filter ids.
|
||||
// Sent with `get` rather than `emit` so the iframe acknowledges the call:
|
||||
// an embedded page that predates `setDataMask` replies with an error instead
|
||||
// of dropping the message silently.
|
||||
const setDataMask = (dataMask: Record<string, any>) =>
|
||||
ourPort.get<void>("setDataMask", {
|
||||
dataMask: Object.fromEntries(
|
||||
Object.entries(dataMask).filter(
|
||||
([, mask]) => typeof mask === "object" && mask !== null,
|
||||
),
|
||||
),
|
||||
});
|
||||
const getChartStates = () =>
|
||||
ourPort.get<Record<string, any>>("getChartStates");
|
||||
const getChartDataPayloads = (params?: { chartId?: number }) =>
|
||||
@@ -396,6 +417,7 @@ export async function embedDashboard({
|
||||
getActiveTabs,
|
||||
observeDataMask,
|
||||
getDataMask,
|
||||
setDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setThemeConfig,
|
||||
|
||||
Generated
+98
-89
@@ -33,7 +33,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -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",
|
||||
@@ -4142,9 +4142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@googleapis/sheets": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
|
||||
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-14.0.0.tgz",
|
||||
"integrity": "sha512-fANEl4RQohsPYUWhcLSYyUyE8A8bRfvw/bp8h0t8VDQqTgdQ3itZBkty4nddtdqCAvNDpA+KM66OejVKDd6aFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"googleapis-common": "^8.0.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"
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -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",
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
|
||||
* or implied. See the License for the specific language governing
|
||||
* permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Reduces a webpack `--json` stats file down to the handful of headline
|
||||
// numbers worth tracking over time, in the flat array format
|
||||
// benchmark-action/github-action-benchmark expects for its
|
||||
// "customSmallerIsBetter" tool. The full stats file also includes a
|
||||
// `modules`/`chunks` graph across ~15k modules, which is enormous and not
|
||||
// useful for this purpose, so we only ever read `entrypoints`.
|
||||
//
|
||||
// Usage: node scripts/bundle-size-summary.js <path-to-stats.json>
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Entrypoints worth tracking: the two user-facing app shells. `menu`,
|
||||
// `preamble`, `theme`, and `service-worker` are small, low-variance
|
||||
// infrastructure chunks, not where bundle bloat actually shows up.
|
||||
const TRACKED_ENTRYPOINTS = ['spa', 'embedded'];
|
||||
|
||||
function entrypointSizeByExt(entrypoint, ext) {
|
||||
return (entrypoint.assets || [])
|
||||
.filter(asset => asset.name.endsWith(ext))
|
||||
.reduce((total, asset) => total + asset.size, 0);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const statsPath = process.argv[2];
|
||||
if (!statsPath) {
|
||||
console.error('Usage: bundle-size-summary.js <path-to-stats.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
||||
const { entrypoints } = stats;
|
||||
if (!entrypoints) {
|
||||
console.error(
|
||||
'stats.json has no `entrypoints` key -- was it built with ' +
|
||||
'`BUNDLE_SIZE_STATS=true` set? Without it, webpack.config.js uses ' +
|
||||
'`stats: "minimal"`, which omits `entrypoints`.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
TRACKED_ENTRYPOINTS.forEach(name => {
|
||||
const entrypoint = entrypoints[name];
|
||||
if (!entrypoint) {
|
||||
console.error(`stats.json is missing the "${name}" entrypoint`);
|
||||
process.exit(1);
|
||||
}
|
||||
results.push({
|
||||
name: `${name} entrypoint (JS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.js'),
|
||||
});
|
||||
results.push({
|
||||
name: `${name} entrypoint (CSS)`,
|
||||
unit: 'bytes',
|
||||
value: entrypointSizeByExt(entrypoint, '.css'),
|
||||
});
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { entrypointSizeByExt, main, TRACKED_ENTRYPOINTS };
|
||||
@@ -35,6 +35,43 @@ if (SERVICE_ACCOUNT_KEY.client_email) {
|
||||
|
||||
const DATETIME = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
|
||||
|
||||
/**
|
||||
* Turn an oxlint diagnostic code into the canonical rule id used by the metrics
|
||||
* series.
|
||||
*
|
||||
* oxlint reports `<plugin>(<rule>)`, where the plugin is the linter the rule came
|
||||
* from: `eslint(no-console)`, `react-hooks(exhaustive-deps)`, `react(jsx-key)`,
|
||||
* `jest(no-conditional-expect)`, `oxc(erasing-op)`, and the legacy
|
||||
* `eslint-plugin-unicorn(no-new-array)` spelling.
|
||||
*
|
||||
* `eslint` is the implicit namespace, so its rules keep their bare name and stay
|
||||
* comparable with the rows recorded before the oxlint migration. Every other
|
||||
* plugin becomes `<plugin>/<rule>`, which is the id those rules are known by in
|
||||
* config and in the pre-migration history.
|
||||
*
|
||||
* @param {string | undefined} code the diagnostic's `code` field
|
||||
* @returns {string} the rule id to record
|
||||
*/
|
||||
function parseRuleId(code) {
|
||||
if (!code) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const match = code.match(/^([\w-]+)\(([^)]+)\)$/);
|
||||
if (!match) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const [, namespace, rule] = match;
|
||||
if (namespace === 'eslint') {
|
||||
return rule;
|
||||
}
|
||||
|
||||
// `eslint-plugin-unicorn(...)` is the same rule as `unicorn/...`
|
||||
const plugin = namespace.replace(/^eslint-plugin-/, '');
|
||||
return `${plugin}/${rule}`;
|
||||
}
|
||||
|
||||
async function writeToGoogleSheet(data, range, headers, append = false) {
|
||||
if (!sheets) {
|
||||
console.log('No Google Sheets credentials, skipping upload');
|
||||
@@ -101,17 +138,7 @@ async function runOxlintAndProcess() {
|
||||
// OXC JSON format has diagnostics array
|
||||
if (results.diagnostics && Array.isArray(results.diagnostics)) {
|
||||
results.diagnostics.forEach(diagnostic => {
|
||||
// Extract rule ID from code like "eslint(no-unused-vars)" or "eslint-plugin-unicorn(no-new-array)"
|
||||
const codeMatch = diagnostic.code?.match(
|
||||
/^(?:eslint(?:-plugin-(\w+))?\()([^)]+)\)$/,
|
||||
);
|
||||
let ruleId = diagnostic.code || 'unknown';
|
||||
|
||||
if (codeMatch) {
|
||||
const plugin = codeMatch[1];
|
||||
const rule = codeMatch[2];
|
||||
ruleId = plugin ? `${plugin}/${rule}` : rule;
|
||||
}
|
||||
const ruleId = parseRuleId(diagnostic.code);
|
||||
|
||||
const file = diagnostic.filename || 'unknown';
|
||||
const line = diagnostic.labels?.[0]?.span?.line || 0;
|
||||
@@ -251,5 +278,10 @@ async function runOxlintAndProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
// Run the process
|
||||
runOxlintAndProcess().catch(console.error);
|
||||
// Run the process, unless this file was imported (e.g. by a test) rather than
|
||||
// executed, in which case nothing should be linted or uploaded on import.
|
||||
if (require.main === module) {
|
||||
runOxlintAndProcess().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { parseRuleId };
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const {
|
||||
entrypointSizeByExt,
|
||||
main,
|
||||
} = require('../../scripts/bundle-size-summary');
|
||||
|
||||
function mockStats(entrypoints) {
|
||||
jest
|
||||
.spyOn(fs, 'readFileSync')
|
||||
.mockReturnValue(JSON.stringify({ entrypoints }));
|
||||
}
|
||||
|
||||
function mockExit() {
|
||||
return jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
}
|
||||
|
||||
const originalArgv = process.argv;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt sums only assets matching the given extension', () => {
|
||||
const entrypoint = {
|
||||
assets: [
|
||||
{ name: 'spa.entry.js', size: 100 },
|
||||
{ name: 'spa.entry.js.map', size: 500 },
|
||||
{ name: 'spa.entry.css', size: 20 },
|
||||
],
|
||||
};
|
||||
expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100);
|
||||
expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20);
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => {
|
||||
expect(entrypointSizeByExt({}, '.js')).toBe(0);
|
||||
});
|
||||
|
||||
test('main prints byte totals for every tracked entrypoint', () => {
|
||||
mockStats({
|
||||
spa: { assets: [{ name: 'spa.js', size: 100 }] },
|
||||
embedded: { assets: [{ name: 'embedded.js', size: 50 }] },
|
||||
});
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
main();
|
||||
|
||||
const printed = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(printed).toEqual([
|
||||
{ name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 },
|
||||
{ name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
{ name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 },
|
||||
{ name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('main exits with an error when a tracked entrypoint is missing from stats.json', () => {
|
||||
mockStats({ spa: { assets: [] } });
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('missing the "embedded" entrypoint'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main exits with an error when stats.json has no `entrypoints` key', () => {
|
||||
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({}));
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no `entrypoints` key'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main prints a usage message and exits when no stats path is given', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
const { parseRuleId } = require('../../scripts/oxlint-metrics-uploader');
|
||||
|
||||
test('eslint rules keep their bare name', () => {
|
||||
expect(parseRuleId('eslint(no-console)')).toBe('no-console');
|
||||
expect(parseRuleId('eslint(prefer-destructuring)')).toBe(
|
||||
'prefer-destructuring',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin rules are recorded under plugin/rule (#42981)', () => {
|
||||
// These are the codes oxlint actually emits. They previously fell through to
|
||||
// the raw `react-hooks(exhaustive-deps)` string, so the rows no longer lined
|
||||
// up with the ids the same rules were recorded under before the migration.
|
||||
expect(parseRuleId('react-hooks(exhaustive-deps)')).toBe(
|
||||
'react-hooks/exhaustive-deps',
|
||||
);
|
||||
expect(parseRuleId('react-hooks(rules-of-hooks)')).toBe(
|
||||
'react-hooks/rules-of-hooks',
|
||||
);
|
||||
expect(parseRuleId('react(jsx-key)')).toBe('react/jsx-key');
|
||||
expect(parseRuleId('jest(no-conditional-expect)')).toBe(
|
||||
'jest/no-conditional-expect',
|
||||
);
|
||||
expect(parseRuleId('oxc(erasing-op)')).toBe('oxc/erasing-op');
|
||||
expect(parseRuleId('typescript(no-explicit-any)')).toBe(
|
||||
'typescript/no-explicit-any',
|
||||
);
|
||||
});
|
||||
|
||||
test('the legacy eslint-plugin- prefix still collapses to the plugin name', () => {
|
||||
expect(parseRuleId('eslint-plugin-unicorn(no-new-array)')).toBe(
|
||||
'unicorn/no-new-array',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unrecognized or missing code is passed through rather than dropped', () => {
|
||||
expect(parseRuleId('something-unparseable')).toBe('something-unparseable');
|
||||
expect(parseRuleId(undefined)).toBe('unknown');
|
||||
expect(parseRuleId('')).toBe('unknown');
|
||||
});
|
||||
@@ -695,6 +695,49 @@ describe('sqlLabReducer', () => {
|
||||
);
|
||||
expect(newState.queries['sync-query'].state).toBe(QueryState.Fetching);
|
||||
});
|
||||
test('should move an async query from running to success when polling reports it finished', () => {
|
||||
const asyncQuery = {
|
||||
...query,
|
||||
id: 'async-query',
|
||||
state: QueryState.Running,
|
||||
runAsync: true,
|
||||
};
|
||||
newState = sqlLabReducer(
|
||||
{
|
||||
...newState,
|
||||
queries: { 'async-query': asyncQuery },
|
||||
},
|
||||
actions.refreshQueries({
|
||||
'async-query': {
|
||||
...asyncQuery,
|
||||
state: QueryState.Success,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(newState.queries['async-query'].state).toBe(QueryState.Success);
|
||||
});
|
||||
test('should downgrade a premature poller success to fetching for a running sync query', () => {
|
||||
const syncQuery = {
|
||||
...query,
|
||||
id: 'sync-running',
|
||||
state: QueryState.Running,
|
||||
runAsync: false,
|
||||
results: null,
|
||||
};
|
||||
newState = sqlLabReducer(
|
||||
{
|
||||
...newState,
|
||||
queries: { 'sync-running': syncQuery },
|
||||
},
|
||||
actions.refreshQueries({
|
||||
'sync-running': {
|
||||
...syncQuery,
|
||||
state: QueryState.Success,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(newState.queries['sync-running'].state).toBe(QueryState.Fetching);
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('CLEAR_INACTIVE_QUERIES', () => {
|
||||
|
||||
@@ -769,14 +769,15 @@ export default function sqlLabReducer(
|
||||
}),
|
||||
// race condition:
|
||||
// because of async behavior, sql lab may still poll a couple of seconds
|
||||
// when it started fetching or finished rendering results
|
||||
// after it started fetching or finished rendering results. Guard only
|
||||
// against re-applying a redundant Success onto a state that's already at
|
||||
// or past Success (Fetching/Success) — Running is strictly before
|
||||
// Success, so an incoming Success there is new information, not a stale
|
||||
// poll, and must be allowed through (otherwise an async query can never
|
||||
// leave Running once observed there).
|
||||
state:
|
||||
currentState === QueryState.Success &&
|
||||
[
|
||||
QueryState.Fetching,
|
||||
QueryState.Success,
|
||||
QueryState.Running,
|
||||
].includes(prevState)
|
||||
[QueryState.Fetching, QueryState.Success].includes(prevState)
|
||||
? prevState
|
||||
: currentState,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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 { DataMaskStateWithId } from '@superset-ui/core';
|
||||
|
||||
// Mock factories must build their own jest.fn()s: jest.mock calls are hoisted
|
||||
// above this file's declarations, so a factory closing over a const would read
|
||||
// it before initialization.
|
||||
jest.mock('@apache-superset/core/utils', () => ({
|
||||
logging: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../views/store', () => ({
|
||||
store: { dispatch: jest.fn(), getState: jest.fn(), subscribe: jest.fn() },
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { embeddedApi } from './api';
|
||||
// eslint-disable-next-line import/first
|
||||
import { updateDataMask } from '../dataMask/actions';
|
||||
|
||||
const { logging: mockLogging } = jest.requireMock(
|
||||
'@apache-superset/core/utils',
|
||||
);
|
||||
const { store: mockStore } = jest.requireMock('../views/store');
|
||||
const mockDispatch = mockStore.dispatch;
|
||||
const mockGetState = mockStore.getState;
|
||||
|
||||
const nativeFilterMask = { filterState: { value: ['CA'] } };
|
||||
const crossFilterMask = { filterState: { value: [2024] } };
|
||||
|
||||
// `dashboardInfo.id` is only set once HYDRATE_DASHBOARD lands, so it doubles as
|
||||
// the "dashboard is hydrated" signal setDataMask waits for.
|
||||
function stateWithFilters(filterIds: string[]) {
|
||||
return {
|
||||
dashboardInfo: { id: 1 },
|
||||
dataMask: Object.fromEntries(filterIds.map(id => [id, { id }])),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('setDataMask dispatches an update for each known filter', () => {
|
||||
mockGetState.mockReturnValue(
|
||||
stateWithFilters(['NATIVE_FILTER-1', 'NATIVE_FILTER-2']),
|
||||
);
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
'NATIVE_FILTER-2': crossFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-2', crossFilterMask),
|
||||
);
|
||||
expect(mockLogging.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setDataMask ignores filter ids the dashboard does not know', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
'NATIVE_FILTER-from-another-dashboard': crossFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
expect(mockLogging.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown filter ids'),
|
||||
'NATIVE_FILTER-from-another-dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
test('setDataMask ignores the change-trigger flags observeDataMask emits', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
});
|
||||
|
||||
test('setDataMask dispatches nothing when no filter id is known', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters([]));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogging.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setDataMask queues the mask until the dashboard hydrates', () => {
|
||||
let notifyStoreSubscribers = () => {};
|
||||
mockStore.subscribe.mockImplementation((listener: () => void) => {
|
||||
notifyStoreSubscribers = listener;
|
||||
return jest.fn();
|
||||
});
|
||||
mockGetState.mockReturnValue({ dataMask: {} });
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogging.warn).not.toHaveBeenCalled();
|
||||
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
notifyStoreSubscribers();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
});
|
||||
@@ -17,12 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { batch } from 'react-redux';
|
||||
import { store } from '../views/store';
|
||||
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
|
||||
import { DashboardChartStates } from '../dashboard/types/chartState';
|
||||
import { hasStatefulCharts } from '../dashboard/util/chartStateConverter';
|
||||
import { getChartDataPayloads as getChartDataPayloadsUtil } from './utils';
|
||||
import { updateDataMask } from '../dataMask/actions';
|
||||
|
||||
const bootstrapData = getBootstrapData();
|
||||
|
||||
@@ -40,6 +43,7 @@ type EmbeddedSupersetApi = {
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
}) => Promise<Record<string, JsonObject>>;
|
||||
setDataMask: ({ dataMask }: { dataMask: DataMaskStateWithId }) => void;
|
||||
};
|
||||
|
||||
const getScrollSize = (): Size => ({
|
||||
@@ -83,6 +87,58 @@ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
|
||||
|
||||
const getDataMask = () => store?.getState()?.dataMask || {};
|
||||
|
||||
const isDashboardHydrated = () => Boolean(store?.getState()?.dashboardInfo?.id);
|
||||
|
||||
const applyDataMask = (dataMask: DataMaskStateWithId) => {
|
||||
// The dashboard's own data mask holds an entry for every native filter and
|
||||
// every cross-filter-capable chart, so it doubles as the set of filter ids
|
||||
// this dashboard can accept. Anything else — a filter id from a different
|
||||
// dashboard, or the change-trigger flags that `observeDataMask` emits
|
||||
// alongside the mask — would otherwise be inserted as a bogus filter and
|
||||
// treated as a globally scoped filter by the active-filter derivation.
|
||||
const knownFilterIds = new Set(Object.keys(getDataMask()));
|
||||
const entries = Object.entries(dataMask);
|
||||
const applicable = entries.filter(([id]) => knownFilterIds.has(id));
|
||||
const ignored = entries.filter(([id]) => !knownFilterIds.has(id));
|
||||
|
||||
if (ignored.length) {
|
||||
logging.warn(
|
||||
'[superset] setDataMask ignored unknown filter ids:',
|
||||
ignored.map(([id]) => id).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
applicable.forEach(([filterId, mask]) => {
|
||||
store?.dispatch(updateDataMask(filterId, mask));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// A mask requested before the dashboard hydrates cannot be applied yet: the
|
||||
// store holds no filter entries to validate the ids against, and hydration
|
||||
// would replace anything dispatched in the meantime. Hold the request and
|
||||
// replay it once hydration lands.
|
||||
let queuedDataMask: DataMaskStateWithId | undefined;
|
||||
let unsubscribeFromHydration: (() => void) | undefined;
|
||||
|
||||
const setDataMask = ({ dataMask }: { dataMask: DataMaskStateWithId }) => {
|
||||
if (isDashboardHydrated()) {
|
||||
applyDataMask(dataMask);
|
||||
return;
|
||||
}
|
||||
|
||||
queuedDataMask = { ...queuedDataMask, ...dataMask };
|
||||
unsubscribeFromHydration ??= store?.subscribe(() => {
|
||||
if (!isDashboardHydrated()) return;
|
||||
unsubscribeFromHydration?.();
|
||||
unsubscribeFromHydration = undefined;
|
||||
const pending = queuedDataMask;
|
||||
queuedDataMask = undefined;
|
||||
if (pending) applyDataMask(pending);
|
||||
});
|
||||
};
|
||||
|
||||
const getChartStates = () =>
|
||||
store?.getState()?.dashboardState?.chartStates || {};
|
||||
|
||||
@@ -102,4 +158,5 @@ export const embeddedApi: EmbeddedSupersetApi = {
|
||||
getDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setDataMask,
|
||||
};
|
||||
|
||||
@@ -298,6 +298,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
|
||||
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
|
||||
Switchboard.defineMethod('getChartStates', embeddedApi.getChartStates);
|
||||
Switchboard.defineMethod('setDataMask', embeddedApi.setDataMask);
|
||||
Switchboard.defineMethod(
|
||||
'getChartDataPayloads',
|
||||
embeddedApi.getChartDataPayloads,
|
||||
|
||||
+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 });
|
||||
|
||||
|
||||
@@ -738,4 +738,14 @@ const smp = new SpeedMeasurePlugin({
|
||||
disable: !measure,
|
||||
});
|
||||
|
||||
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
|
||||
// above omits both). Not `normal`/`detailed` stats: those also serialize the
|
||||
// full ~15k-module dependency graph, which is hundreds of MB for this app --
|
||||
// large enough to exceed Node's max string length when read back with
|
||||
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
|
||||
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
|
||||
if (process.env.BUNDLE_SIZE_STATS) {
|
||||
config.stats = { all: false, assets: true, entrypoints: true };
|
||||
}
|
||||
|
||||
module.exports = smp.wrap(config);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -21,6 +21,7 @@ from functools import partial
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
from superset import db
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import DatabaseNotFoundError
|
||||
from superset.daos.database import DatabaseUserOAuth2TokensDAO
|
||||
@@ -96,6 +97,11 @@ class OAuth2StoreTokenCommand(BaseCommand):
|
||||
database_id=self._state["database_id"],
|
||||
):
|
||||
DatabaseUserOAuth2TokensDAO.delete([existing])
|
||||
# flush the delete before inserting the replacement -- the unit
|
||||
# of work otherwise emits INSERTs before DELETEs within a single
|
||||
# flush, which would trip the (user_id, database_id) unique
|
||||
# index below on the old row.
|
||||
db.session.flush()
|
||||
|
||||
# store tokens
|
||||
expiration = datetime.now() + timedelta(seconds=token_response["expires_in"])
|
||||
|
||||
@@ -318,27 +318,39 @@ def import_tag(
|
||||
|
||||
for tag_name in target_tag_names:
|
||||
try:
|
||||
tag = existing_tags.get(tag_name)
|
||||
# Isolate each tag operation in a SAVEPOINT so a failure (e.g. a
|
||||
# concurrent unique-constraint violation) rolls back only the failed
|
||||
# tag and leaves the session usable for the remaining tags, instead
|
||||
# of poisoning the session with a pending-rollback state.
|
||||
with db_session.begin_nested():
|
||||
tag = existing_tags.get(tag_name)
|
||||
|
||||
# If tag does not exist, create it
|
||||
if tag is None:
|
||||
description = tag_descriptions.get(tag_name, None)
|
||||
tag = Tag(name=tag_name, description=description, type="custom")
|
||||
db_session.add(tag)
|
||||
existing_tags[tag_name] = tag # Update the existing_tags dictionary
|
||||
# If tag does not exist, create it
|
||||
if tag is None:
|
||||
description = tag_descriptions.get(tag_name, None)
|
||||
tag = Tag(name=tag_name, description=description, type="custom")
|
||||
db_session.add(tag)
|
||||
existing_tags[tag_name] = tag # Update the existing_tags dictionary
|
||||
|
||||
# Ensure the association with the object
|
||||
tagged_object = (
|
||||
db_session.query(TaggedObject)
|
||||
.filter_by(object_id=object_id, object_type=object_type, tag_id=tag.id)
|
||||
.first()
|
||||
)
|
||||
if not tagged_object:
|
||||
new_tagged_object = TaggedObject(
|
||||
tag_id=tag.id, object_id=object_id, object_type=object_type
|
||||
# Ensure the association with the object
|
||||
tagged_object = (
|
||||
db_session.query(TaggedObject)
|
||||
.filter_by(
|
||||
object_id=object_id, object_type=object_type, tag_id=tag.id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
db_session.add(new_tagged_object)
|
||||
if not tagged_object:
|
||||
new_tagged_object = TaggedObject(
|
||||
tag_id=tag.id, object_id=object_id, object_type=object_type
|
||||
)
|
||||
db_session.add(new_tagged_object)
|
||||
|
||||
# Only record the tag as imported once the SAVEPOINT has been
|
||||
# released (and its pending inserts flushed) without error; the
|
||||
# nested block's own flush can still fail on a concurrent
|
||||
# unique-constraint violation, in which case this line must not
|
||||
# run.
|
||||
new_tag_ids.append(tag.id)
|
||||
|
||||
except SQLAlchemyError as err:
|
||||
@@ -349,7 +361,9 @@ def import_tag(
|
||||
object_id,
|
||||
err,
|
||||
)
|
||||
continue # No need for manual rollback, handled by transaction decorator
|
||||
# The SAVEPOINT was rolled back by begin_nested(); the session is
|
||||
# still usable for the remaining tags.
|
||||
continue
|
||||
|
||||
# Remove old tags not in the new config
|
||||
for tag in existing_assocs:
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any, cast
|
||||
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import db, results_backend, results_backend_use_msgpack
|
||||
from superset.commands.base import BaseCommand
|
||||
@@ -98,6 +99,15 @@ class SqlExecutionResultsCommand(BaseCommand):
|
||||
),
|
||||
status=403,
|
||||
) from ex
|
||||
except TemplateError as ex:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=str(ex),
|
||||
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=400,
|
||||
) from ex
|
||||
|
||||
# Now fetch results from backend (query exists, so this is a valid request)
|
||||
read_from_results_backend_start = now_as_float()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -46,6 +46,7 @@ Configuration:
|
||||
|
||||
import logging
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Callable, cast, Generator, TYPE_CHECKING, TypeAlias, TypeVar
|
||||
|
||||
from flask import current_app, g, has_app_context, has_request_context
|
||||
@@ -60,6 +61,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
|
||||
@@ -78,6 +80,15 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The resolved user's id for the current tool call, set by
|
||||
# _setup_user_context(). g.user is only valid for the lifetime of the
|
||||
# per-call app context pushed by _get_app_context_manager() (see its
|
||||
# docstring) and is gone by the time LoggingMiddleware's on_call_tool/
|
||||
# on_message finally-blocks run after call_next() returns. This ContextVar
|
||||
# survives that context pop (mirrors _mcp_call_id_var in middleware.py) so
|
||||
# audit logging can attribute the call to the right user.
|
||||
_mcp_user_id_var: ContextVar[int | None] = ContextVar("mcp_user_id", default=None)
|
||||
|
||||
# An MCP request resolves to a real DB ``User`` or, for embedded guests, a
|
||||
# ``GuestUser`` (an AnonymousUserMixin, not a ``User`` subclass). Both are valid
|
||||
# authenticated principals for tool execution.
|
||||
@@ -244,21 +255,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]:
|
||||
@@ -890,9 +889,9 @@ def _assert_user_active(user: MCPUser | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _setup_user_context() -> MCPUser | None:
|
||||
def _resolve_user_with_retry() -> MCPUser | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
Resolve the current user, retrying once on a stale DB connection.
|
||||
|
||||
Includes retry logic for stale database connections (e.g., SSL dropped
|
||||
by proxy/load balancer after idle periods). On OperationalError, the
|
||||
@@ -901,14 +900,6 @@ def _setup_user_context() -> MCPUser | None:
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
# Clear stale g.user to prevent user impersonation across
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
user = None # Ensure defined before loop in case of unexpected exit
|
||||
@@ -925,7 +916,7 @@ def _setup_user_context() -> MCPUser | None:
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
break
|
||||
return user
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
if "application context" in str(e):
|
||||
@@ -959,8 +950,38 @@ def _setup_user_context() -> MCPUser | None:
|
||||
g.pop("user", None)
|
||||
raise
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _setup_user_context() -> MCPUser | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
# Clear stale g.user to prevent user impersonation across
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
# Clear any user_id left over from a previous call in this context
|
||||
# (e.g. sequential calls sharing one asyncio task) so a failed/
|
||||
# unauthenticated lookup below doesn't inherit a stale value.
|
||||
_mcp_user_id_var.set(None)
|
||||
|
||||
user = _resolve_user_with_retry()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
_assert_user_active(user)
|
||||
g.user = user
|
||||
# GuestUser (embedded auth) has no numeric id; leave the ContextVar
|
||||
# cleared (already reset above) rather than raise.
|
||||
if (user_id := getattr(user, "id", None)) is not None:
|
||||
_mcp_user_id_var.set(user_id)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -326,7 +326,7 @@ async def generate_chart( # noqa: C901
|
||||
|
||||
# Persist the chart only when explicitly requested (save_chart=False by default)
|
||||
if request.save_chart:
|
||||
await ctx.report_progress(2, 5, "Creating chart in database")
|
||||
await ctx.report_progress(2, 5, "Validating chart query")
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
|
||||
# Find the dataset to get its numeric ID
|
||||
@@ -414,6 +414,64 @@ async def generate_chart( # noqa: C901
|
||||
)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
# Compile before persisting. A failed query must not leave a broken
|
||||
# chart row behind and then rely on a later transaction to delete it.
|
||||
with event_logger.log_context(action="mcp.generate_chart.compile_check"):
|
||||
compile_result = _compile_chart(form_data, dataset.id)
|
||||
if not compile_result.success:
|
||||
logger.warning(
|
||||
"Compile check failed before chart creation: %s",
|
||||
compile_result.error,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Chart compile check failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message="Chart query failed to execute. The chart was not saved.",
|
||||
details=str(compile_result.error) or "",
|
||||
suggestions=[
|
||||
"Check that all columns exist in the dataset",
|
||||
"Verify aggregate functions are compatible with column types",
|
||||
"Ensure filters reference valid columns",
|
||||
"Try simplifying the chart configuration",
|
||||
],
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": error.model_dump(),
|
||||
"form_data": (
|
||||
_sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data
|
||||
)
|
||||
),
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
"optimization_suggestions": [],
|
||||
},
|
||||
"warnings": (
|
||||
sanitization_warnings
|
||||
+ runtime_warnings
|
||||
+ response_warnings
|
||||
+ compile_result.warnings
|
||||
),
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
await ctx.report_progress(3, 5, "Creating chart in database")
|
||||
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.generate_chart.db_write"):
|
||||
command = CreateChartCommand(
|
||||
@@ -489,66 +547,6 @@ async def generate_chart( # noqa: C901
|
||||
# Add any validation warnings (e.g., virtual dataset warnings)
|
||||
response_warnings.extend(dataset_check.warnings)
|
||||
|
||||
# Compile check: execute the chart query to catch runtime errors
|
||||
await ctx.report_progress(3, 5, "Running compile check (test query)")
|
||||
with event_logger.log_context(
|
||||
action="mcp.generate_chart.compile_check"
|
||||
):
|
||||
compile_result = _compile_chart(form_data, dataset.id)
|
||||
if not compile_result.success:
|
||||
# Query failed — delete the broken chart and return an error
|
||||
logger.warning(
|
||||
"Compile check failed for chart %s: %s",
|
||||
chart_id,
|
||||
compile_result.error,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Chart compile check failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
ChartDAO.delete([chart])
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message=(
|
||||
"Chart query failed to execute. The chart was not saved."
|
||||
),
|
||||
details=str(compile_result.error) or "",
|
||||
suggestions=[
|
||||
"Check that all columns exist in the dataset",
|
||||
"Verify aggregate functions are compatible "
|
||||
"with column types",
|
||||
"Ensure filters reference valid columns",
|
||||
"Try simplifying the chart configuration",
|
||||
],
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": error.model_dump(),
|
||||
"form_data": (
|
||||
_sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data
|
||||
)
|
||||
),
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
"optimization_suggestions": [],
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
except CommandException as e:
|
||||
logger.error("Chart creation failed: %s", e)
|
||||
await ctx.error("Chart creation failed: error=%s" % (str(e),))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -102,7 +102,8 @@ async def list_charts(
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``slice_name``, ``viz_type``, ``description``,
|
||||
``changed_on``, ``created_on``
|
||||
``changed_on``, ``changed_on_delta_humanized`` (alias for ``changed_on``),
|
||||
``created_on``
|
||||
|
||||
To filter by a person, call find_users to resolve the name to a user ID,
|
||||
then pass it as a filter: filters=[{"col": "created_by_fk", "opr": "eq",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -257,6 +257,7 @@ CHART_SORTABLE_COLUMNS = [
|
||||
"viz_type",
|
||||
"description",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
CHART_SEARCH_COLUMNS = ["slice_name", "description"]
|
||||
@@ -354,6 +355,7 @@ DATASET_SORTABLE_COLUMNS = [
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
DATASET_SEARCH_COLUMNS = ["table_name", "description"]
|
||||
@@ -450,6 +452,7 @@ DASHBOARD_SORTABLE_COLUMNS = [
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
DASHBOARD_SEARCH_COLUMNS = ["dashboard_title", "slug"]
|
||||
|
||||
@@ -56,15 +56,6 @@ DEFAULT_DASHBOARD_COLUMNS = [
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
SORTABLE_DASHBOARD_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
_DEFAULT_LIST_DASHBOARDS_REQUEST = ListDashboardsRequest()
|
||||
|
||||
|
||||
@@ -103,7 +94,8 @@ async def list_dashboards(
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``dashboard_title``, ``slug``, ``published``,
|
||||
``changed_on``, ``created_on``
|
||||
``changed_on``, ``changed_on_delta_humanized`` (alias for ``changed_on``),
|
||||
``created_on``
|
||||
|
||||
To filter by a person (e.g. "dashboards Maxime is working on"), do NOT pass
|
||||
the name as the search parameter — search matches titles and slugs only.
|
||||
|
||||
@@ -66,14 +66,6 @@ DEFAULT_DATASET_COLUMNS = [
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
SORTABLE_DATASET_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
_DEFAULT_LIST_DATASETS_REQUEST = ListDatasetsRequest()
|
||||
|
||||
|
||||
@@ -115,7 +107,8 @@ async def list_datasets(
|
||||
``created_by_fk``, ``changed_by_fk``
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``table_name``, ``schema``, ``changed_on``, ``created_on``
|
||||
``id``, ``table_name``, ``schema``, ``changed_on``,
|
||||
``changed_on_delta_humanized`` (alias for ``changed_on``), ``created_on``
|
||||
|
||||
To filter by a person, call find_users to resolve the name to a user ID,
|
||||
then pass it as a filter: filters=[{"col": "created_by_fk", "opr": "eq",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,20 @@ F = TypeVar("F", bound=BaseModel) # For filter types
|
||||
L = TypeVar("L", bound=BaseModel) # For list response schemas
|
||||
|
||||
|
||||
# Humanized/computed columns accepted as order_column aliases, mapped to the
|
||||
# real sortable column that backs them. Superset's own REST APIs (see the
|
||||
# `@renders("changed_on")` binding on `changed_on_delta_humanized` in
|
||||
# models/helpers.py, and each ModelRestApi's `order_columns`) already sort
|
||||
# by the underlying timestamp when asked to order by the humanized string,
|
||||
# since the humanized value is derived from it and isn't itself a queryable
|
||||
# column. Mirrored here so DAO.list() (which does a plain
|
||||
# `getattr(model, order_column)`) receives an actual column, not a Python
|
||||
# property/method.
|
||||
_ORDER_COLUMN_ALIASES: dict[str, str] = {
|
||||
"changed_on_delta_humanized": "changed_on",
|
||||
}
|
||||
|
||||
|
||||
class BaseCore(ABC):
|
||||
"""
|
||||
Abstract base class for all MCP Core classes.
|
||||
@@ -388,6 +402,14 @@ class ModelListCore(BaseCore, Generic[L]):
|
||||
columns_to_load.append(dependency)
|
||||
|
||||
self._validate_order_column(order_column)
|
||||
# Resolve humanized/computed aliases (e.g. changed_on_delta_humanized)
|
||||
# to the real column they're derived from. Must happen after
|
||||
# validation (which checks against the advertised sortable_columns,
|
||||
# including the alias) and before the DAO call, since DAO.list()
|
||||
# sorts via `getattr(model, order_column)` and would receive a
|
||||
# Python property/method instead of a SQL column otherwise.
|
||||
if order_column is not None and order_column in self._sortable_columns:
|
||||
order_column = _ORDER_COLUMN_ALIASES.get(order_column, order_column)
|
||||
|
||||
deleted_state_bound = self._build_deleted_state_filter(deleted_state)
|
||||
if deleted_state_bound is not None:
|
||||
|
||||
@@ -41,6 +41,7 @@ from superset.exceptions import SupersetException, SupersetSecurityException
|
||||
from superset.extensions import event_logger, stats_logger_manager
|
||||
from superset.mcp_service.auth import (
|
||||
_get_app_context_manager,
|
||||
_mcp_user_id_var,
|
||||
get_user_from_request,
|
||||
is_tool_visible_to_current_user,
|
||||
MCPNoAuthSourceError,
|
||||
@@ -625,6 +626,24 @@ class LoggingMiddleware(Middleware):
|
||||
success = False
|
||||
raise
|
||||
finally:
|
||||
# user_id was captured before call_next() ran the tool, i.e.
|
||||
# before the @tool auth decorator (superset/mcp_service/auth.py)
|
||||
# resolves the user. It sets g.user on a per-call app context
|
||||
# that _get_app_context_manager() pushes and pops around the
|
||||
# tool's execution (see its docstring), so g.user/get_user_id()
|
||||
# are back to their pre-call state by the time we get here —
|
||||
# re-reading get_user_id() would still yield the stale value.
|
||||
# _mcp_user_id_var is a plain ContextVar (not tied to that Flask
|
||||
# app-context lifecycle) that _setup_user_context() sets before
|
||||
# the context pops, so it survives to this point.
|
||||
resolved_user_id = _mcp_user_id_var.get(None)
|
||||
if resolved_user_id is not None:
|
||||
user_id = resolved_user_id
|
||||
# Reset so a later on_call_tool/on_message in the same asyncio
|
||||
# task (e.g. an unprotected tool or resource/prompt read that
|
||||
# never calls _setup_user_context()) doesn't inherit this call's
|
||||
# resolved user id.
|
||||
_mcp_user_id_var.set(None)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
self._log_call_tool_result(
|
||||
context=context,
|
||||
@@ -666,34 +685,44 @@ class LoggingMiddleware(Middleware):
|
||||
self._extract_context_info(context)
|
||||
)
|
||||
try:
|
||||
with _get_app_context_manager():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log mcp_message event: %s", log_error)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
agent_id,
|
||||
user_id,
|
||||
context.method,
|
||||
)
|
||||
return await call_next(context)
|
||||
return await call_next(context)
|
||||
finally:
|
||||
# See the matching comment in on_call_tool: g.user/get_user_id()
|
||||
# are stale here because the per-call app context has already
|
||||
# been popped. _mcp_user_id_var survives it.
|
||||
resolved_user_id = _mcp_user_id_var.get(None)
|
||||
if resolved_user_id is not None:
|
||||
user_id = resolved_user_id
|
||||
# See the matching reset in on_call_tool.
|
||||
_mcp_user_id_var.set(None)
|
||||
try:
|
||||
with _get_app_context_manager():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log mcp_message event: %s", log_error)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
agent_id,
|
||||
user_id,
|
||||
context.method,
|
||||
)
|
||||
|
||||
|
||||
class StructuredContentStripperMiddleware(Middleware):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
# 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.
|
||||
"""Enforce one OAuth2 token per (user_id, database_id).
|
||||
|
||||
`OAuth2StoreTokenCommand` always deletes any existing token for a
|
||||
user+database pair before storing a new one, so the table is only ever
|
||||
meant to carry a single live row per pair -- but that invariant was only
|
||||
enforced in application code, via a plain (non-unique) lookup index. A
|
||||
race between two concurrent OAuth2 callbacks for the same user+database
|
||||
can leave duplicate rows behind, and nothing downstream picks a
|
||||
deterministic one of them. Flagged as a follow-up during review of #42211
|
||||
(which fixed an unrelated `purge_oauth2_tokens` filter bug on this same
|
||||
table).
|
||||
|
||||
Pre-flight: deletes any pre-existing duplicate rows, keeping the
|
||||
highest-id row per (user_id, database_id) pair, since
|
||||
`OAuth2StoreTokenCommand` always deletes-then-inserts and a higher id is
|
||||
therefore the more recently issued token.
|
||||
|
||||
Revision ID: da0e3f0081bf
|
||||
Revises: b8d2f4a6c901
|
||||
Create Date: 2026-08-07 09:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from superset.migrations.shared.utils import create_index, drop_index
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "da0e3f0081bf"
|
||||
down_revision: str = "b8d2f4a6c901"
|
||||
|
||||
TABLE_NAME = "database_user_oauth2_tokens"
|
||||
INDEX_NAME = "idx_user_id_database_id"
|
||||
# Temporary name for the unique index while it and the old non-unique index
|
||||
# briefly coexist -- see the comment in ``upgrade`` below.
|
||||
TMP_INDEX_NAME = "idx_user_id_database_id_tmp_unique"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
metadata = sa.MetaData()
|
||||
table = sa.Table(TABLE_NAME, metadata, autoload_with=bind)
|
||||
|
||||
# Find the highest id per (user_id, database_id) pair by reading into
|
||||
# Python first, rather than deleting via a subquery on the same table:
|
||||
# MySQL rejects a DELETE whose WHERE clause subqueries the target table
|
||||
# (error 1093).
|
||||
rows = bind.execute(
|
||||
sa.select(table.c.id, table.c.user_id, table.c.database_id)
|
||||
).fetchall()
|
||||
max_id_by_pair: dict[tuple[int, int], int] = {}
|
||||
for row in rows:
|
||||
pair = (row.user_id, row.database_id)
|
||||
if row.id > max_id_by_pair.get(pair, -1):
|
||||
max_id_by_pair[pair] = row.id
|
||||
keep_ids = set(max_id_by_pair.values())
|
||||
dupe_ids = [row.id for row in rows if row.id not in keep_ids]
|
||||
if dupe_ids:
|
||||
bind.execute(table.delete().where(table.c.id.in_(dupe_ids)))
|
||||
|
||||
# MySQL's InnoDB won't drop an index that's still needed to satisfy a
|
||||
# foreign key (error 1553) -- the existing (user_id, database_id) index
|
||||
# is the only one covering the `user_id` FK. Create the new unique
|
||||
# index (which covers the same leading column) before dropping the old
|
||||
# one, so an FK-satisfying index always exists, then swap it into its
|
||||
# final name.
|
||||
create_index(TABLE_NAME, TMP_INDEX_NAME, ["user_id", "database_id"], unique=True)
|
||||
drop_index(TABLE_NAME, INDEX_NAME)
|
||||
create_index(TABLE_NAME, INDEX_NAME, ["user_id", "database_id"], unique=True)
|
||||
drop_index(TABLE_NAME, TMP_INDEX_NAME)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The pre-flight dedupe above is not reversible -- any rows it removed
|
||||
# stay removed -- but that only ever discards rows that violated the
|
||||
# single-token-per-pair invariant the application already assumed.
|
||||
create_index(TABLE_NAME, TMP_INDEX_NAME, ["user_id", "database_id"])
|
||||
drop_index(TABLE_NAME, INDEX_NAME)
|
||||
create_index(TABLE_NAME, INDEX_NAME, ["user_id", "database_id"])
|
||||
drop_index(TABLE_NAME, TMP_INDEX_NAME)
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
"""merge oauth2 token uniqueness with report_schedule include_cta
|
||||
|
||||
Revision ID: 1072de5ed955
|
||||
Revises: ('da0e3f0081bf', '2d6ad72e4af6')
|
||||
Create Date: 2026-08-15 01:39:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1072de5ed955"
|
||||
down_revision = ("da0e3f0081bf", "2d6ad72e4af6")
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -1607,7 +1607,9 @@ class DatabaseUserOAuth2Tokens(Model, AuditMixinNullable):
|
||||
"""
|
||||
|
||||
__tablename__ = "database_user_oauth2_tokens"
|
||||
__table_args__ = (sqla.Index("idx_user_id_database_id", "user_id", "database_id"),)
|
||||
__table_args__ = (
|
||||
sqla.Index("idx_user_id_database_id", "user_id", "database_id", unique=True),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1081,19 +1081,15 @@ msgstr "A-Z"
|
||||
msgid "AND"
|
||||
msgstr "Y"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API Key Created"
|
||||
msgstr "se ha creado"
|
||||
msgstr "Clave API creada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API Keys"
|
||||
msgstr "Clave privada"
|
||||
msgstr "Claves API"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API key created successfully"
|
||||
msgstr "El informe se ha creado"
|
||||
msgstr "Clave API creada correctamente"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API key name is required"
|
||||
msgstr "El nombre de la clave API es obligatorio"
|
||||
|
||||
@@ -1173,9 +1169,9 @@ msgstr "Formato adaptativo"
|
||||
msgid "Add"
|
||||
msgstr "Añadir"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Add %s view(s)"
|
||||
msgstr "%s opción(es)"
|
||||
msgstr "Añadir %s vista(s)"
|
||||
|
||||
msgid "Add BCC Recipients"
|
||||
msgstr "Añadir destinatarios CCO"
|
||||
@@ -1214,9 +1210,8 @@ msgstr "Añadir rol"
|
||||
msgid "Add Rule"
|
||||
msgstr "Añadir regla"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add Semantic View"
|
||||
msgstr "Añadir un elemento"
|
||||
msgstr "Añadir vista semántica"
|
||||
|
||||
msgid "Add Tag"
|
||||
msgstr "Añadir etiqueta"
|
||||
@@ -1793,9 +1788,8 @@ msgstr "Se ha producido un error al acceder al valor."
|
||||
msgid "An error occurred while accessing the value."
|
||||
msgstr "Se ha producido un error al acceder al valor."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while adding semantic views"
|
||||
msgstr "Se ha producido un error al cargar el SQL"
|
||||
msgstr "Se ha producido un error al añadir las vistas semánticas"
|
||||
|
||||
msgid ""
|
||||
"An error occurred while collapsing the table schema. Please contact your "
|
||||
@@ -1818,9 +1812,8 @@ msgstr "Se ha producido un error al crear la fuente de datos"
|
||||
msgid "An error occurred while creating the extension."
|
||||
msgstr "Se ha producido un error al crear el valor."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while creating the semantic layer"
|
||||
msgstr "Se ha producido un error al crear el valor."
|
||||
msgstr "Se ha producido un error al crear la capa semántica"
|
||||
|
||||
msgid "An error occurred while creating the value."
|
||||
msgstr "Se ha producido un error al crear el valor."
|
||||
@@ -1870,9 +1863,8 @@ msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
msgid "An error occurred while fetching available themes"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching available views"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
msgstr "Se ha producido un error al recuperar las vistas disponibles"
|
||||
|
||||
#, python-format
|
||||
msgid "An error occurred while fetching chart editor values: %s"
|
||||
@@ -1956,9 +1948,8 @@ msgstr ""
|
||||
msgid "An error occurred while fetching schema values: %s"
|
||||
msgstr "Se ha producido un error al recuperar los valores del esquema: %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
msgstr "Se ha producido un error al recuperar los tipos de capa semántica"
|
||||
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Se ha producido un error al recuperar las capas semánticas"
|
||||
@@ -1970,21 +1961,18 @@ msgstr "Se ha producido un error al recuperar el estado de la pestaña"
|
||||
msgid "An error occurred while fetching table metadata for %s"
|
||||
msgstr "Se ha producido un error al recuperar los metadatos de la tabla de %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the configuration schema"
|
||||
msgstr "Se ha producido un error al recuperar los nombres de las funciones."
|
||||
msgstr "Se ha producido un error al recuperar el esquema de configuración"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the runtime schema"
|
||||
msgstr "Se ha producido un error al recuperar los nombres de las funciones."
|
||||
msgstr "Se ha producido un error al recuperar el esquema de ejecución"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the semantic layer"
|
||||
msgstr "Se ha producido un error al recuperar el estado de la pestaña"
|
||||
msgstr "Se ha producido un error al recuperar la capa semántica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the semantic view structure"
|
||||
msgstr "Se ha producido un error al recuperar el estado de la pestaña"
|
||||
msgstr ""
|
||||
"Se ha producido un error al recuperar la estructura de la vista semántica"
|
||||
|
||||
#, fuzzy, python-format
|
||||
msgid "An error occurred while fetching theme datasource values: %s"
|
||||
@@ -2057,9 +2045,8 @@ msgstr ""
|
||||
msgid "An error occurred while rendering the visualization: %s"
|
||||
msgstr "Se ha producido un error al renderizar la visualización: %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while saving the semantic view"
|
||||
msgstr "Se ha producido un error al cargar el SQL"
|
||||
msgstr "Se ha producido un error al guardar la vista semántica"
|
||||
|
||||
msgid "An error occurred while starring this chart"
|
||||
msgstr "Se ha producido un error al destacar este gráfico"
|
||||
@@ -2084,9 +2071,8 @@ msgstr ""
|
||||
msgid "An error occurred while updating the extension."
|
||||
msgstr "Se ha producido un error al actualizar el valor."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while updating the semantic layer"
|
||||
msgstr "Se ha producido un error al actualizar el valor."
|
||||
msgstr "Se ha producido un error al actualizar la capa semántica"
|
||||
|
||||
msgid "An error occurred while updating the value."
|
||||
msgstr "Se ha producido un error al actualizar el valor."
|
||||
@@ -2454,11 +2440,11 @@ msgstr ""
|
||||
"¿Está seguro de que desea eliminar el tema predeterminado del sistema? La"
|
||||
" aplicación volverá al predeterminado del archivo de configuración."
|
||||
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Are you sure you want to revoke this API key? This action cannot be "
|
||||
"undone."
|
||||
msgstr "¿Seguro que quieres eliminar las anotaciones seleccionadas?"
|
||||
msgstr ""
|
||||
"¿Seguro que quieres revocar esta clave API? Esta acción no se puede deshacer."
|
||||
|
||||
msgid "Are you sure you want to save and apply changes?"
|
||||
msgstr "¿Seguro que quieres guardar y aplicar los cambios?"
|
||||
@@ -3322,13 +3308,11 @@ msgstr "No se permite cambiar esta fuente de datos"
|
||||
msgid "Changing this report is forbidden"
|
||||
msgstr "No se permite cambiar este informe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Changing this semantic layer is forbidden"
|
||||
msgstr "No se permite cambiar este gráfico"
|
||||
msgstr "No se permite cambiar esta capa semántica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Changing this semantic view is forbidden"
|
||||
msgstr "No se permite cambiar este conjunto de datos"
|
||||
msgstr "No se permite cambiar esta vista semántica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Changing this task is forbidden"
|
||||
@@ -3791,9 +3775,8 @@ msgstr "Haz clic para ordenar de forma ascendente"
|
||||
msgid "Click to sort descending"
|
||||
msgstr "Haz clic para ordenar de forma descendente"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Client ID"
|
||||
msgstr "Anchura de la línea"
|
||||
msgstr "ID de cliente"
|
||||
|
||||
msgid "Client Secret"
|
||||
msgstr "Secreto de cliente"
|
||||
@@ -4456,9 +4439,8 @@ msgstr "Mapa del país"
|
||||
msgid "Create"
|
||||
msgstr "Crear"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Create API Key"
|
||||
msgstr "Creado por"
|
||||
msgstr "Crear clave API"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Create Tag"
|
||||
@@ -4804,9 +4786,8 @@ msgstr "No se ha encontrado el panel de control %(dashboard_id)s"
|
||||
msgid "Dashboard Filter"
|
||||
msgstr "Titulo del panel de control"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Dashboard Id"
|
||||
msgstr "panel de control"
|
||||
msgstr "ID del panel de control"
|
||||
|
||||
#, python-format
|
||||
msgid "Dashboard [%s] just got created and chart [%s] was added to it"
|
||||
@@ -5436,9 +5417,8 @@ msgstr ""
|
||||
"Define si el paso debe aparecer al principio, en el medio o al final "
|
||||
"entre dos puntos de datos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Definition"
|
||||
msgstr "desviación"
|
||||
msgstr "Definición"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, ja, sr,
|
||||
# sr_Latn]
|
||||
@@ -5478,13 +5458,11 @@ msgstr "¿Eliminar el informe?"
|
||||
msgid "Delete Role?"
|
||||
msgstr "¿Eliminar el rol?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Semantic Layer?"
|
||||
msgstr "¿Eliminar la capa?"
|
||||
msgstr "¿Eliminar la capa semántica?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Semantic View?"
|
||||
msgstr "¿Eliminar la plantilla?"
|
||||
msgstr "¿Eliminar la vista semántica?"
|
||||
|
||||
msgid "Delete Template?"
|
||||
msgstr "¿Eliminar la plantilla?"
|
||||
@@ -5978,9 +5956,8 @@ msgstr "Dominio"
|
||||
msgid "Don't refresh"
|
||||
msgstr "Datos actualizados"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Done"
|
||||
msgstr "Ninguno"
|
||||
msgstr "Listo"
|
||||
|
||||
msgid "Donut"
|
||||
msgstr "Dónut"
|
||||
@@ -6211,9 +6188,8 @@ msgstr "Duración en ms (10500 => 0:00:10.5)"
|
||||
msgid "Duration in ms (66000 => 1m 6s)"
|
||||
msgstr "Duración en ms (66 000 => 1 m 6 s)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Duration in seconds"
|
||||
msgstr "Introduce la duración en segundos"
|
||||
msgstr "Duración en segundos"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ru]
|
||||
#, fuzzy
|
||||
@@ -6954,9 +6930,8 @@ msgstr ""
|
||||
msgid "Experimental"
|
||||
msgstr "Experimental"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Expired"
|
||||
msgstr "Explorar"
|
||||
msgstr "Caducado"
|
||||
|
||||
msgid "Explore"
|
||||
msgstr "Explorar"
|
||||
@@ -7079,9 +7054,8 @@ msgstr "Exponer base de datos en SQL Lab"
|
||||
msgid "Expose in SQL Lab"
|
||||
msgstr "Exponer en SQL Lab"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Expression"
|
||||
msgstr "Expresión SQL"
|
||||
msgstr "Expresión"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Expression cannot be empty"
|
||||
@@ -7171,17 +7145,15 @@ msgstr "No se han podido recuperar los resultados"
|
||||
msgid "Failed to apply theme: Invalid JSON"
|
||||
msgstr "Error al aplicar el tema: JSON no válido"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to copy API key to clipboard"
|
||||
msgstr "Copiar consulta de partición al portapapeles"
|
||||
msgstr "No se ha podido copiar la clave API al portapapeles"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to copy stack trace to clipboard"
|
||||
msgstr "Copiar al portapapeles"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to create API key"
|
||||
msgstr "No se ha podido generar el informe"
|
||||
msgstr "No se ha podido crear la clave API"
|
||||
|
||||
msgid "Failed to create report"
|
||||
msgstr "No se ha podido generar el informe"
|
||||
@@ -7207,9 +7179,8 @@ msgid ""
|
||||
"administrator."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to fetch API keys"
|
||||
msgstr "No se han podido etiquetar los elementos"
|
||||
msgstr "No se han podido recuperar las claves API"
|
||||
|
||||
msgid "Failed to generate chart edit URL"
|
||||
msgstr "No se ha podido generar la URL de edición del gráfico"
|
||||
@@ -7257,9 +7228,8 @@ msgstr ""
|
||||
msgid "Failed to retrieve advanced type"
|
||||
msgstr "No se ha podido recuperar el tipo avanzado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to revoke API key"
|
||||
msgstr "No se ha podido detener la consulta."
|
||||
msgstr "No se ha podido revocar la clave API"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to save chart customization"
|
||||
@@ -7866,9 +7836,8 @@ msgstr "Nombre y URL de la hoja de cálculo de Google"
|
||||
msgid "Grace period"
|
||||
msgstr "Periodo de gracia"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grain"
|
||||
msgstr "Granularidad temporal"
|
||||
msgstr "Granularidad"
|
||||
|
||||
msgid "Graph Chart"
|
||||
msgstr "Gráfico"
|
||||
@@ -8230,9 +8199,8 @@ msgstr ""
|
||||
msgid "In this view you can preview the first 25 rows. "
|
||||
msgstr "En esta vista puede obtener una vista previa de las primeras 25 filas. "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Inactive"
|
||||
msgstr "Activo"
|
||||
msgstr "Inactivo"
|
||||
|
||||
msgid "Include Series"
|
||||
msgstr "Incluir series"
|
||||
@@ -8654,9 +8622,8 @@ msgstr "Continuar editando"
|
||||
msgid "Key"
|
||||
msgstr "Clave"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Key Prefix"
|
||||
msgstr "Prefijo"
|
||||
msgstr "Prefijo de la clave"
|
||||
|
||||
msgid "Keyboard shortcuts"
|
||||
msgstr "Accesos rápidos de teclado"
|
||||
@@ -8777,9 +8744,8 @@ msgstr "Última actualización %s"
|
||||
msgid "Last Updated %s by %s"
|
||||
msgstr "Última actualización %s por %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Last Used"
|
||||
msgstr "Listar usuarios"
|
||||
msgstr "Último uso"
|
||||
|
||||
#, python-format
|
||||
msgid "Last available value seen on %s"
|
||||
@@ -9292,9 +9258,8 @@ msgstr ""
|
||||
msgid "Mapbox"
|
||||
msgstr "Mapbox"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Mapbox (API key required)"
|
||||
msgstr "El correo electrónico es obligatorio"
|
||||
msgstr "Mapbox (requiere clave API)"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -9861,9 +9826,8 @@ msgstr "Nombre de la columna que contiene el ID del nodo primario"
|
||||
msgid "Name of the id column"
|
||||
msgstr "Nombre de la columna de ID"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Name of the semantic layer"
|
||||
msgstr "Nombre de la columna de ID"
|
||||
msgstr "Nombre de la capa semántica"
|
||||
|
||||
msgid "Name of the source nodes"
|
||||
msgstr "Nombre de los nodos de origen"
|
||||
@@ -9914,9 +9878,9 @@ msgstr "Error de red."
|
||||
msgid "New"
|
||||
msgstr "Ahora"
|
||||
|
||||
#, -ERR:PROP-NOT-FOUND-, fuzzy
|
||||
#, -ERR:PROP-NOT-FOUND-
|
||||
msgid "New Semantic Layer"
|
||||
msgstr "No hay capas de anotación"
|
||||
msgstr "Nueva capa semántica"
|
||||
|
||||
msgid "New chart"
|
||||
msgstr "Nuevo gráfico"
|
||||
@@ -11994,13 +11958,11 @@ msgstr "Lat. y long. inversas"
|
||||
msgid "Reverse lat/long "
|
||||
msgstr "Lat./long. inversa "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Revoke"
|
||||
msgstr "Eliminar"
|
||||
msgstr "Revocar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Revoke API Key"
|
||||
msgstr "Clave de grupo"
|
||||
msgstr "Revocar clave API"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, ja, sr,
|
||||
# sr_Latn]
|
||||
@@ -12008,9 +11970,8 @@ msgstr "Clave de grupo"
|
||||
msgid "Revoke this API key"
|
||||
msgstr "Revocar esta clave API"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Revoked"
|
||||
msgstr "Trazado"
|
||||
msgstr "Revocado"
|
||||
|
||||
msgid "Rich Tooltip"
|
||||
msgstr "Información sobre herramientas mejorada"
|
||||
@@ -12728,13 +12689,11 @@ msgstr "Selecciona un esquema"
|
||||
msgid "Select a schema if the database supports this"
|
||||
msgstr "Selecciona un esquema si la base de datos lo admite"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select a semantic layer"
|
||||
msgstr "Selecciona un esquema"
|
||||
msgstr "Selecciona una capa semántica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select a semantic layer type"
|
||||
msgstr "Seleccionar tipo de visualización"
|
||||
msgstr "Selecciona un tipo de capa semántica"
|
||||
|
||||
msgid "Select a sheet name from the uploaded file"
|
||||
msgstr "Seleccione un nombre de hoja del archivo subido"
|
||||
@@ -12972,9 +12931,8 @@ msgstr "Selecciona un esquema"
|
||||
msgid "Select scheme"
|
||||
msgstr "Seleccionar esquema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select semantic views"
|
||||
msgstr "Seleccionar esquema"
|
||||
msgstr "Selecciona las vistas semánticas"
|
||||
|
||||
msgid ""
|
||||
"Select shape for computing values. \"FIXED\" sets all zoom levels to the "
|
||||
@@ -13129,13 +13087,11 @@ msgstr "La selección de una base de datos es obligatoria"
|
||||
msgid "Selection method"
|
||||
msgstr "Selecciona el método de entrega"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic"
|
||||
msgstr "Correo electrónico"
|
||||
msgstr "Semántico"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic Layer"
|
||||
msgstr "Capa de anotación"
|
||||
msgstr "Capa semántica"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -13149,61 +13105,47 @@ msgstr "Vista semántica"
|
||||
msgid "Semantic Views"
|
||||
msgstr "Vistas semánticas"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer"
|
||||
msgstr "Capa de anotación"
|
||||
msgstr "Capa semántica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer could not be created."
|
||||
msgstr "No se ha podido crear la capa de anotación."
|
||||
msgstr "No se ha podido crear la capa semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer could not be deleted."
|
||||
msgstr "No se han podido eliminar las capas de anotación."
|
||||
msgstr "No se ha podido eliminar la capa semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer could not be updated."
|
||||
msgstr "No se ha podido actualizar la capa de anotación."
|
||||
msgstr "No se ha podido actualizar la capa semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer created"
|
||||
msgstr "Se ha creado la plantilla de anotación"
|
||||
msgstr "Capa semántica creada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer does not exist"
|
||||
msgstr "El gráfico no existe"
|
||||
msgstr "La capa semántica no existe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer parameters are invalid."
|
||||
msgstr "Los parámetros de la capa de anotación no son válidos."
|
||||
msgstr "Los parámetros de la capa semántica no son válidos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer type"
|
||||
msgstr "Tipo de la capa de anotación"
|
||||
msgstr "Tipo de capa semántica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer updated"
|
||||
msgstr "Se ha actualizado la plantilla de anotación"
|
||||
msgstr "Capa semántica actualizada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view could not be created."
|
||||
msgstr "No se ha podido crear el conjunto de datos."
|
||||
msgstr "No se ha podido crear la vista semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view could not be deleted."
|
||||
msgstr "No se han podido eliminar las plantillas CSS."
|
||||
msgstr "No se ha podido eliminar la vista semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view could not be updated."
|
||||
msgstr "No se ha podido actualizar el conjunto de datos."
|
||||
msgstr "No se ha podido actualizar la vista semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view does not exist"
|
||||
msgstr "El conjunto de datos no existe"
|
||||
msgstr "La vista semántica no existe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view parameters are invalid."
|
||||
msgstr "Los parámetros del conjunto de datos no son válidos."
|
||||
msgstr "Los parámetros de la vista semántica no son válidos."
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -13772,9 +13714,8 @@ msgstr "Omitir espacios después del delimitador"
|
||||
msgid "Skipped %d system themes that cannot be deleted"
|
||||
msgstr "Se omitieron %d temas del sistema que no se pueden eliminar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Slice Id"
|
||||
msgstr "Anchura de la línea"
|
||||
msgstr "ID del gráfico"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Slider"
|
||||
@@ -14017,9 +13958,8 @@ msgstr "Fuente SQL"
|
||||
msgid "Source category"
|
||||
msgstr "Categoría de fuente"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Source location"
|
||||
msgstr "Categoría de fuente"
|
||||
msgstr "Ubicación de origen"
|
||||
|
||||
msgid "Sparkline"
|
||||
msgstr "Minigráfica"
|
||||
@@ -18866,9 +18806,8 @@ msgstr "No tienes acceso a este conjunto de datos."
|
||||
msgid "You don't have access to this embedded dashboard config."
|
||||
msgstr "No tienes acceso a la configuración de este panel de control incrustado."
|
||||
|
||||
#, fuzzy
|
||||
msgid "You don't have access to this semantic view."
|
||||
msgstr "No tienes acceso a este conjunto de datos."
|
||||
msgstr "No tienes acceso a esta vista semántica."
|
||||
|
||||
#, fuzzy
|
||||
msgid "You don't have permission to copy to clipboard"
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -21,6 +21,7 @@ import pandas as pd
|
||||
import pytest
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError, TemplateSyntaxError
|
||||
|
||||
from superset import db, sql_lab
|
||||
from superset.commands.sql_lab import estimate, export, results
|
||||
@@ -405,6 +406,29 @@ class TestSqlExecutionResultsCommand(SupersetTestCase):
|
||||
)
|
||||
assert ex_info.value.status == 403
|
||||
|
||||
@pytest.mark.usefixtures("create_database_and_query")
|
||||
@patch("superset.commands.sql_lab.results.results_backend_use_msgpack", False)
|
||||
def test_validation_malformed_jinja(self) -> None:
|
||||
# ``raise_for_access`` re-parses the query's unrendered Jinja via
|
||||
# ``process_jinja_sql`` and can raise a raw ``TemplateError`` (e.g. an
|
||||
# unclosed ``{% if %}``). ``TemplateSyntaxError`` is a subclass of
|
||||
# ``TemplateError``. It must surface as a 400, not an opaque 500.
|
||||
assert issubclass(TemplateSyntaxError, TemplateError)
|
||||
|
||||
command = results.SqlExecutionResultsCommand("abc_query", 1000)
|
||||
|
||||
with mock.patch(
|
||||
"superset.models.sql_lab.Query.raise_for_access",
|
||||
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
|
||||
):
|
||||
with pytest.raises(SupersetErrorException) as ex_info:
|
||||
command.run()
|
||||
assert (
|
||||
ex_info.value.error.error_type
|
||||
== SupersetErrorType.GENERIC_COMMAND_ERROR
|
||||
)
|
||||
assert ex_info.value.status == 400
|
||||
|
||||
@pytest.mark.usefixtures("create_database_and_query")
|
||||
@patch("superset.commands.sql_lab.results.results_backend_use_msgpack", False)
|
||||
def test_run_succeeds(self) -> None:
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
import copy
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from flask_appbuilder.security.sqla.models import Role, User
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from superset import security_manager
|
||||
@@ -506,3 +508,71 @@ def test_import_tag_logic_for_charts(session_with_schema: Session):
|
||||
.all()
|
||||
)
|
||||
assert len(associated_tags) == 0
|
||||
|
||||
|
||||
def test_import_tag_savepoint_keeps_session_usable(
|
||||
mocker: MockerFixture, session_with_schema: Session
|
||||
) -> None:
|
||||
"""
|
||||
When a single tag operation fails with a SQLAlchemyError (e.g. a unique
|
||||
constraint violation from a concurrent import), the per-tag SAVEPOINT
|
||||
isolates the failure so the session is not left in a pending-rollback
|
||||
state, and the remaining tags still import successfully.
|
||||
"""
|
||||
contents = {
|
||||
"tags.yaml": yaml.dump(
|
||||
{
|
||||
"tags": [
|
||||
{"tag_name": "tag_1", "description": "Description for tag_1"},
|
||||
{"tag_name": "tag_2", "description": "Description for tag_2"},
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
object_id = 1
|
||||
object_type = "chart"
|
||||
|
||||
# Simulate a unique-constraint violation discovered when the first
|
||||
# TaggedObject's SAVEPOINT is flushed (e.g. a concurrent import already
|
||||
# created the same association) -- not synchronously from Session.add().
|
||||
# `import_tag`'s own pre-insert existence check would normally catch a
|
||||
# real duplicate row, so the failure is injected at the point SQLAlchemy
|
||||
# actually persists the pending row: `begin_nested()`'s implicit flush
|
||||
# on a successful `with` exit, which calls `Session.flush()` directly
|
||||
# (see `SessionTransaction._prepare_impl`).
|
||||
pending_tagged_objects: list[TaggedObject] = []
|
||||
original_add = session_with_schema.add
|
||||
|
||||
def tracking_add(obj: object) -> None:
|
||||
if isinstance(obj, TaggedObject):
|
||||
pending_tagged_objects.append(obj)
|
||||
original_add(obj)
|
||||
|
||||
original_flush = session_with_schema.flush
|
||||
|
||||
def flaky_flush(*args: Any, **kwargs: Any) -> None:
|
||||
# Only the first TaggedObject ever added should fail, and only while
|
||||
# it's still pending -- once its SAVEPOINT rolls back, SQLAlchemy
|
||||
# expunges it from the session, so this does not also fail tag_2's
|
||||
# flush.
|
||||
if (
|
||||
pending_tagged_objects
|
||||
and pending_tagged_objects[0] is not None
|
||||
and pending_tagged_objects[0] in session_with_schema.new
|
||||
):
|
||||
raise SQLAlchemyError("UNIQUE constraint failed: tagged_object")
|
||||
original_flush(*args, **kwargs)
|
||||
|
||||
mocker.patch.object(session_with_schema, "add", side_effect=tracking_add)
|
||||
mocker.patch.object(session_with_schema, "flush", side_effect=flaky_flush)
|
||||
|
||||
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
|
||||
new_tag_ids = import_tag(
|
||||
["tag_1", "tag_2"], contents, object_id, object_type, session_with_schema
|
||||
)
|
||||
|
||||
# tag_1 failed on the unique violation, but tag_2 succeeded.
|
||||
assert len(new_tag_ids) == 1
|
||||
# The session is still usable — no PendingRollbackError.
|
||||
assert session_with_schema.query(TaggedObject).count() == 1
|
||||
|
||||
@@ -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"
|
||||
@@ -475,7 +475,8 @@ class _DetachableSlice:
|
||||
|
||||
async def _generate_saved_chart(
|
||||
refetch: Any,
|
||||
) -> tuple[Any, _DetachableSlice]:
|
||||
compile_result: CompileResult | None = None,
|
||||
) -> tuple[Any, _DetachableSlice, Mock]:
|
||||
"""Run generate_chart(save_chart=True) with a chart that detaches on commit.
|
||||
|
||||
``refetch`` is used as the ``ChartDAO.find_by_id`` behaviour of the
|
||||
@@ -503,6 +504,7 @@ async def _generate_saved_chart(
|
||||
# The instance is detached right after the commit, before any of the reads
|
||||
# that build the response.
|
||||
session.refresh.side_effect = lambda _chart: chart.detach()
|
||||
create_command = Mock(return_value=Mock(run=Mock(return_value=chart)))
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -524,12 +526,12 @@ async def _generate_saved_chart(
|
||||
patch("superset.mcp_service.auth.has_dataset_access", return_value=True),
|
||||
patch(
|
||||
"superset.commands.chart.create.CreateChartCommand",
|
||||
return_value=Mock(run=Mock(return_value=chart)),
|
||||
create_command,
|
||||
),
|
||||
patch("superset.db.session", session),
|
||||
patch(
|
||||
"superset.mcp_service.chart.tool.generate_chart._compile_chart",
|
||||
return_value=CompileResult(success=True, warnings=[]),
|
||||
return_value=compile_result or CompileResult(success=True, warnings=[]),
|
||||
),
|
||||
patch("superset.daos.chart.ChartDAO", Mock(find_by_id=refetch)),
|
||||
patch(
|
||||
@@ -543,7 +545,7 @@ async def _generate_saved_chart(
|
||||
):
|
||||
result = await generate_chart(request, ctx=ctx)
|
||||
|
||||
return result, chart
|
||||
return result, chart, create_command
|
||||
|
||||
|
||||
class TestGenerateChartDetachedInstance:
|
||||
@@ -560,7 +562,7 @@ class TestGenerateChartDetachedInstance:
|
||||
"""A detached instance no longer turns a committed chart into an error."""
|
||||
refetched = _make_mock_chart()
|
||||
|
||||
result, chart = await _generate_saved_chart(
|
||||
result, chart, _create_command = await _generate_saved_chart(
|
||||
refetch=Mock(return_value=refetched)
|
||||
)
|
||||
|
||||
@@ -575,7 +577,7 @@ class TestGenerateChartDetachedInstance:
|
||||
@pytest.mark.asyncio
|
||||
async def test_detached_chart_falls_back_to_captured_scalars(self) -> None:
|
||||
"""The minimal fallback response never reads the detached instance."""
|
||||
result, chart = await _generate_saved_chart(
|
||||
result, chart, _create_command = await _generate_saved_chart(
|
||||
refetch=Mock(side_effect=SQLAlchemyError("session is gone"))
|
||||
)
|
||||
|
||||
@@ -587,6 +589,25 @@ class TestGenerateChartDetachedInstance:
|
||||
assert result.chart.slice_name == "Concurrent chart"
|
||||
assert result.chart.viz_type == "table"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_failure_does_not_create_a_chart(self) -> None:
|
||||
result, chart, create_command = await _generate_saved_chart(
|
||||
refetch=Mock(),
|
||||
compile_result=CompileResult(
|
||||
success=False,
|
||||
error="column category must appear in GROUP BY",
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
tier="compile",
|
||||
warnings=["Database returned partial metadata"],
|
||||
),
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.chart is None
|
||||
assert result.warnings == ["Database returned partial metadata"]
|
||||
assert chart._detached is False
|
||||
create_command.assert_not_called()
|
||||
|
||||
|
||||
class TestChartSerializationEagerLoading:
|
||||
"""Tests for eager loading fix in generate_chart serialization path."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
@@ -419,3 +420,42 @@ async def test_list_charts_certified_filter(
|
||||
expected in actual
|
||||
for expected, actual in zip(expected_names, actual_names, strict=False)
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.daos.chart.ChartDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_charts_changed_on_delta_humanized_order_column(
|
||||
mock_list, mcp_server
|
||||
):
|
||||
"""Regression test: order_column='changed_on_delta_humanized' is the
|
||||
"Last modified" column name used by Superset's own REST API and list
|
||||
views. Production chatbot calls pass it when asked to sort by "most
|
||||
recently modified" and must not be rejected. It resolves to 'changed_on'
|
||||
for the DAO, matching REST API sort behaviour (see
|
||||
models/helpers.py:changed_on_delta_humanized, @renders("changed_on"))."""
|
||||
mock_list.return_value = ([], 0)
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"list_charts",
|
||||
{"request": {"order_column": "changed_on_delta_humanized"}},
|
||||
)
|
||||
mock_list.assert_called_once()
|
||||
call_args = mock_list.call_args[1]
|
||||
assert call_args["order_column"] == "changed_on"
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "charts" in data
|
||||
|
||||
|
||||
@patch("superset.daos.chart.ChartDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_charts_invalid_order_column_raises_tool_error(
|
||||
mock_list, mcp_server
|
||||
):
|
||||
"""A genuinely unknown order_column must still be rejected."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_charts", {"request": {"order_column": "random"}}
|
||||
)
|
||||
assert "Invalid order_column" in str(excinfo.value)
|
||||
mock_list.assert_not_called()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1457,22 +1457,22 @@ class TestDashboardSortableColumns:
|
||||
|
||||
def test_dashboard_sortable_columns_definition(self):
|
||||
"""Test that dashboard sortable columns are properly defined."""
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import (
|
||||
SORTABLE_DASHBOARD_COLUMNS,
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
)
|
||||
|
||||
assert SORTABLE_DASHBOARD_COLUMNS == [
|
||||
assert DASHBOARD_SORTABLE_COLUMNS == [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
# Ensure no computed properties are included
|
||||
assert "changed_on_delta_humanized" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
assert "changed_by_name" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
assert "uuid" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
# Ensure unsupported computed properties are excluded
|
||||
assert "changed_by_name" not in DASHBOARD_SORTABLE_COLUMNS
|
||||
assert "uuid" not in DASHBOARD_SORTABLE_COLUMNS
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
@@ -1501,18 +1501,59 @@ class TestDashboardSortableColumns:
|
||||
|
||||
def test_sortable_columns_in_docstring(self):
|
||||
"""Test that sortable columns are documented in tool docstring."""
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import (
|
||||
list_dashboards,
|
||||
SORTABLE_DASHBOARD_COLUMNS,
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
)
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import list_dashboards
|
||||
|
||||
# Check list_dashboards docstring for sortable columns documentation
|
||||
assert list_dashboards.__doc__ is not None
|
||||
assert "Sortable columns for" in list_dashboards.__doc__
|
||||
assert "order_column" in list_dashboards.__doc__
|
||||
for col in SORTABLE_DASHBOARD_COLUMNS:
|
||||
for col in DASHBOARD_SORTABLE_COLUMNS:
|
||||
assert col in list_dashboards.__doc__
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_changed_on_delta_humanized_order_column(
|
||||
self, mock_list, mcp_server
|
||||
):
|
||||
"""Regression test: order_column='changed_on_delta_humanized' is the
|
||||
"Last modified" column name used by Superset's own REST API and list
|
||||
views. Production chatbot calls pass it when asked to sort dashboards
|
||||
by "most recently modified" and must not be rejected. It resolves to
|
||||
'changed_on' for the DAO, matching REST API sort behaviour (see
|
||||
dashboards/api.py's order_columns and
|
||||
models/helpers.py:changed_on_delta_humanized)."""
|
||||
mock_list.return_value = ([], 0)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDashboardsRequest(order_column="changed_on_delta_humanized")
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
mock_list.assert_called_once()
|
||||
call_args = mock_list.call_args[1]
|
||||
assert call_args["order_column"] == "changed_on"
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["dashboards"] == []
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_invalid_order_column_raises_tool_error(
|
||||
self, mock_list, mcp_server
|
||||
):
|
||||
"""A genuinely unknown order_column must still be rejected."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_dashboards", {"request": {"order_column": "random"}}
|
||||
)
|
||||
assert "Invalid order_column" in str(excinfo.value)
|
||||
mock_list.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1758,22 +1758,22 @@ class TestDatasetSortableColumns:
|
||||
|
||||
def test_dataset_sortable_columns_definition(self):
|
||||
"""Test that dataset sortable columns are properly defined."""
|
||||
from superset.mcp_service.dataset.tool.list_datasets import (
|
||||
SORTABLE_DATASET_COLUMNS,
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
)
|
||||
|
||||
assert SORTABLE_DATASET_COLUMNS == [
|
||||
assert DATASET_SORTABLE_COLUMNS == [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
# Ensure no computed properties are included
|
||||
assert "changed_on_delta_humanized" not in SORTABLE_DATASET_COLUMNS
|
||||
assert "changed_by_name" not in SORTABLE_DATASET_COLUMNS
|
||||
assert "database_name" not in SORTABLE_DATASET_COLUMNS
|
||||
assert "uuid" not in SORTABLE_DATASET_COLUMNS
|
||||
# Ensure unsupported computed properties are excluded
|
||||
assert "changed_by_name" not in DATASET_SORTABLE_COLUMNS
|
||||
assert "database_name" not in DATASET_SORTABLE_COLUMNS
|
||||
assert "uuid" not in DATASET_SORTABLE_COLUMNS
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
@@ -1805,18 +1805,59 @@ class TestDatasetSortableColumns:
|
||||
|
||||
def test_sortable_columns_in_docstring(self):
|
||||
"""Test that sortable columns are documented in tool docstring."""
|
||||
from superset.mcp_service.dataset.tool.list_datasets import (
|
||||
list_datasets,
|
||||
SORTABLE_DATASET_COLUMNS,
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
)
|
||||
from superset.mcp_service.dataset.tool.list_datasets import list_datasets
|
||||
|
||||
# Check list_datasets docstring for sortable columns documentation
|
||||
assert list_datasets.__doc__ is not None
|
||||
assert "Sortable columns for" in list_datasets.__doc__
|
||||
assert "order_column" in list_datasets.__doc__
|
||||
for col in SORTABLE_DATASET_COLUMNS:
|
||||
for col in DATASET_SORTABLE_COLUMNS:
|
||||
assert col in list_datasets.__doc__
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_datasets_changed_on_delta_humanized_order_column(
|
||||
self, mock_dataset_list, mcp_server
|
||||
):
|
||||
"""Regression test: order_column='changed_on_delta_humanized' is the
|
||||
"Last modified" column name used by Superset's own REST API and list
|
||||
views. Production chatbot calls pass it when asked to sort datasets
|
||||
by "most recently modified" and must not be rejected. It resolves to
|
||||
'changed_on' for the DAO, matching REST API sort behaviour (see
|
||||
daos/datasource.py's sort_col_map and
|
||||
models/helpers.py:changed_on_delta_humanized)."""
|
||||
mock_dataset_list.return_value = ([], 0)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDatasetsRequest(order_column="changed_on_delta_humanized")
|
||||
result = await client.call_tool(
|
||||
"list_datasets", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
mock_dataset_list.assert_called_once()
|
||||
call_args = mock_dataset_list.call_args[1]
|
||||
assert call_args["order_column"] == "changed_on"
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["datasets"] == []
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_datasets_invalid_order_column_raises_tool_error(
|
||||
self, mock_dataset_list, mcp_server
|
||||
):
|
||||
"""A genuinely unknown order_column must still be rejected."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_datasets", {"request": {"order_column": "random"}}
|
||||
)
|
||||
assert "Invalid order_column" in str(excinfo.value)
|
||||
mock_dataset_list.assert_not_called()
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_ordering(self, mock_dataset_list, mcp_server):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -276,9 +276,43 @@ def test_model_list_tool_rejects_private_order_column():
|
||||
tool.run_tool(order_column="created_by_fk")
|
||||
|
||||
|
||||
def test_model_list_tool_allows_order_column_when_sortable_columns_not_declared():
|
||||
"""When sortable_columns is not provided, order_column is passed through to the DAO
|
||||
without validation (backward-compatible behaviour)."""
|
||||
def test_model_list_tool_resolves_changed_on_delta_humanized_alias():
|
||||
"""order_column='changed_on_delta_humanized' is accepted when declared in
|
||||
sortable_columns and is translated to the real 'changed_on' column before
|
||||
reaching the DAO — the humanized value is a Python property (rendered via
|
||||
FAB's @renders("changed_on")), not a queryable SQLAlchemy column, so
|
||||
passing it straight through would break DAO.list()'s
|
||||
`getattr(model, order_column)` sort.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
class CapturingDAO:
|
||||
@classmethod
|
||||
def list(cls, order_column=None, **kwargs):
|
||||
captured["order_column"] = order_column
|
||||
return [], 0
|
||||
|
||||
tool = ModelListCore(
|
||||
dao_class=CapturingDAO,
|
||||
output_schema=DummyOutputSchema,
|
||||
item_serializer=dummy_serializer,
|
||||
filter_type=None,
|
||||
default_columns=["id", "name"],
|
||||
search_columns=["name"],
|
||||
list_field_name="items",
|
||||
output_list_schema=DummyListSchema,
|
||||
sortable_columns=["id", "name", "changed_on", "changed_on_delta_humanized"],
|
||||
)
|
||||
|
||||
# Should not raise, and the DAO must receive the real column name.
|
||||
tool.run_tool(order_column="changed_on_delta_humanized")
|
||||
|
||||
assert captured["order_column"] == "changed_on"
|
||||
|
||||
|
||||
def test_model_list_tool_rejects_unknown_order_column_even_with_alias_declared():
|
||||
"""A genuinely unknown order_column must still raise, even when the tool
|
||||
also declares the changed_on_delta_humanized alias as sortable."""
|
||||
tool = ModelListCore(
|
||||
dao_class=DummyDAO,
|
||||
output_schema=DummyOutputSchema,
|
||||
@@ -288,10 +322,39 @@ def test_model_list_tool_allows_order_column_when_sortable_columns_not_declared(
|
||||
search_columns=["name"],
|
||||
list_field_name="items",
|
||||
output_list_schema=DummyListSchema,
|
||||
sortable_columns=["id", "name", "changed_on", "changed_on_delta_humanized"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid order_column 'random'"):
|
||||
tool.run_tool(order_column="random")
|
||||
|
||||
|
||||
def test_model_list_tool_allows_order_column_when_sortable_columns_not_declared():
|
||||
"""When sortable_columns is not provided, order_column is passed through to the DAO
|
||||
without validation (backward-compatible behaviour)."""
|
||||
captured: dict = {}
|
||||
|
||||
class CapturingDAO:
|
||||
@classmethod
|
||||
def list(cls, order_column=None, **kwargs):
|
||||
captured["order_column"] = order_column
|
||||
return [], 0
|
||||
|
||||
tool = ModelListCore(
|
||||
dao_class=CapturingDAO,
|
||||
output_schema=DummyOutputSchema,
|
||||
item_serializer=dummy_serializer,
|
||||
filter_type=None,
|
||||
default_columns=["id", "name"],
|
||||
search_columns=["name"],
|
||||
list_field_name="items",
|
||||
output_list_schema=DummyListSchema,
|
||||
# sortable_columns intentionally omitted
|
||||
)
|
||||
# Should not raise even though "name" is not in the (empty) sortable list
|
||||
tool.run_tool(order_column="name")
|
||||
# The no-allowlist path preserves the order column without alias resolution.
|
||||
tool.run_tool(order_column="changed_on_delta_humanized")
|
||||
|
||||
assert captured["order_column"] == "changed_on_delta_humanized"
|
||||
|
||||
|
||||
def test_model_list_tool_injects_current_user_id_for_created_by_me():
|
||||
|
||||
@@ -606,6 +606,69 @@ def test_setup_user_context_allows_active_user(app) -> None:
|
||||
assert g.user is active_user
|
||||
|
||||
|
||||
# -- _mcp_user_id_var (ContextVar surviving the per-call app context pop) --
|
||||
#
|
||||
# g.user is only valid for the lifetime of the per-call app context that
|
||||
# _get_app_context_manager() pushes around tool execution; it's popped
|
||||
# before LoggingMiddleware's finally block runs, so get_user_id() there
|
||||
# always sees a stale/cleared g. _mcp_user_id_var is a plain ContextVar,
|
||||
# not tied to that app-context lifecycle, set here so it survives to be
|
||||
# read later for audit logging.
|
||||
|
||||
|
||||
def test_setup_user_context_sets_contextvar_for_active_user(app) -> None:
|
||||
"""_mcp_user_id_var carries the resolved user's id past this call."""
|
||||
from superset.mcp_service.auth import _mcp_user_id_var, _setup_user_context
|
||||
|
||||
active_user = _make_mock_user("active_user")
|
||||
active_user.is_active = True
|
||||
active_user.active = True
|
||||
active_user.id = 321
|
||||
|
||||
with app.test_request_context():
|
||||
with patch(
|
||||
"superset.mcp_service.auth.get_user_from_request",
|
||||
return_value=active_user,
|
||||
):
|
||||
_setup_user_context()
|
||||
assert _mcp_user_id_var.get() == 321
|
||||
|
||||
|
||||
def test_setup_user_context_clears_stale_contextvar_on_failure(app) -> None:
|
||||
"""A previous call's user_id must not leak into a call that fails to
|
||||
resolve a user (e.g. sequential calls sharing one asyncio task)."""
|
||||
from superset.mcp_service.auth import _mcp_user_id_var, _setup_user_context
|
||||
|
||||
with app.test_request_context():
|
||||
_mcp_user_id_var.set(999)
|
||||
with patch(
|
||||
"superset.mcp_service.auth.get_user_from_request",
|
||||
side_effect=ValueError("no user"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="no user"):
|
||||
_setup_user_context()
|
||||
assert _mcp_user_id_var.get() is None
|
||||
|
||||
|
||||
def test_setup_user_context_leaves_contextvar_unset_for_guest_user(app) -> None:
|
||||
"""GuestUser (embedded auth) has no numeric id -- the ContextVar must
|
||||
stay cleared rather than store a bogus value."""
|
||||
from superset.mcp_service.auth import _mcp_user_id_var, _setup_user_context
|
||||
|
||||
guest_user = _make_mock_user("guest_user")
|
||||
guest_user.is_active = True
|
||||
guest_user.active = True
|
||||
guest_user.id = None
|
||||
|
||||
with app.test_request_context():
|
||||
with patch(
|
||||
"superset.mcp_service.auth.get_user_from_request",
|
||||
return_value=guest_user,
|
||||
):
|
||||
_setup_user_context()
|
||||
assert _mcp_user_id_var.get() is None
|
||||
|
||||
|
||||
# -- Multi-issuer binding guard --
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -36,6 +36,7 @@ from fastmcp.tools.tool import ToolResult
|
||||
from mcp import types as mt
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from superset.mcp_service.auth import _mcp_user_id_var
|
||||
from superset.mcp_service.middleware import LoggingMiddleware
|
||||
|
||||
|
||||
@@ -417,6 +418,108 @@ class TestLoggingMiddlewareOnMessage:
|
||||
assert payload["error_type"] == "PermissionError"
|
||||
|
||||
|
||||
class TestLoggingMiddlewareUserIdContextVar:
|
||||
"""Tests for _mcp_user_id_var overriding the pre-call user_id snapshot.
|
||||
|
||||
_extract_context_info() runs *before* call_next(), i.e. before the
|
||||
tool's auth decorator (superset/mcp_service/auth.py) has resolved a
|
||||
user, so get_user_id() there is always pre-auth (typically None).
|
||||
_setup_user_context() sets _mcp_user_id_var while running inside
|
||||
call_next(), right before the per-call app context (which g.user
|
||||
depends on) is popped. These tests simulate that by setting the
|
||||
ContextVar from within call_next(), mirroring the real auth decorator.
|
||||
"""
|
||||
|
||||
@patch("superset.mcp_service.middleware.event_logger")
|
||||
@patch("superset.mcp_service.middleware.get_user_id", return_value=None)
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_call_tool_uses_contextvar_set_during_call_next(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
) -> None:
|
||||
"""Logged user_id reflects auth resolved during call_next(), not
|
||||
the pre-call snapshot."""
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="list_charts")
|
||||
|
||||
async def fake_call_next(_ctx):
|
||||
_mcp_user_id_var.set(42)
|
||||
return "tool_result"
|
||||
|
||||
token = _mcp_user_id_var.set(None)
|
||||
try:
|
||||
result = await middleware.on_call_tool(ctx, fake_call_next)
|
||||
finally:
|
||||
_mcp_user_id_var.reset(token)
|
||||
|
||||
assert result == "tool_result"
|
||||
call_kwargs = mock_event_logger.log.call_args[1]
|
||||
assert call_kwargs["user_id"] == 42
|
||||
|
||||
@patch("superset.mcp_service.middleware.event_logger")
|
||||
@patch("superset.mcp_service.middleware.get_user_id", return_value=7)
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_call_tool_falls_back_when_contextvar_unset(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
) -> None:
|
||||
"""When the call never runs _setup_user_context() (ContextVar
|
||||
stays at its default), the pre-call get_user_id() value is used
|
||||
as-is -- no regression for paths that don't set the ContextVar."""
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(name="health_check")
|
||||
call_next = AsyncMock(return_value="ok")
|
||||
|
||||
token = _mcp_user_id_var.set(None)
|
||||
try:
|
||||
await middleware.on_call_tool(ctx, call_next)
|
||||
finally:
|
||||
_mcp_user_id_var.reset(token)
|
||||
|
||||
call_kwargs = mock_event_logger.log.call_args[1]
|
||||
assert call_kwargs["user_id"] == 7
|
||||
|
||||
@patch("superset.mcp_service.middleware.event_logger")
|
||||
@patch("superset.mcp_service.middleware.get_user_id", return_value=None)
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_uses_contextvar_set_during_call_next(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
) -> None:
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(method="resources/read", name="instance/metadata")
|
||||
|
||||
async def fake_call_next(_ctx):
|
||||
_mcp_user_id_var.set(99)
|
||||
return "resource_data"
|
||||
|
||||
token = _mcp_user_id_var.set(None)
|
||||
try:
|
||||
result = await middleware.on_message(ctx, fake_call_next)
|
||||
finally:
|
||||
_mcp_user_id_var.reset(token)
|
||||
|
||||
assert result == "resource_data"
|
||||
call_kwargs = mock_event_logger.log.call_args[1]
|
||||
assert call_kwargs["user_id"] == 99
|
||||
|
||||
@patch("superset.mcp_service.middleware.event_logger")
|
||||
@patch("superset.mcp_service.middleware.get_user_id", return_value=13)
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_falls_back_when_contextvar_unset(
|
||||
self, mock_get_user_id, mock_event_logger
|
||||
) -> None:
|
||||
middleware = LoggingMiddleware()
|
||||
ctx = _make_context(method="resources/read", name="instance/metadata")
|
||||
call_next = AsyncMock(return_value="resource_data")
|
||||
|
||||
token = _mcp_user_id_var.set(None)
|
||||
try:
|
||||
await middleware.on_message(ctx, call_next)
|
||||
finally:
|
||||
_mcp_user_id_var.reset(token)
|
||||
|
||||
call_kwargs = mock_event_logger.log.call_args[1]
|
||||
assert call_kwargs["user_id"] == 13
|
||||
|
||||
|
||||
class TestResolveToolName:
|
||||
"""Tests for LoggingMiddleware._resolve_tool_name()."""
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# 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.
|
||||
"""Tests for migration ``da0e3f0081bf_enforce_oauth2_token_uniqueness``.
|
||||
|
||||
Runs the migration's ``upgrade()`` and ``downgrade()`` against an
|
||||
in-memory SQLite engine seeded with pre-existing duplicate
|
||||
(user_id, database_id) rows -- the state a deployment could be in today,
|
||||
since the prior plain index never stopped them from accumulating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
create_engine,
|
||||
inspect,
|
||||
Integer,
|
||||
MetaData,
|
||||
select,
|
||||
Table,
|
||||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
migration = import_module(
|
||||
"superset.migrations.versions."
|
||||
"2026-08-07_09-00_da0e3f0081bf_enforce_oauth2_token_uniqueness"
|
||||
)
|
||||
|
||||
TABLE_NAME: str = migration.TABLE_NAME # "database_user_oauth2_tokens"
|
||||
INDEX_NAME: str = migration.INDEX_NAME # "idx_user_id_database_id"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> Engine:
|
||||
"""In-memory SQLite seeded with a minimal pre-migration token table,
|
||||
including duplicate (user_id, database_id) rows -- id 2 and id 3 both
|
||||
belong to user 1 + database 10, which the plain index never prevented.
|
||||
"""
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
md = MetaData()
|
||||
table = Table(
|
||||
TABLE_NAME,
|
||||
md,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("user_id", Integer, nullable=False),
|
||||
Column("database_id", Integer, nullable=False),
|
||||
)
|
||||
md.create_all(engine)
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
table.insert(),
|
||||
[
|
||||
{"id": 1, "user_id": 1, "database_id": 20},
|
||||
{"id": 2, "user_id": 1, "database_id": 10},
|
||||
{"id": 3, "user_id": 1, "database_id": 10},
|
||||
{"id": 4, "user_id": 2, "database_id": 10},
|
||||
],
|
||||
)
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def _indexes(engine: Engine) -> dict[str, bool]:
|
||||
"""Map of index name -> ``unique`` flag on the target table."""
|
||||
return {ix["name"]: ix["unique"] for ix in inspect(engine).get_indexes(TABLE_NAME)}
|
||||
|
||||
|
||||
def _rows(engine: Engine) -> list[tuple[int, int, int]]:
|
||||
with engine.begin() as conn:
|
||||
table = Table(TABLE_NAME, MetaData(), autoload_with=conn)
|
||||
return sorted(
|
||||
(r.id, r.user_id, r.database_id)
|
||||
for r in conn.execute(select(table)).fetchall()
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_deletes_duplicates_keeping_highest_id(engine: Engine) -> None:
|
||||
"""Only the highest-id row survives per (user_id, database_id) pair;
|
||||
unrelated pairs are untouched.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
|
||||
assert _rows(engine) == [
|
||||
(1, 1, 20),
|
||||
(3, 1, 10),
|
||||
(4, 2, 10),
|
||||
], "upgrade() must keep only the highest-id row per duplicate pair"
|
||||
|
||||
|
||||
def test_upgrade_makes_the_index_unique(engine: Engine) -> None:
|
||||
"""upgrade() replaces the plain lookup index with a unique one."""
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
|
||||
assert _indexes(engine) == {INDEX_NAME: True}
|
||||
|
||||
|
||||
def test_upgrade_index_rejects_new_duplicates(engine: Engine) -> None:
|
||||
"""After upgrade(), inserting a second row for an existing
|
||||
(user_id, database_id) pair is rejected at the DB level.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
|
||||
table = Table(TABLE_NAME, MetaData(), autoload_with=engine)
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as conn:
|
||||
conn.execute(table.insert().values(id=99, user_id=1, database_id=20))
|
||||
|
||||
|
||||
def test_downgrade_restores_a_plain_non_unique_index(engine: Engine) -> None:
|
||||
"""downgrade() drops the unique index and recreates a plain one --
|
||||
the dedupe deletions from upgrade() are not reversed, since those rows
|
||||
already violated the single-token-per-pair invariant the application
|
||||
assumed.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
migration.downgrade()
|
||||
|
||||
assert _indexes(engine) == {INDEX_NAME: False}
|
||||
assert _rows(engine) == [
|
||||
(1, 1, 20),
|
||||
(3, 1, 10),
|
||||
(4, 2, 10),
|
||||
]
|
||||
|
||||
# Now that the index is plain again, duplicates are allowed once more.
|
||||
table = Table(TABLE_NAME, MetaData(), autoload_with=engine)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(table.insert().values(id=99, user_id=1, database_id=20))
|
||||
assert (99, 1, 20) in _rows(engine)
|
||||
|
||||
|
||||
def test_upgrade_is_idempotent(engine: Engine) -> None:
|
||||
"""``create_index``/``drop_index`` are skip-if-exists; running
|
||||
``upgrade()`` twice must not raise.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
with Operations.context(ctx):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
@@ -1513,12 +1513,22 @@ def test_purge_oauth2_tokens_scoped_by_database_id(session: Session) -> None:
|
||||
session.add(database1)
|
||||
session.flush()
|
||||
|
||||
# Insert several tokens on database1 so token PKs advance past 1 and drift
|
||||
# away from any single database PK.
|
||||
# Insert several tokens on database1, one per distinct user, so token PKs
|
||||
# advance past 1 and drift away from any single database PK. Each token
|
||||
# is for a different user to respect the unique (user_id, database_id)
|
||||
# constraint -- PK drift is what this test needs, not repeat users.
|
||||
for i in range(5):
|
||||
drift_user = User(
|
||||
first_name="Drift",
|
||||
last_name=str(i),
|
||||
email=f"drift{i}@example.org",
|
||||
username=f"drift{i}",
|
||||
)
|
||||
session.add(drift_user)
|
||||
session.flush()
|
||||
session.add(
|
||||
DatabaseUserOAuth2Tokens(
|
||||
user_id=user.id,
|
||||
user_id=drift_user.id,
|
||||
database_id=database1.id,
|
||||
access_token=f"db1_access_{i}", # noqa: S106
|
||||
access_token_expiration=datetime(2023, 1, 1),
|
||||
@@ -1573,6 +1583,59 @@ def test_purge_oauth2_tokens_scoped_by_database_id(session: Session) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_oauth2_tokens_unique_per_user_and_database(session: Session) -> None:
|
||||
"""
|
||||
``database_user_oauth2_tokens`` allows at most one row per
|
||||
(user_id, database_id) pair. `OAuth2StoreTokenCommand` always
|
||||
deletes any existing token before storing a new one, so a second row for
|
||||
the same pair can only appear via a lost race between two concurrent
|
||||
callbacks -- the DB-level constraint is what makes that impossible rather
|
||||
than merely unlikely.
|
||||
"""
|
||||
from flask_appbuilder.security.sqla.models import Role, User # noqa: F401
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from superset.models.core import Database, DatabaseUserOAuth2Tokens
|
||||
|
||||
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
|
||||
|
||||
user = User(
|
||||
first_name="Alice",
|
||||
last_name="Doe",
|
||||
email="adoe3@example.org",
|
||||
username="adoe3",
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
database = Database(database_name="my_unique_oauth2_db", sqlalchemy_uri="sqlite://")
|
||||
session.add(database)
|
||||
session.flush()
|
||||
|
||||
session.add(
|
||||
DatabaseUserOAuth2Tokens(
|
||||
user_id=user.id,
|
||||
database_id=database.id,
|
||||
access_token="first_access_token", # noqa: S106
|
||||
access_token_expiration=datetime(2023, 1, 1),
|
||||
refresh_token="first_refresh_token", # noqa: S106
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
session.add(
|
||||
DatabaseUserOAuth2Tokens(
|
||||
user_id=user.id,
|
||||
database_id=database.id,
|
||||
access_token="second_access_token", # noqa: S106
|
||||
access_token_expiration=datetime(2023, 1, 1),
|
||||
refresh_token="second_refresh_token", # noqa: S106
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.flush()
|
||||
|
||||
|
||||
def test_compile_sqla_query_no_optimization(query: Select) -> None:
|
||||
"""
|
||||
Test the `compile_sqla_query` method.
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user