Compare commits

..

16 Commits

Author SHA1 Message Date
Evan
c32284d56a chore(ci): correct setup-python pin comment to match v6.2.0
The pinned SHA a309ff8 resolves to tag v6.2.0, but the inline comment
read "# v6", which zizmor flags as a ref-version-mismatch. Update the
comment to the precise version, matching the rest of the workflows that
use full semver in their pin comments.

Resolves code-scanning alert #2550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:56:35 -07:00
Imad Helal
6bc77fecc2 feat(country-map): add cross-filters support (#35859)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 00:54:47 -07:00
dependabot[bot]
420a74b01e chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#41358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:52:16 -07:00
dependabot[bot]
7ba59c2d79 chore(deps): bump @jsonforms/vanilla-renderers from 3.7.0 to 3.8.0 in /superset-frontend (#41367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:51:53 -07:00
dependabot[bot]
b77c525d4b chore(deps-dev): bump storybook from 10.4.5 to 10.4.6 in /superset-frontend (#41368)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:51:22 -07:00
dependabot[bot]
41ce9ca7d3 chore(deps-dev): bump @swc/plugin-emotion from 14.12.0 to 14.13.0 in /superset-frontend (#41377)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:51:06 -07:00
Abdul Rehman
c2fb94cedf perf(filters): cache column-values endpoint to skip DB on repeat requests (#40839) 2026-06-23 23:41:26 -07:00
yousoph
1d0866556f fix(sql_lab): serialize dict/list cell values as valid JSON strings (#41099)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:39:23 -07:00
Evan Rusackas
b4dfeef2fd fix(reports): add network timeouts so schedules can't hang forever (#41250)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-23 18:01:03 -07:00
Dinesh M
0ec6cae45d feat(Boxplot): Allow configuration of y-axis range (#24380)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: dinesh-zemoso <dinesh.mandava@zemosolabs.com>
2026-06-23 17:48:06 -07:00
Lukas Biermann
d6ede99861 fix(tags): tags api change tag_get_objects method to be aligned with api documentation (#29338)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:12:33 -07:00
Hans Yu
9b6d3ce775 fix(models): make naive datetime object timezone-aware before converting to unix timestamp (#39782)
Co-authored-by: Hans Yu <hans.yu@digits.schwarz>
2026-06-23 14:09:26 -07:00
yousoph
c1f4062af6 fix(sql-lab): normalize tabViewId in QUERY_EDITOR_SET_SQL reducer (#40983)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:28:20 -07:00
crabulous
3bc3f47d67 fix(dataset): import/export jinja template bug (#28790)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:25:49 -07:00
Durgaprasad M L
acb996a324 feat(mcp): support virtual dataset metrics and improve adhoc SQL metric discoverability (#40935)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:19:44 -07:00
innovark
c1d08bf27c fix(table): respect row limit with server pagination (#41024)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 12:17:12 -07:00
75 changed files with 1984 additions and 626 deletions

View File

@@ -31,7 +31,7 @@ jobs:
checks: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: true
ref: master
@@ -40,7 +40,7 @@ jobs:
uses: ./.github/actions/setup-supersetbot/
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.10"

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
pull-requests: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check and notify

View File

@@ -26,7 +26,7 @@ jobs:
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout Repository"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: "Dependency Review"
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: "Checkout Repository"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -30,7 +30,7 @@ jobs:
docker: ${{ steps.check.outputs.docker }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -71,7 +71,7 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -177,7 +177,7 @@ jobs:
timeout-minutes: 30
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Free up disk space

View File

@@ -23,7 +23,7 @@ jobs:
run:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Note: registry-url is intentionally omitted. When set, actions/setup-node

View File

@@ -21,7 +21,7 @@ jobs:
run:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6

View File

@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -27,7 +27,7 @@ jobs:
security-events: write
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -16,7 +16,7 @@ jobs:
issues: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -28,7 +28,7 @@ jobs:
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -33,7 +33,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# pulls all commits (needed for lerna / semantic release to correctly version)

View File

@@ -152,7 +152,7 @@ jobs:
- name: Checkout PR code (only if build needed)
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.check.outputs.target_sha }}
persist-credentials: false

View File

@@ -41,7 +41,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false

View File

@@ -28,7 +28,7 @@ jobs:
name: Link Checking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
@@ -73,7 +73,7 @@ jobs:
working-directory: docs
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
@@ -112,7 +112,7 @@ jobs:
working-directory: docs
steps:
- name: "Checkout PR head: ${{ github.event.workflow_run.head_sha }}"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false

View File

@@ -38,7 +38,7 @@ jobs:
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -97,21 +97,21 @@ jobs:
# Conditional checkout based on context
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
@@ -207,21 +207,21 @@ jobs:
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge

View File

@@ -31,7 +31,7 @@ jobs:
working-directory: superset-extensions-cli
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -27,7 +27,7 @@ jobs:
should-run: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -110,7 +110,7 @@ jobs:
id-token: write
steps:
- name: Checkout Code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.ref || github.ref_name }}
persist-credentials: true

View File

@@ -34,7 +34,7 @@ jobs:
frontend: ${{ steps.check.outputs.frontend }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -83,21 +83,21 @@ jobs:
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge

View File

@@ -29,7 +29,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -72,7 +72,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
@@ -157,7 +157,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
@@ -207,7 +207,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -72,7 +72,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
@@ -127,7 +127,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -30,7 +30,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for file changes
@@ -55,7 +55,7 @@ jobs:
PYTHONPATH: ${{ github.workspace }}
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive
@@ -61,7 +61,7 @@ jobs:
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: recursive

View File

@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 20
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install dependencies

View File

@@ -38,7 +38,7 @@ jobs:
});
- name: "Checkout ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -27,7 +27,7 @@ jobs:
# zizmor: ignore[artipacked] - required persisted credentials to push synced requirement changes back to remote
- name: Checkout source code
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: true

View File

@@ -54,7 +54,7 @@ jobs:
fail-fast: false
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -120,7 +120,7 @@ jobs:
pull-requests: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0

View File

@@ -32,7 +32,7 @@ jobs:
name: Generate Reports
steps:
- name: Checkout Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -36,7 +36,7 @@
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.8.0",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -187,7 +187,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.41",
"@swc/plugin-emotion": "^14.12.0",
"@swc/plugin-emotion": "^14.13.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -272,7 +272,7 @@
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.4.5",
"storybook": "10.4.6",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
@@ -5387,41 +5387,41 @@
}
},
"node_modules/@jsonforms/core": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
"integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.8.0.tgz",
"integrity": "sha512-XSvaZuQSs/MceG5nDDcrE879onPHkGBy0xEuLeZMUkSM/M8wc1dEUrJtMOZVNSITocm9YXFY1qQ5gnsPP38zAg==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.3",
"ajv": "^8.6.1",
"ajv": "^8.18.0",
"ajv-formats": "^2.1.0",
"lodash": "^4.17.21"
}
},
"node_modules/@jsonforms/react": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
"integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.8.0.tgz",
"integrity": "sha512-k81+yWLpCQl+3XizS1bLjXoBwYhW1OAkjSXFA8W5qNtfPZjSOXDgtiuMOGYDv4b60tu2e9RB8h2P2O7QhfkhiA==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"@jsonforms/core": "3.7.0",
"@jsonforms/core": "3.8.0",
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@jsonforms/vanilla-renderers": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
"integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.8.0.tgz",
"integrity": "sha512-s75TG4hSYgYLN9IRVhYtGjijqyhVXijgDhb2WnMqY+Ki7MQkLn9U7yg/l89NEpwzWS1sv0DxKUxriqVUq382og==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"@jsonforms/core": "3.7.0",
"@jsonforms/react": "3.7.0",
"@jsonforms/core": "3.8.0",
"@jsonforms/react": "3.8.0",
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
@@ -10819,9 +10819,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "14.12.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.12.0.tgz",
"integrity": "sha512-lyAQgTeDkowq/4+8JYaviVOL4jXSdObz+uuk84DjM0z4qoiMpI6xoDVp7/tjWeVjmLc2U6Qp3hDuwWMZ5xe88Q==",
"version": "14.13.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.13.0.tgz",
"integrity": "sha512-UT1l9tr934HjnktUiMGbw1rWrIXUhAByTB0DwZJwHmS8KWox+wNBIK4ZkJ2tKVU/PnQZRni+R9e6xklFkmgSYg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -39527,9 +39527,9 @@
}
},
"node_modules/storybook": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.5.tgz",
"integrity": "sha512-QZuv1gS9Tf9RMCjDw5JOfv1XSB5IhU0uhSKQNS7l/N9zDpmSydirCspkCNT9e0zkFfPkZ9vmQUTzHY/BA07saA==",
"version": "10.4.6",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.6.tgz",
"integrity": "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -39540,7 +39540,7 @@
"@vitest/expect": "3.2.4",
"@vitest/spy": "3.2.4",
"@webcontainer/env": "^1.1.1",
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0",
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0",
"open": "^10.2.0",
"oxc-parser": "^0.127.0",
"oxc-resolver": "^11.19.1",

View File

@@ -119,7 +119,7 @@
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.8.0",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
@@ -270,7 +270,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.41",
"@swc/plugin-emotion": "^14.12.0",
"@swc/plugin-emotion": "^14.13.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.9.1",
@@ -355,7 +355,7 @@
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.4.5",
"storybook": "10.4.6",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",

View File

@@ -21,10 +21,12 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
BinaryQueryObjectFilterClause,
CategoricalColorNamespace,
ContextMenuFilters,
DataMask,
ValueFormatter,
getSequentialSchemeRegistry,
} from '@superset-ui/core';
import countries, { countryOptions } from './countries';
@@ -65,9 +67,28 @@ interface CountryMapProps {
formatter: ValueFormatter;
colorScheme: string;
sliceId: number;
onContextMenu?: (
clientX: number,
clientY: number,
data: ContextMenuFilters,
) => void;
emitCrossFilters?: boolean;
setDataMask?: (dataMask: DataMask) => void;
filterState?: {
selectedValues?: string[];
extraFormData?: {
filters?: BinaryQueryObjectFilterClause[];
};
};
entity?: string;
}
const maps: Record<string, GeoData> = {};
// Store zoom state per chart instance using element as key to enable garbage collection
const zoomStates = new WeakMap<
HTMLElement,
{ scale: number; translate: [number, number] }
>();
function CountryMap(element: HTMLElement, props: CountryMapProps) {
const {
@@ -75,10 +96,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
width,
height,
country,
entity,
linearColorScheme,
formatter,
colorScheme,
sliceId,
filterState,
emitCrossFilters,
onContextMenu,
setDataMask,
} = props;
const container = element;
@@ -99,7 +125,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
? colorScale(d.country_id, sliceId)
: (linearColorScale(d.metric) ?? '');
});
const colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none';
const colorFn = (feature: GeoFeature): string => {
if (!feature?.properties) return '#d9d9d9';
const iso = feature.properties.ISO;
return colorMap[iso] || '#d9d9d9';
};
// Check if dashboard is in edit mode
const isEditMode = container.closest('.dashboard--editing') !== null;
const path = d3.geo.path();
const div = d3.select(container);
@@ -112,6 +146,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.attr('width', width)
.attr('height', height)
.attr('preserveAspectRatio', 'xMidYMid meet');
// Only set grab cursor if not in edit mode
if (!isEditMode) {
svg.style('cursor', 'grab');
}
const backgroundRect = svg
.append('rect')
.attr('class', 'background')
@@ -119,39 +158,64 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.attr('height', height);
const g = svg.append('g');
const mapLayer = g.append('g').classed('map-layer', true);
// Add hover popup for tooltip
const hoverPopup = div.append('div').attr('class', 'hover-popup');
let centered: GeoFeature | null;
// Track mouse position to distinguish clicks from drags
let mousedownPos: { x: number; y: number } | null = null;
const clicked = function clicked(d: GeoFeature) {
const hasCenter = d && centered !== d;
let x: number;
let y: number;
let k: number;
const halfWidth = width / 2;
const halfHeight = height / 2;
// Cross-filter support
const getCrossFilterDataMask = (
source: GeoFeature,
): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => {
if (!entity) return undefined;
if (hasCenter) {
const centroid = path.centroid(d);
[x, y] = centroid;
k = 4;
centered = d;
} else {
x = halfWidth;
y = halfHeight;
k = 1;
centered = null;
}
const selected = filterState?.selectedValues || [];
const iso = source?.properties?.ISO;
if (!iso) return undefined;
g.transition()
.duration(750)
.attr(
'transform',
`translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`,
);
const isSelected = selected.includes(iso);
const values = isSelected ? [] : [iso];
return {
dataMask: {
extraFormData: {
filters: values.length
? [{ col: entity, op: 'IN', val: values }]
: [],
},
filterState: {
value: values.length ? values : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected: isSelected,
};
};
backgroundRect.on('click', clicked);
// Handle right-click context menu
const handleContextMenu = (feature: GeoFeature): void => {
const pointerEvent = d3.event;
if (typeof onContextMenu === 'function') {
pointerEvent?.preventDefault();
}
const iso = feature?.properties?.ISO;
if (!iso || typeof onContextMenu !== 'function' || !entity) return;
const drillVal = iso;
const drillToDetailFilters = [
{ col: entity, op: '==', val: drillVal, formattedVal: drillVal },
];
const drillByFilters = [{ col: entity, op: '==', val: drillVal }];
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: getCrossFilterDataMask(feature),
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
});
};
const getNameOfRegion = function getNameOfRegion(
feature: GeoFeature,
@@ -165,7 +229,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
return '';
};
const updatePopupPosition = () => {
const updatePopupPosition = (): void => {
const svgHeight = svg.node().getBoundingClientRect().height;
const [x, y] = d3.mouse(svg.node());
hoverPopup
@@ -175,36 +239,135 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
};
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
const mouseenter = function mouseenter(
this: SVGPathElement,
d: GeoFeature,
): void {
// Darken color
let c: string = colorFn(d);
if (c !== 'none') {
if (c) {
c = d3.rgb(c).darker().toString();
}
d3.select(this).style('fill', c);
// Display information popup
const result = data.filter(
region => region.country_id === d.properties.ISO,
);
// Display information popup
const result = data.filter(r => r.country_id === d?.properties?.ISO);
const regionName = escapeHtml(getNameOfRegion(d));
const metricValue =
result.length > 0 ? escapeHtml(String(formatter(result[0].metric))) : '';
hoverPopup
.style('display', 'block')
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
);
.html(`<div><strong>${regionName}</strong><br>${metricValue}</div>`);
updatePopupPosition();
};
const mousemove = function mousemove() {
// Mouse move handler to update tooltip position
const mousemove = function mousemove(): void {
updatePopupPosition();
};
const mouseout = function mouseout(this: SVGPathElement) {
d3.select(this).style('fill', colorFn);
const mouseout = function mouseout(this: SVGPathElement): void {
d3.select(this).style('fill', (d: GeoFeature) => colorFn(d));
hoverPopup.style('display', 'none');
};
function drawMap(mapData: GeoData) {
// Only enable zoom if not in edit mode
if (!isEditMode) {
// Zoom with panning bounds
const zoom = d3.behavior
.zoom()
.scaleExtent([1, 4])
.on('zoomstart', () => {
svg.style('cursor', 'grabbing');
})
.on('zoom', () => {
const { translate, scale } = d3.event;
let [tx, ty] = translate;
const scaledW = width * scale;
const scaledH = height * scale;
const minX = Math.min(0, width - scaledW);
const maxX = 0;
const minY = Math.min(0, height - scaledH);
const maxY = 0;
tx = Math.max(Math.min(tx, maxX), minX);
ty = Math.max(Math.min(ty, maxY), minY);
// Sync D3's internal translate state with the clamped values so the
// next wheel/zoom event starts from the constrained position rather
// than the unclamped one (otherwise the view jumps).
zoom.translate([tx, ty]);
g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`);
const prev = zoomStates.get(element);
const changed =
!prev ||
prev.scale !== scale ||
prev.translate[0] !== tx ||
prev.translate[1] !== ty;
if (changed) {
zoomStates.set(element, { scale, translate: [tx, ty] });
}
})
.on('zoomend', () => {
svg.style('cursor', 'grab');
});
d3.select(svg.node()).call(zoom);
// Restore previous zoom state if it exists
const savedZoom = zoomStates.get(element);
if (savedZoom) {
const { scale, translate } = savedZoom;
zoom.scale(scale).translate(translate);
g.attr(
'transform',
`translate(${translate[0]}, ${translate[1]}) scale(${scale})`,
);
}
}
// Visual highlighting for selected regions
function highlightSelectedRegion(
selectedValues: string[] | null = null,
): void {
const selected = selectedValues || filterState?.selectedValues || [];
mapLayer
.selectAll('path.region')
.style('fill-opacity', (d: GeoFeature) => {
const iso = d?.properties?.ISO;
return selected.length === 0 || selected.includes(iso) ? 1 : 0.3;
})
.style('stroke', (d: GeoFeature) => {
const iso = d?.properties?.ISO;
return selected.includes(iso) ? '#222' : null;
})
.style('stroke-width', (d: GeoFeature) => {
const iso = d?.properties?.ISO;
return selected.includes(iso) ? '1.5px' : '0.5px';
});
}
// Click handler for cross-filters
const handleClick = (feature: GeoFeature): void => {
if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') {
return;
}
const result = getCrossFilterDataMask(feature);
if (!result) return;
const { dataMask, isCurrentValueSelected } = result;
setDataMask(dataMask);
const iso = feature?.properties?.ISO;
const newSelection = isCurrentValueSelected || !iso ? [] : [iso];
highlightSelectedRegion(newSelection);
};
function drawMap(mapData: GeoData): void {
const { features } = mapData;
const center = d3.geo.centroid(mapData);
const scale = 100;
@@ -215,13 +378,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.translate([width / 2, height / 2]);
path.projection(projection);
// Compute scale that fits container.
const bounds = path.bounds(mapData);
const hscale = (scale * width) / (bounds[1][0] - bounds[0][0]);
const vscale = (scale * height) / (bounds[1][1] - bounds[0][1]);
const newScale = hscale < vscale ? hscale : vscale;
const newScale = Math.min(hscale, vscale);
// Compute bounds and offset using the updated scale.
projection.scale(newScale);
const newBounds = path.bounds(mapData);
projection.translate([
@@ -229,20 +390,45 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height - (newBounds[0][1] + newBounds[1][1]) / 2,
]);
// Draw each province as a path
mapLayer
.selectAll('path')
.data(features)
const sel = mapLayer.selectAll('path.region').data(features);
sel
.enter()
.append('path')
.attr('d', path)
.attr('class', 'region')
.attr('vector-effect', 'non-scaling-stroke')
.attr('vector-effect', 'non-scaling-stroke');
// Apply attributes and event handlers to all elements (enter + update)
mapLayer
.selectAll('path.region')
.attr('d', path)
.style('fill', colorFn)
.on('mouseenter', mouseenter)
.on('mousemove', mousemove)
.on('mouseout', mouseout)
.on('click', clicked);
.on('contextmenu', handleContextMenu)
.on('mousedown', function mousedown() {
const pos = d3.mouse(svg.node());
mousedownPos = { x: pos[0], y: pos[1] };
})
.on('click', function click(feature: GeoFeature) {
if (mousedownPos) {
const pos = d3.mouse(svg.node());
const dx = Math.abs(pos[0] - mousedownPos.x);
const dy = Math.abs(pos[1] - mousedownPos.y);
const dragThreshold = 5;
if (dx < dragThreshold && dy < dragThreshold) {
handleClick(feature);
}
mousedownPos = null;
}
});
sel.exit().remove();
highlightSelectedRegion();
}
const map = maps[country];

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import transformProps from './transformProps';
import exampleUsa from './images/exampleUsa.jpg';
import exampleUsaDark from './images/exampleUsa-dark.jpg';
@@ -49,6 +49,11 @@ const metadata = new ChartMetadata({
thumbnail,
thumbnailDark,
useLegacyApi: true,
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
});
export default class CountryMapChartPlugin extends ChartPlugin {

View File

@@ -19,8 +19,18 @@
import { ChartProps, getValueFormatter } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, queriesData, datasource } = chartProps;
const {
width,
height,
formData,
queriesData,
datasource,
hooks = {},
filterState,
emitCrossFilters,
} = chartProps;
const {
entity,
linearColorScheme,
numberFormat,
currencyFormat,
@@ -49,6 +59,8 @@ export default function transformProps(chartProps: ChartProps) {
detectedCurrency,
);
const { onContextMenu, setDataMask } = hooks;
return {
width,
height,
@@ -59,5 +71,10 @@ export default function transformProps(chartProps: ChartProps) {
colorScheme,
sliceId,
formatter,
entity,
onContextMenu,
setDataMask,
emitCrossFilters,
filterState,
};
}

View File

@@ -133,10 +133,11 @@ describe('CountryMap (legacy d3)', () => {
expect(popup!).toHaveStyle({ display: 'none' });
});
test('shows tooltip on mouseenter/mousemove/mouseout', async () => {
test('emits a cross-filter data mask when a region is clicked', () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const setDataMask = jest.fn();
render(
<ReactCountryMap
@@ -147,19 +148,101 @@ describe('CountryMap (legacy d3)', () => {
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
entity="country_code"
emitCrossFilters
setDataMask={setDataMask}
filterState={{ selectedValues: [] }}
/>,
);
const region = document.querySelector('path.region');
expect(region).not.toBeNull();
const popup = document.querySelector('.hover-popup');
expect(popup).not.toBeNull();
// A click is only treated as a selection when it follows a mousedown
// without dragging beyond the threshold (d3.mouse is mocked to a fixed
// position, so the down/up positions match).
fireEvent.mouseDown(region!);
fireEvent.click(region!);
fireEvent.mouseEnter(region!);
expect(popup!).toHaveStyle({ display: 'block' });
expect(setDataMask).toHaveBeenCalledTimes(1);
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }],
},
filterState: expect.objectContaining({ value: ['CAN'] }),
}),
);
});
fireEvent.mouseOut(region!);
expect(popup!).toHaveStyle({ display: 'none' });
test('does not emit a cross-filter when emitCrossFilters is disabled', () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const setDataMask = jest.fn();
render(
<ReactCountryMap
width={500}
height={300}
data={[{ country_id: 'CAN', metric: 100 }]}
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
entity="country_code"
emitCrossFilters={false}
setDataMask={setDataMask}
filterState={{ selectedValues: [] }}
/>,
);
const region = document.querySelector('path.region');
fireEvent.mouseDown(region!);
fireEvent.click(region!);
expect(setDataMask).not.toHaveBeenCalled();
});
test('opens the context menu with drill-by keyed on the entity control', () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const onContextMenu = jest.fn();
render(
<ReactCountryMap
width={500}
height={300}
data={[{ country_id: 'CAN', metric: 100 }]}
country="canada"
linearColorScheme="bnbColors"
colorScheme=""
formatter={jest.fn().mockReturnValue('100')}
entity="country_code"
onContextMenu={onContextMenu}
filterState={{ selectedValues: [] }}
/>,
);
const region = document.querySelector('path.region');
expect(region).not.toBeNull();
fireEvent.contextMenu(region!, { clientX: 123, clientY: 45 });
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [[clientX, clientY, payload]] = onContextMenu.mock.calls;
expect(clientX).toBe(123);
expect(clientY).toBe(45);
expect(payload.drillToDetail).toEqual([
{ col: 'country_code', op: '==', val: 'CAN', formattedVal: 'CAN' },
]);
// groupbyFieldName must be the form-data control key ('entity'), not the
// selected column value ('country_code'), so DrillByModal can map the
// selection back to the chart control.
expect(payload.drillBy).toEqual({
filters: [{ col: 'country_code', op: '==', val: 'CAN' }],
groupbyFieldName: 'entity',
});
});
});

View File

@@ -0,0 +1,76 @@
/**
* 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 { ChartProps } from '@superset-ui/core';
import transformProps from '../src/transformProps';
const onContextMenu = jest.fn();
const setDataMask = jest.fn();
const createProps = (formDataOverrides = {}, chartPropsOverrides = {}) =>
({
width: 800,
height: 600,
formData: {
entity: 'country_code',
linearColorScheme: 'bnbColors',
numberFormat: '.2f',
selectCountry: 'France',
colorScheme: '',
sliceId: 1,
metric: 'count',
...formDataOverrides,
},
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
datasource: { currencyFormats: {}, columnFormats: {} },
hooks: { onContextMenu, setDataMask },
filterState: { selectedValues: ['FRA'] },
emitCrossFilters: true,
...chartPropsOverrides,
}) as unknown as ChartProps;
test('forwards cross-filter hooks and state to the chart', () => {
const transformed = transformProps(createProps());
expect(transformed).toMatchObject({
width: 800,
height: 600,
entity: 'country_code',
onContextMenu,
setDataMask,
emitCrossFilters: true,
filterState: { selectedValues: ['FRA'] },
data: [{ country_id: 'FRA', metric: 10 }],
});
});
test('lowercases the selected country for map lookup', () => {
const transformed = transformProps(createProps());
expect(transformed.country).toBe('france');
});
test('passes a null country when none is selected', () => {
const transformed = transformProps(createProps({ selectCountry: undefined }));
expect(transformed.country).toBeNull();
});
test('defaults hooks to an empty object when none are provided', () => {
const transformed = transformProps(createProps({}, { hooks: undefined }));
expect(transformed.onContextMenu).toBeUndefined();
expect(transformed.setDataMask).toBeUndefined();
});

View File

@@ -160,6 +160,20 @@ const config: ControlPanelConfig = {
},
],
['zoomable'],
[
{
name: 'y_axis_slider',
config: {
type: 'CheckboxControl',
label: t('Y-axis range slider'),
default: false,
renderTrigger: true,
description: t(
'Show a draggable slider to control the visible range of the Y-axis.',
),
},
},
],
],
},
],

View File

@@ -74,6 +74,7 @@ export default function transformProps(
yAxisTitlePosition,
sliceId,
zoomable,
yAxisSlider,
} = formData as BoxPlotQueryFormData;
const refs: Refs = {};
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
@@ -257,6 +258,28 @@ export default function transformProps(
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
);
const dataZoom = [
...(zoomable
? [
{
type: 'inside',
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
]
: []),
...(yAxisSlider
? [
{
type: 'slider',
show: true,
yAxisIndex: [0],
// Adjust the axis window without dropping data points outside the range.
filterMode: 'none',
},
]
: []),
];
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
@@ -298,15 +321,7 @@ export default function transformProps(
},
},
},
dataZoom: zoomable
? [
{
type: 'inside',
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
]
: [],
dataZoom,
};
return {

View File

@@ -30,6 +30,7 @@ export type BoxPlotQueryFormData = QueryFormData & {
numberFormat?: string;
whiskerOptions?: BoxPlotFormDataWhiskerOptions;
xTickLayout?: BoxPlotFormXTickLayout;
yAxisSlider?: boolean;
} & TitleFormData;
export type BoxPlotFormDataWhiskerOptions =

View File

@@ -71,6 +71,15 @@ describe('BoxPlot transformProps', () => {
theme: supersetTheme,
});
const buildChartProps = (formDataOverrides: Partial<SqlaFormData> = {}) =>
new ChartProps({
formData: { ...formData, ...formDataOverrides },
width: 800,
height: 600,
queriesData: chartProps.queriesData,
theme: supersetTheme,
}) as EchartsBoxPlotChartProps;
test('should transform chart props for viz', () => {
expect(transformProps(chartProps as EchartsBoxPlotChartProps)).toEqual(
expect.objectContaining({
@@ -125,4 +134,41 @@ describe('BoxPlot transformProps', () => {
}),
);
});
test('should add a vertical Y-axis slider to dataZoom when yAxisSlider is enabled', () => {
const { echartOptions } = transformProps(
buildChartProps({ yAxisSlider: true }),
);
expect((echartOptions as any).dataZoom).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'slider',
show: true,
yAxisIndex: [0],
filterMode: 'none',
}),
]),
);
});
test('should not add a Y-axis slider when yAxisSlider is disabled', () => {
const { echartOptions } = transformProps(
buildChartProps({ yAxisSlider: false }),
);
expect((echartOptions as any).dataZoom).not.toContainEqual(
expect.objectContaining({ type: 'slider' }),
);
});
test('should combine zoomable and yAxisSlider dataZoom entries', () => {
const { echartOptions } = transformProps(
buildChartProps({ zoomable: true, yAxisSlider: true }),
);
expect((echartOptions as any).dataZoom).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'inside' }),
expect.objectContaining({ type: 'slider', yAxisIndex: [0] }),
]),
);
});
});

View File

@@ -54,7 +54,7 @@ export function getQueryMode(formData: TableChartFormData) {
return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate;
}
const buildQuery: BuildQuery<TableChartFormData> = (
export const buildQuery: BuildQuery<TableChartFormData> = (
formData: TableChartFormData,
options,
) => {
@@ -217,6 +217,17 @@ const buildQuery: BuildQuery<TableChartFormData> = (
const moreProps: Partial<QueryObject> = {};
const ownState = options?.ownState ?? {};
// Server pagination sizing, shared between the per-page request below and
// the filter-change reset further down.
const pageSize =
Number(ownState.pageSize ?? formDataCopy.server_page_length) || 0;
const configuredRowLimit = Number(formDataCopy.row_limit) || 0;
// row_limit for the first page, capped by the configured row limit. Used
// when a filter change resets pagination back to page 0.
const firstPageRowLimit =
configuredRowLimit > 0
? Math.min(pageSize, configuredRowLimit)
: pageSize;
// Build Query flag to check if its for either download as csv, excel or json
const isDownloadQuery =
['csv', 'xlsx'].includes(formData?.result_format || '') ||
@@ -229,11 +240,24 @@ const buildQuery: BuildQuery<TableChartFormData> = (
}
if (!isDownloadQuery && formDataCopy.server_pagination) {
const pageSize = ownState.pageSize ?? formDataCopy.server_page_length;
const currentPage = ownState.currentPage ?? 0;
// Never page past the configured row limit. Clamping the page to the last
// one that still falls within the limit keeps the request inside the cap
// and avoids emitting row_limit: 0, which the backend treats as
// "no limit" rather than "no rows" (see helpers.py get_sqla_query).
const lastPage =
configuredRowLimit > 0 && pageSize > 0
? Math.max(Math.ceil(configuredRowLimit / pageSize) - 1, 0)
: Number(ownState.currentPage) || 0;
const currentPage = Math.min(Number(ownState.currentPage) || 0, lastPage);
const rowOffset = currentPage * pageSize;
const remainingRows =
configuredRowLimit > 0
? Math.max(configuredRowLimit - rowOffset, 0)
: pageSize;
moreProps.row_limit = pageSize;
moreProps.row_offset = currentPage * pageSize;
moreProps.row_limit =
configuredRowLimit > 0 ? Math.min(pageSize, remainingRows) : pageSize;
moreProps.row_offset = rowOffset;
}
// getting sort by in case of server pagination from own state
@@ -263,11 +287,19 @@ const buildQuery: BuildQuery<TableChartFormData> = (
JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !==
JSON.stringify(queryObject.filters)
) {
queryObject = { ...queryObject, row_offset: 0 };
// Reset to the first page: restore the full first-page row_limit rather
// than carrying over the last page's capped value.
queryObject = {
...queryObject,
row_offset: 0,
row_limit: firstPageRowLimit,
};
const modifiedOwnState = {
...options?.ownState,
currentPage: 0,
pageSize: queryObject.row_limit ?? 0,
// Persist the user-selected page size, not the per-request row_limit,
// which may be capped to the remaining rows on the last page.
pageSize,
};
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
}

View File

@@ -17,7 +17,9 @@
* under the License.
*/
import { QueryMode, TimeGranularity, VizType } from '@superset-ui/core';
import buildQuery from '../src/buildQuery';
import buildQuery, {
buildQuery as buildQueryUncached,
} from '../src/buildQuery';
import { TableChartFormData } from '../src/types';
const basicFormData: TableChartFormData = {
@@ -278,6 +280,172 @@ describe('plugin-chart-table', () => {
expect(queries[0].filters?.some(f => f.op === 'ILIKE')).toBeFalsy();
});
test('uses user row limit when it is lower than server page size', () => {
const { queries } = buildQuery(
{
...baseFormDataWithServerPagination,
row_limit: 10,
server_page_length: 20,
slice_id: 101,
},
{
ownState: {
currentPage: 0,
pageSize: 20,
},
},
);
expect(queries[0]).toMatchObject({
row_limit: 10,
row_offset: 0,
});
});
test('limits server page size by remaining rows inside user row limit', () => {
const { queries } = buildQuery(
{
...baseFormDataWithServerPagination,
row_limit: 120,
server_page_length: 50,
slice_id: 102,
},
{
ownState: {
currentPage: 2,
pageSize: 50,
sortBy: [{ key: 'category', desc: true }],
},
},
);
expect(queries[0]).toMatchObject({
orderby: [['category', false]],
row_limit: 20,
row_offset: 100,
});
expect(queries[1]).toMatchObject({
is_rowcount: true,
row_limit: 120,
row_offset: 0,
});
});
test('clamps pages beyond the row limit instead of emitting row_limit: 0', () => {
const { queries } = buildQuery(
{
...baseFormDataWithServerPagination,
row_limit: 120,
server_page_length: 50,
slice_id: 103,
},
{
ownState: {
// Page 5 is well past the cap; offset would be 250 > 120, which
// previously made row_limit collapse to 0 ("no limit").
currentPage: 5,
pageSize: 50,
},
},
);
expect(queries[0].row_limit).not.toBe(0);
expect(queries[0]).toMatchObject({
row_limit: 20,
row_offset: 100,
});
});
test('restores the full first-page row limit after a filter change reset', () => {
// Uncached export lets us seed cachedChanges directly; the default
// export overrides extras with its own closure.
const { queries } = buildQueryUncached(
{
...baseFormDataWithServerPagination,
row_limit: 120,
server_page_length: 50,
slice_id: 104,
},
{
// User was on the capped last page (row_limit would be 20)...
ownState: {
currentPage: 2,
pageSize: 50,
},
// ...then an external filter changed, so the cached filters differ
// from the current ones and pagination resets to page 0.
extras: {
cachedChanges: {
104: [{ col: 'category', op: '==', val: 'previous' }],
},
},
},
);
expect(queries[0].row_limit).not.toBe(0);
expect(queries[0]).toMatchObject({
row_limit: 50,
row_offset: 0,
});
});
test('persists the user page size, not the capped limit, on filter reset', () => {
const setDataMask = jest.fn();
buildQueryUncached(
{
...baseFormDataWithServerPagination,
row_limit: 120,
server_page_length: 50,
slice_id: 106,
},
{
// On the capped last page, the per-request row_limit is 20.
ownState: {
currentPage: 2,
pageSize: 50,
},
extras: {
cachedChanges: {
106: [{ col: 'category', op: '==', val: 'previous' }],
},
},
hooks: { setDataMask, setCachedChanges: jest.fn() },
},
);
// The persisted page size must stay 50, not collapse to the capped 20.
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
currentPage: 0,
pageSize: 50,
}),
}),
);
});
test('falls back to the page size when no row limit is configured', () => {
const { queries } = buildQuery(
{
...baseFormDataWithServerPagination,
row_limit: undefined,
server_page_length: 50,
slice_id: 105,
},
{
ownState: {
currentPage: 3,
pageSize: 50,
},
},
);
expect(queries[0]).toMatchObject({
row_limit: 50,
row_offset: 150,
});
});
});
});
});

View File

@@ -200,6 +200,27 @@ describe('sqlLabReducer', () => {
expect(newState.unsavedQueryEditor.sql).toBe(sql);
expect(newState.unsavedQueryEditor.id).toBe(qe!.id);
});
test('should set Sql when dispatched with tabViewId (backend persistence)', () => {
// Simulate SqllabBackendPersistence: queryEditor gets a tabViewId after save
const tabViewId = 'tab-view-42';
const migrateAction = {
type: actions.MIGRATE_QUERY_EDITOR,
oldQueryEditor: qe,
newQueryEditor: { ...qe!, tabViewId, inLocalStorage: false },
};
newState = sqlLabReducer(newState, migrateAction as SqlLabAction);
// Restore SQL using tabViewId (as restoreSql in QueryTable does)
const sql = 'SELECT restored_query FROM history';
const restoreAction = {
type: actions.QUERY_EDITOR_SET_SQL,
queryEditor: { id: tabViewId },
sql,
};
newState = sqlLabReducer(newState, restoreAction);
expect(newState.unsavedQueryEditor.sql).toBe(sql);
expect(newState.unsavedQueryEditor.id).toBe(qe!.id);
});
test('should not fail while setting queryLimit', () => {
const queryLimit = 101;
const action = {

View File

@@ -604,8 +604,20 @@ export default function sqlLabReducer(
},
[actions.QUERY_EDITOR_SET_SQL]() {
const { unsavedQueryEditor } = state;
const actionId = action.queryEditor!.id!;
// Skip the O(n) tabViewId scan on the common path (keystroke: actionId already
// matches the active editor's client-side id). Only scan when ids differ, which
// happens when restoring from history with a backend-assigned tabViewId.
const normalizedId =
unsavedQueryEditor?.id === actionId
? actionId
: ((
getFromArr(state.queryEditors, actionId, 'tabViewId') as
| QueryEditor
| undefined
)?.id ?? actionId);
if (
unsavedQueryEditor?.id === action.queryEditor!.id &&
unsavedQueryEditor?.id === normalizedId &&
unsavedQueryEditor.sql === action.sql
) {
return state;
@@ -618,7 +630,7 @@ export default function sqlLabReducer(
sql: action.sql ?? undefined,
...(action.queryId && { latestQueryId: action.queryId }),
},
action.queryEditor!.id!,
normalizedId,
),
};
},

View File

@@ -524,7 +524,11 @@ class BaseReportState:
self._update_query_context()
try:
csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies)
csv_data = get_chart_csv_data(
chart_url=url,
auth_cookies=auth_cookies,
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
)
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
logger.info(
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
@@ -574,7 +578,11 @@ class BaseReportState:
self._update_query_context()
try:
dataframe = get_chart_dataframe(url, auth_cookies)
dataframe = get_chart_dataframe(
url,
auth_cookies,
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
)
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
logger.info(
"DataFrame generation from %s as user %s took %.2fs - execution_id: %s",

View File

@@ -1153,6 +1153,12 @@ SCREENSHOT_LOCATE_WAIT = int(timedelta(seconds=10).total_seconds())
# Time before selenium times out after waiting for all DOM class elements named
# "loading" are gone.
SCREENSHOT_LOAD_WAIT = int(timedelta(minutes=1).total_seconds())
# Maximum time (in seconds) selenium waits for an initial page navigation
# (driver.get) to complete. Without it the navigation blocks indefinitely when
# the target page never finishes loading (e.g. an unreachable WEBDRIVER_BASEURL),
# which leaves the report schedule stuck in the WORKING state. Set to None to
# disable (not recommended).
SCREENSHOT_PAGE_LOAD_WAIT = int(timedelta(minutes=2).total_seconds())
# Selenium destroy retries
SCREENSHOT_SELENIUM_RETRIES = 5
# Give selenium an headstart, in seconds
@@ -1736,6 +1742,11 @@ SMTP_MAIL_FROM = "superset@superset.com"
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
# default system root CA certificates.
SMTP_SSL_SERVER_AUTH = False
# Socket timeout (in seconds) for the SMTP connection used when sending
# alert/report emails. Without a timeout the underlying socket blocks
# indefinitely if the SMTP server becomes unreachable, which leaves report
# schedules stuck in the WORKING state. Set to None to disable (not recommended).
SMTP_TIMEOUT = 30
ENABLE_CHUNK_ENCODING = False
# Whether to bump the logging level to ERROR on the flask_appbuilder package
@@ -2084,6 +2095,12 @@ ALERT_REPORTS_NOTIFICATION_DRY_RUN = False
# Max tries to run queries to prevent false errors caused by transient errors
# being returned to users. Set to a value >1 to enable retries.
ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
# Socket timeout (in seconds) for the HTTP request that fetches chart data when
# generating CSV/dataframe report attachments. Without a timeout the request
# blocks indefinitely if the Superset webserver is unreachable from the worker,
# which leaves the report schedule stuck in the WORKING state. Set to None to
# disable (not recommended).
ALERT_REPORTS_CSV_REQUEST_TIMEOUT = 60
# Custom width for screenshots
ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
@@ -2128,6 +2145,12 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=1).total_seconds())
# For workspaces with 10k+ channels, consider increasing to 10
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
# Timeout (in seconds) for outbound Slack API calls. The Slack SDK defaults to 30s;
# exposing it here lets operators grant more time for large file uploads (multi-MB
# CSVs, PDFs, screenshot sets) to congested or rate-limited Slack endpoints without
# patching code, consistent with the SMTP/CSV/screenshot timeouts.
SLACK_API_TIMEOUT = 30
# The webdriver to use for generating reports when using Selenium (not Playwright).
# This setting is ignored when PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled, as
# Playwright always uses Chromium regardless of this value.

View File

@@ -294,6 +294,20 @@ class ImportV1MetricSchema(Schema):
return data
@pre_load
def fix_template_params(
self, data: dict[str, Any], **kwargs: Any
) -> dict[str, Any]:
"""
Fix for template_params initially being exported as an empty string.
"""
if (
isinstance(data.get("template_params"), str)
and data["template_params"].strip() == ""
):
data["template_params"] = None
return data
metric_name = fields.String(required=True)
verbose_name = fields.String(allow_none=True)
metric_type = fields.String(allow_none=True)

View File

@@ -31,7 +31,12 @@ from superset.exceptions import SupersetSecurityException
from superset.extensions import cache_manager
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType
from superset.utils.core import (
apply_max_row_limit,
DatasourceType,
parse_boolean_string,
SqlExpressionType,
)
from superset.views.base_api import BaseSupersetApi, statsd_metrics
logger = logging.getLogger(__name__)
@@ -125,13 +130,63 @@ class DatasourceRestApi(BaseSupersetApi):
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
denormalize_column = not datasource.normalize_columns
# Cache distinct column-value results so a dashboard with many filters
# backed by the same (often heavy) virtual dataset doesn't re-execute
# the wrapping query per filter (#39342).
#
# Key fields:
# - ``rls`` — full RLS fingerprint via
# ``security_manager.get_rls_cache_key`` (the canonical helper used
# by viz.py and query_context_processor.py). This is the sole
# security-isolation field — two users with identical effective
# RLS share a cache entry (intentional: they would see identical
# filtered values anyway), while users with different RLS, guest
# sessions with different guest-token RLS, and anonymous sessions
# with no RLS each get their own partition. We deliberately do
# NOT include the raw user id; doing so would defeat the
# intended cross-user cache sharing without adding any real
# security boundary beyond what the RLS fingerprint already
# provides.
# - ``changed_on`` — auto-busts cached entries when the dataset's
# underlying SQL is edited.
# - ``uid`` / ``col`` / ``limit`` / ``denorm`` — basic query-shape
# isolation so different inputs never collide.
force = parse_boolean_string(request.args.get("force"))
cache_key = (
"col_values:"
+ hashlib.sha256(
json.dumps(
{
"uid": datasource.uid,
"col": column_name,
"limit": row_limit,
"denorm": denormalize_column,
"rls": security_manager.get_rls_cache_key(datasource),
"changed_on": str(getattr(datasource, "changed_on", "")),
},
sort_keys=True,
).encode()
).hexdigest()
)
if (
not force
and (cached := cache_manager.data_cache.get(cache_key)) is not None
):
logger.debug(
"column-values cache HIT: uid=%s col=%s", datasource.uid, column_name
)
response = self.response(200, result=cached)
response.headers["X-Cache-Status"] = "HIT"
return response
try:
payload = datasource.values_for_column(
column_name=column_name,
limit=row_limit,
denormalize_column=denormalize_column,
)
return self.response(200, result=payload)
except KeyError:
return self.response(
400, message=f"Column name {column_name} does not exist"
@@ -145,6 +200,31 @@ class DatasourceRestApi(BaseSupersetApi):
),
)
# Warn before caching very large payloads (high-cardinality columns)
# so operators can spot cache-memory pressure before Redis OOMs.
# Threshold is operator-tunable; defaults to 100k rows.
warn_threshold = app.config.get("FILTER_VALUES_CACHE_WARN_THRESHOLD", 100_000)
if (payload_size := len(payload)) > warn_threshold:
logger.warning(
"column-values payload exceeds cache-warn threshold: "
"uid=%s col=%s rows=%d threshold=%d",
datasource.uid,
column_name,
payload_size,
warn_threshold,
)
timeout = datasource.cache_timeout or app.config.get(
"CACHE_DEFAULT_TIMEOUT", 300
)
cache_manager.data_cache.set(cache_key, payload, timeout=timeout)
logger.debug(
"column-values cache MISS: uid=%s col=%s", datasource.uid, column_name
)
response = self.response(200, result=payload)
response.headers["X-Cache-Status"] = "MISS"
return response
@expose(
"/<datasource_type>/<int:datasource_id>/validate_expression/",
methods=("POST",),

View File

@@ -139,6 +139,16 @@ Example table config:
## Available Aggregations
SUM, COUNT, AVG, MIN, MAX, COUNT_DISTINCT, STDDEV, VAR, MEDIAN
## Custom SQL Metrics
For ratio metrics, weighted averages, and conditional aggregates,
use `sql_expression` with a `label`:
`{{"sql_expression": "SUM(revenue) / COUNT(*)", "label": "Avg Revenue"}}`
Do NOT combine `sql_expression` with `name` or `aggregate`.
## Saved Metrics
If a metric is already defined on the dataset, use `saved_metric=True`:
`{{"name": "avg_revenue", "saved_metric": true}}`
## Time Grain Options (for temporal x-axis)
PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P3M (quarterly), P1Y (yearly)

View File

@@ -75,6 +75,7 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"columns": [
{"name": "customer_name"},
{"name": "revenue", "aggregate": "SUM"},
{"sql_expression": "SUM(revenue) / COUNT(*)", "label": "Avg per Order"},
],
},
],

View File

@@ -120,8 +120,10 @@ class TableColumnInfo(BaseModel):
class SqlMetricInfo(BaseModel):
metric_name: str = Field(
...,
description="Saved metric name. In chart configs, reference as "
'{"name": "<metric_name>", "saved_metric": true}.',
description=(
"Saved metric name. In chart configs, reference as "
'{"name": "<metric_name>", "saved_metric": true}.'
),
)
verbose_name: str | None = Field(None, description="Verbose name")
expression: str | None = Field(None, description="SQL expression")
@@ -408,6 +410,30 @@ class GetDatasetInfoRequest(MetadataCacheControl):
return parsed
class CreateDatasetMetric(BaseModel):
"""Metric definition for dataset creation."""
metric_name: str = Field(..., description="Name of the metric")
expression: str = Field(..., description="SQL expression for the metric")
verbose_name: str | None = None
description: str | None = None
metric_type: str | None = None
d3format: str | None = None
warning_text: str | None = None
class CreateDatasetCalculatedColumn(BaseModel):
"""Calculated column definition for dataset creation."""
column_name: str = Field(..., description="Name of the calculated column")
expression: str = Field(..., description="SQL expression for the column")
verbose_name: str | None = None
description: str | None = None
type: str | None = None
advanced_data_type: str | None = None
is_dttm: bool | None = None
class CreateDatasetRequest(BaseModel):
"""Request schema for create_dataset to register a physical table as a dataset."""
@@ -512,6 +538,16 @@ class CreateVirtualDatasetRequest(BaseModel):
None,
description="Human-readable description of the dataset (optional).",
)
metrics: list[CreateDatasetMetric] | None = Field(
None,
description="Optional list of saved metrics to create. Each metric "
"must have 'metric_name' and 'expression'.",
)
calculated_columns: list[CreateDatasetCalculatedColumn] | None = Field(
None,
description="Optional list of calculated columns to create. Each column "
"must have 'column_name' and 'expression'.",
)
@field_validator("sql")
@classmethod

View File

@@ -30,6 +30,55 @@ from superset.mcp_service.dataset.schemas import (
logger = logging.getLogger(__name__)
def _build_update_props(
request: CreateVirtualDatasetRequest, dataset: Any
) -> dict[str, Any]:
update_props: dict[str, Any] = {}
if request.metrics:
# Merge existing metrics with new ones
existing_metrics = [
{"id": m.id, "metric_name": m.metric_name} for m in dataset.metrics
]
update_props["metrics"] = existing_metrics + [
m.model_dump(exclude_none=True) for m in request.metrics
]
if request.calculated_columns:
# Merge existing columns with new ones
existing_cols = [
{"id": c.id, "column_name": c.column_name} for c in dataset.columns
]
update_props["columns"] = existing_cols + [
c.model_dump(exclude_none=True) for c in request.calculated_columns
]
return update_props
def _cleanup_failed_dataset(dataset_id: int) -> None:
from superset.commands.dataset.delete import DeleteDatasetCommand
try:
DeleteDatasetCommand([dataset_id]).run()
except Exception as cleanup_exc:
logger.error(
"Failed to clean up dataset %s after update error: %s",
dataset_id,
cleanup_exc,
)
def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> Any:
from superset.commands.dataset.exceptions import DatasetUpdateFailedError
from superset.commands.dataset.update import UpdateDatasetCommand
try:
return UpdateDatasetCommand(dataset_id, update_props).run()
except Exception as exc:
_cleanup_failed_dataset(dataset_id)
if not isinstance(exc, DatasetUpdateFailedError):
raise DatasetUpdateFailedError() from exc
raise
@tool(
tags=["mutate"],
class_permission_name="Dataset",
@@ -56,8 +105,8 @@ async def create_virtual_dataset(
3. Use the returned ``columns`` list to pick columns for the chart config
"""
await ctx.info(
"Creating virtual dataset: database_id=%s, dataset_name=%r"
% (request.database_id, request.dataset_name)
f"Creating virtual dataset: database_id={request.database_id}, "
f"dataset_name={request.dataset_name!r}"
)
try:
@@ -65,6 +114,7 @@ async def create_virtual_dataset(
from superset.commands.dataset.exceptions import (
DatasetCreateFailedError,
DatasetInvalidError,
DatasetUpdateFailedError,
)
from superset.mcp_service.utils.url_utils import get_superset_base_url
@@ -85,6 +135,14 @@ async def create_virtual_dataset(
dataset = CreateDatasetCommand(properties).run()
if request.metrics or request.calculated_columns:
update_props = _build_update_props(request, dataset)
with event_logger.log_context(
action="mcp.create_virtual_dataset.update"
):
dataset = _update_virtual_dataset(dataset.id, update_props)
# Build response
columns = [col.column_name for col in dataset.columns]
dataset_url = (
@@ -93,8 +151,8 @@ async def create_virtual_dataset(
)
await ctx.info(
"Virtual dataset created: id=%s, dataset_name=%r, columns=%s"
% (dataset.id, dataset.table_name, columns)
f"Virtual dataset created: id={dataset.id}, "
f"dataset_name={dataset.table_name!r}, columns={columns}"
)
return CreateVirtualDatasetResponse(
@@ -108,7 +166,7 @@ async def create_virtual_dataset(
except DatasetInvalidError as exc:
messages = exc.normalized_messages()
await ctx.warning("Virtual dataset validation failed: %s" % (messages,))
await ctx.warning(f"Virtual dataset validation failed: {messages}")
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
@@ -119,7 +177,7 @@ async def create_virtual_dataset(
error=str(messages),
)
except DatasetCreateFailedError as exc:
await ctx.error("Virtual dataset creation failed: %s" % (str(exc),))
await ctx.error(f"Virtual dataset creation failed: {exc}")
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
@@ -129,9 +187,19 @@ async def create_virtual_dataset(
url=None,
error=f"Failed to create dataset: {exc}",
)
except DatasetUpdateFailedError as exc:
await ctx.error(f"Virtual dataset update failed: {exc}")
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
sql=request.sql,
database_id=request.database_id,
columns=[],
url=None,
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
)
except Exception as exc:
await ctx.error(
"Unexpected error creating virtual dataset: %s: %s"
% (type(exc).__name__, str(exc))
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
)
raise

View File

@@ -27,9 +27,7 @@ from flask import current_app
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import lazyload, Session
# Note: Import Database functionality without importing the actual model
from superset import db, db_engine_specs, security_manager
from superset.databases.utils import make_url_safe
from superset import db, security_manager
from superset.db_engine_specs.base import GenericDBException
from superset.migrations.shared.security_converge import (
add_pvms,
@@ -37,80 +35,13 @@ from superset.migrations.shared.security_converge import (
PermissionView,
ViewMenu,
)
from superset.models.core import Database
logger = logging.getLogger("alembic.env")
Base: Type[Any] = declarative_base()
class Database(Base):
"""Local Database model for migration"""
__tablename__ = "dbs"
id = sa.Column(sa.Integer, primary_key=True)
sqlalchemy_uri = sa.Column(sa.String(1024))
encrypted_extra = sa.Column(sa.Text)
database_name = sa.Column(sa.String(250))
@property
def db_engine_spec(self) -> Type[Any]:
url = make_url_safe(self.sqlalchemy_uri)
backend = url.get_backend_name()
try:
driver = url.get_driver_name()
except Exception:
driver = None
return db_engine_specs.get_engine_spec(backend, driver)
def get_default_catalog(self) -> str | None:
"""Get default catalog using the engine spec."""
return self.db_engine_spec.get_default_catalog(self)
def is_oauth2_enabled(self) -> bool:
"""Check if OAuth2 is enabled for this database."""
from superset.utils import json
encrypted_extra = json.loads(self.encrypted_extra or "{}")
return bool(encrypted_extra.get("oauth2_client_info"))
def get_inspector(self, catalog: str | None = None) -> Any:
"""Get a database inspector for introspection."""
from sqlalchemy import create_engine, inspect
# Create an engine from the URI
engine = create_engine(self.sqlalchemy_uri)
if catalog and hasattr(engine, "execution_options"):
engine = engine.execution_options(catalog=catalog)
return inspect(engine)
def get_all_schema_names(self, catalog: str | None = None) -> list[str]:
"""
Get all schema names for this database.
Uses SQLAlchemy inspector to get schema names directly.
"""
try:
with self.get_inspector(catalog=catalog) as inspector:
return self.db_engine_spec.get_schema_names(inspector)
except Exception as ex:
# Convert any exception to GenericDBException for consistent handling
raise GenericDBException(str(ex)) from ex
def get_all_catalog_names(self) -> list[str]:
"""
Get all catalog names for this database.
Uses SQLAlchemy inspector to get catalog names directly.
"""
try:
with self.get_inspector() as inspector:
return self.db_engine_spec.get_catalog_names(self, inspector)
except Exception as ex:
# Convert any exception to GenericDBException for consistent handling
raise GenericDBException(str(ex)) from ex
class SqlaTable(Base):
__tablename__ = "tables"

View File

@@ -27,7 +27,7 @@ import re
import uuid
from collections.abc import Hashable, Iterator
from contextlib import contextmanager
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import (
Any,
Callable,
@@ -2765,7 +2765,15 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if tf:
if tf in {"epoch_ms", "epoch_s"}:
seconds_since_epoch = int(dttm.timestamp())
# In general, Superset works with timezone-naive datetime objects
# internally. However, timestamp() applies local timezone to
# timezone-naive datetime objects. Therefore, we have to be explicit
# about UTC before calling timestamp().
dttm_tz_aware = dttm
if dttm_tz_aware.tzinfo is None:
dttm_tz_aware = dttm_tz_aware.replace(tzinfo=timezone.utc)
seconds_since_epoch = int(dttm_tz_aware.timestamp())
if tf == "epoch_s":
return str(seconds_since_epoch)
return str(seconds_since_epoch * 1000)

View File

@@ -73,9 +73,21 @@ def stringify_values(array: NDArray[Any]) -> NDArray[Any]:
obj[na_obj] = None
else:
try:
# for simple string conversions
# this handles odd character types better
obj[...] = obj.astype(str)
val = obj.item()
if isinstance(val, (dict, list)):
try:
# Use json.dumps for valid double-quoted JSON.
# str() gives single-quoted repr like {'a': 1}
# which breaks the frontend cell viewer.
obj[...] = stringify(val)
except TypeError:
# Non-JSON-serializable value (e.g. bytes, custom
# objects): fall back to str() to avoid crashing.
obj[...] = str(val)
else:
# for simple string conversions
# this handles odd character types better
obj[...] = obj.astype(str)
except ValueError:
obj[...] = stringify(obj)

View File

@@ -543,11 +543,28 @@ class TagRestApi(BaseSupersetModelRestApi):
---
get:
summary: Get all objects associated with a tag
description: >-
Get all objects associated with a tag.
If tagIds is set, tags will be ignored.
parameters:
- in: path
- in: query
name: tagIds
schema:
type: integer
name: tag_id
type: array
items:
type: integer
- in: query
name: tags
schema:
type: array
items:
type: string
- in: query
name: types
schema:
type: array
items:
type: string
responses:
200:
description: List of tagged objects associated with a Tag

View File

@@ -938,6 +938,11 @@ def send_mime_email(
smtp_starttls = config["SMTP_STARTTLS"]
smtp_ssl = config["SMTP_SSL"]
smtp_ssl_server_auth = config["SMTP_SSL_SERVER_AUTH"]
# A missing timeout means the socket blocks forever when the SMTP server is
# unreachable, wedging the report schedule in the WORKING state. Fall back to
# the key being absent for backwards compatibility with custom configs.
# Keep this fallback in sync with the SMTP_TIMEOUT default in config.py.
smtp_timeout = config.get("SMTP_TIMEOUT", 30)
if dryrun:
logger.info("Dryrun enabled, email notification content is below:")
@@ -948,17 +953,27 @@ def send_mime_email(
# root CA certificates
ssl_context = ssl.create_default_context() if smtp_ssl_server_auth else None
smtp = (
smtplib.SMTP_SSL(smtp_host, smtp_port, context=ssl_context)
smtplib.SMTP_SSL(
smtp_host, smtp_port, context=ssl_context, timeout=smtp_timeout
)
if smtp_ssl
else smtplib.SMTP(smtp_host, smtp_port)
else smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
)
if smtp_starttls:
smtp.starttls(context=ssl_context)
if smtp_user and smtp_password:
smtp.login(smtp_user, smtp_password)
logger.debug("Sent an email to %s", str(e_to))
smtp.sendmail(e_from, e_to, mime_msg.as_string())
smtp.quit()
try:
if smtp_starttls:
smtp.starttls(context=ssl_context)
if smtp_user and smtp_password:
smtp.login(smtp_user, smtp_password)
logger.debug("Sent an email to %s", str(e_to))
smtp.sendmail(e_from, e_to, mime_msg.as_string())
finally:
# Always release the socket; the new timeout means starttls/login/
# sendmail can raise, and a skipped quit() would leak connections in
# the long-lived worker process.
try:
smtp.quit()
except smtplib.SMTPException:
pass
def recipients_string_to_list(address_string: str | None) -> list[str]:

View File

@@ -90,14 +90,18 @@ def df_to_escaped_csv(df: pd.DataFrame, **kwargs: Any) -> Any:
def get_chart_csv_data(
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
chart_url: str,
auth_cookies: Optional[dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Optional[bytes]:
content = None
if auth_cookies:
opener = urllib.request.build_opener()
cookie_str = ";".join([f"{key}={val}" for key, val in auth_cookies.items()])
opener.addheaders.append(("Cookie", cookie_str))
response = opener.open(chart_url)
# A missing timeout means the socket blocks forever when the Superset
# webserver is unreachable, wedging the report schedule in WORKING.
response = opener.open(chart_url, timeout=timeout)
content = response.read()
if response.getcode() != 200:
raise URLError(response.getcode())
@@ -107,11 +111,13 @@ def get_chart_csv_data(
def get_chart_dataframe(
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
chart_url: str,
auth_cookies: Optional[dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Optional[pd.DataFrame]:
# Disable all the unnecessary-lambda violations in this function
# pylint: disable=unnecessary-lambda
content = get_chart_csv_data(chart_url, auth_cookies)
content = get_chart_csv_data(chart_url, auth_cookies, timeout)
if content is None:
return None

View File

@@ -48,7 +48,11 @@ def get_slack_client() -> WebClient:
token: str = app.config["SLACK_API_TOKEN"]
if callable(token):
token = token()
client = WebClient(token=token, proxy=app.config["SLACK_PROXY"])
client = WebClient(
token=token,
proxy=app.config["SLACK_PROXY"],
timeout=app.config["SLACK_API_TIMEOUT"],
)
max_retry_count = app.config.get("SLACK_API_RATE_LIMIT_RETRY_COUNT", 2)
rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=max_retry_count)

View File

@@ -502,9 +502,23 @@ class WebDriverSelenium(WebDriverProxy):
self._driver = self._create()
if not self._driver:
raise RuntimeError("WebDriver creation failed")
self._driver.set_window_size(*self._window)
if self._user:
self._auth(self._user)
try:
self._driver.set_window_size(*self._window)
# Bound driver.get() so an unreachable page raises a
# TimeoutException instead of blocking the worker (and the
# report schedule) forever.
page_load_wait = app.config["SCREENSHOT_PAGE_LOAD_WAIT"]
if page_load_wait is not None:
self._driver.set_page_load_timeout(page_load_wait)
if self._user:
self._auth(self._user)
except Exception:
# A failure mid-setup (e.g. the new page-load timeout or auth
# raising) would otherwise leave a partially initialized,
# unauthenticated driver cached for reuse. Tear it down so the
# next access recreates it cleanly.
self._destroy()
raise
return self._driver
def _create_firefox_driver(

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from unittest.mock import ANY, patch
import pytest
@@ -23,12 +24,21 @@ from sqlalchemy.sql.elements import TextClause
from superset import db, security_manager
from superset.connectors.sqla.models import SqlaTable
from superset.daos.exceptions import DatasourceTypeNotSupportedError
from superset.extensions import cache_manager
from superset.utils import json
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
class TestDatasourceApi(SupersetTestCase):
def setUp(self):
# Clear the column-values cache before every test so that
# ``get_column_values`` always re-runs ``values_for_column`` rather
# than returning a payload populated by a previous test. Prevents
# order-dependent flakes now that the endpoint caches its result.
super().setUp()
cache_manager.data_cache.clear()
def get_virtual_dataset(self):
return (
db.session.query(SqlaTable)
@@ -205,6 +215,123 @@ class TestDatasourceApi(SupersetTestCase):
response = json.loads(rv.data.decode("utf-8"))
assert response["result"] == []
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_cache_hit_skips_query(self, values_for_column_mock):
"""Regression test for #39342.
Two identical requests for the same column on the same datasource
should hit ``values_for_column`` exactly once — the second request
returns the cached payload.
"""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["a", "b", "c"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
rv1 = self.client.get(url)
rv2 = self.client.get(url)
assert rv1.status_code == 200
assert rv2.status_code == 200
assert json.loads(rv2.data.decode("utf-8"))["result"] == ["a", "b", "c"]
assert values_for_column_mock.call_count == 1
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_force_bypasses_cache(self, values_for_column_mock):
"""``?force=true`` should bypass the cache and re-query the source."""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["a", "b"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
self.client.get(url)
self.client.get(f"{url}?force=true")
assert values_for_column_mock.call_count == 2
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_cache_isolated_per_column(self, values_for_column_mock):
"""Different columns on the same datasource must not share a cache
entry — otherwise filter values would be silently swapped."""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["x"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
self.client.get(f"api/v1/datasource/table/{table.id}/column/col1/values/")
self.client.get(f"api/v1/datasource/table/{table.id}/column/col2/values/")
assert values_for_column_mock.call_count == 2
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_cache_busts_on_changed_on(self, values_for_column_mock):
"""Editing the underlying virtual dataset SQL bumps ``changed_on``,
which is part of the cache key — so the next request must miss the
cache and re-run the query."""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["v"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
self.client.get(url)
# Simulate an edit to the dataset; ``changed_on`` is what the cache
# key reads, so any new value forces a miss.
table.changed_on = datetime(2030, 1, 1)
db.session.flush()
self.client.get(url)
assert values_for_column_mock.call_count == 2
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.datasource.api.security_manager.get_rls_cache_key")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_cache_isolated_per_rls_context(
self, values_for_column_mock, get_rls_cache_key_mock
):
"""RLS safety for guest/embedded sessions. ``get_user_id()`` returns
``None`` for guest users, so two embedded dashboards with different
guest-token RLS would otherwise collide on ``user=None``. Including
the RLS fingerprint in the cache key keeps them separate."""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["v"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
get_rls_cache_key_mock.return_value = ["dept='A'"]
self.client.get(url)
get_rls_cache_key_mock.return_value = ["dept='B'"]
self.client.get(url)
assert values_for_column_mock.call_count == 2
@pytest.mark.usefixtures("app_context", "virtual_dataset")
@patch("superset.models.helpers.ExploreMixin.values_for_column")
def test_get_column_values_response_advertises_cache_status(
self, values_for_column_mock
):
"""The ``X-Cache-Status`` response header should advertise MISS on
the populating request and HIT on the next identical request, so
operators can debug cache behavior from logs or browser devtools."""
cache_manager.data_cache.clear()
values_for_column_mock.return_value = ["v"]
self.login(ADMIN_USERNAME)
table = self.get_virtual_dataset()
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
rv_miss = self.client.get(url)
rv_hit = self.client.get(url)
assert rv_miss.headers.get("X-Cache-Status") == "MISS"
assert rv_hit.headers.get("X-Cache-Status") == "HIT"
@patch("superset.datasource.api.security_manager.can_access")
@patch("superset.datasource.api.GetCombinedDatasourceListCommand.run")
def test_combined_list_invalid_order_column(

View File

@@ -193,7 +193,9 @@ class TestEmailSmtp(SupersetTestCase):
msg = MIMEMultipart()
utils.send_mime_email("from", "to", msg, current_app.config, dryrun=False)
mock_smtp.assert_called_with(
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
timeout=current_app.config["SMTP_TIMEOUT"],
)
assert mock_smtp.return_value.starttls.called
mock_smtp.return_value.login.assert_called_with(
@@ -218,6 +220,7 @@ class TestEmailSmtp(SupersetTestCase):
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
context=None,
timeout=current_app.config["SMTP_TIMEOUT"],
)
@mock.patch("smtplib.SMTP_SSL")
@@ -235,6 +238,7 @@ class TestEmailSmtp(SupersetTestCase):
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
context=mock.ANY,
timeout=current_app.config["SMTP_TIMEOUT"],
)
called_context = mock_smtp_ssl.call_args.kwargs["context"]
assert called_context.verify_mode == ssl.CERT_REQUIRED
@@ -266,7 +270,9 @@ class TestEmailSmtp(SupersetTestCase):
)
assert not mock_smtp_ssl.called
mock_smtp.assert_called_with(
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
timeout=current_app.config["SMTP_TIMEOUT"],
)
assert not mock_smtp.login.called
current_app.config["SMTP_USER"] = smtp_user
@@ -281,6 +287,36 @@ class TestEmailSmtp(SupersetTestCase):
assert not mock_smtp.called
assert not mock_smtp_ssl.called
@mock.patch("smtplib.SMTP_SSL")
@mock.patch("smtplib.SMTP")
def test_send_mime_respects_custom_timeout(
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
) -> None:
"""A configured SMTP_TIMEOUT must reach the smtplib client.
A missing timeout would block the worker forever when the SMTP server
is unreachable, wedging the report schedule in WORKING (issue #40047).
"""
config = {**current_app.config, "SMTP_TIMEOUT": 7, "SMTP_SSL": False}
mock_smtp.return_value = mock.Mock()
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
assert mock_smtp.call_args.kwargs["timeout"] == 7
@mock.patch("smtplib.SMTP_SSL")
@mock.patch("smtplib.SMTP")
def test_send_mime_timeout_defaults_when_unset(
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
) -> None:
"""An absent SMTP_TIMEOUT key falls back to the 30s default.
Custom configs predating SMTP_TIMEOUT must still get a finite timeout.
"""
config = {k: v for k, v in current_app.config.items() if k != "SMTP_TIMEOUT"}
config["SMTP_SSL"] = False
mock_smtp.return_value = mock.Mock()
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
assert mock_smtp.call_args.kwargs["timeout"] == 30
if __name__ == "__main__":
unittest.main()

View File

@@ -2007,7 +2007,11 @@ def test_slack_token_callable_chart_report(
TEST_ID, create_report_slack_chart.id, datetime.utcnow()
).run()
slack_token_mock.assert_called()
slack_client_mock_class.assert_called_with(token="cool_code", proxy=None) # noqa: S106
slack_client_mock_class.assert_called_with(
token="cool_code", # noqa: S106
proxy=None,
timeout=30,
)
assert_log(ReportState.SUCCESS)

View File

@@ -810,6 +810,64 @@ def test_import_dataset_extra_empty_string(
assert sqla_table.extra is None # noqa: E711
def test_import_dataset_template_params_is_empty_string(
mocker: MockerFixture, session: Session
) -> None:
"""
Test importing a dataset when the template_params field is an empty string.
"""
mocker.patch.object(security_manager, "can_access", return_value=True)
engine = db.session.get_bind()
SqlaTable.metadata.create_all(engine) # pylint: disable=no-member
database = Database(database_name="my_database", sqlalchemy_uri="sqlite://")
db.session.add(database)
db.session.flush()
dataset_uuid = uuid.uuid4()
yaml_config: dict[str, Any] = {
"version": "1.0.0",
"table_name": "my_table",
"main_dttm_col": "ds",
"schema": "my_schema",
"sql": None,
"params": {
"remote_id": 64,
"database_name": "examples",
"import_time": 1606677834,
},
"template_params": "",
"extra": None,
"uuid": dataset_uuid,
"metrics": [
{
"metric_name": "cnt",
"expression": "COUNT(*)",
}
],
"columns": [
{
"column_name": "profit",
"is_dttm": False,
"is_active": True,
"type": "INTEGER",
"groupby": False,
"filterable": False,
"expression": "revenue-expenses",
}
],
"database_uuid": database.uuid,
}
schema = ImportV1DatasetSchema()
dataset_config = schema.load(yaml_config)
dataset_config["database_id"] = database.id
sqla_table = import_dataset(dataset_config)
assert sqla_table.template_params is None # noqa: E711
@patch("superset.commands.dataset.importers.v1.utils.is_safe_host", return_value=True)
@patch("superset.commands.dataset.importers.v1.utils.request.build_opener")
def test_import_column_allowed_data_url(

View File

@@ -1805,14 +1805,34 @@ def _make_mock_virtual_dataset(
id: int = 21,
table_name: str = "Customer Revenue",
column_names: list[str] | None = None,
metric_names: list[str] | None = None,
) -> MagicMock:
"""Create a mock virtual dataset object with configurable columns."""
"""Create a mock virtual dataset object with configurable columns and metrics."""
if column_names is None:
column_names = ["name", "revenue"]
if metric_names is None:
metric_names = []
dataset = MagicMock()
dataset.id = id
dataset.table_name = table_name
dataset.columns = [MagicMock(column_name=c) for c in column_names]
cols = []
for i, c in enumerate(column_names):
col = MagicMock()
col.id = i + 1
col.column_name = c
cols.append(col)
dataset.columns = cols
metrics = []
for i, m in enumerate(metric_names):
metric = MagicMock()
metric.id = i + 100
metric.metric_name = m
metrics.append(metric)
dataset.metrics = metrics
return dataset
@@ -2077,6 +2097,250 @@ async def test_create_virtual_dataset_optional_fields_forwarded(
assert props["description"] == "A test dataset"
@pytest.mark.asyncio
async def test_create_virtual_dataset_with_metrics_and_columns(
mcp_server: object,
) -> None:
"""metrics and calculated_columns are forwarded to UpdateDatasetCommand."""
mock_dataset = _make_mock_virtual_dataset(
column_names=["col1"], metric_names=["existing_m"]
)
mock_create_instance = MagicMock()
mock_create_instance.run.return_value = mock_dataset
mock_create_cls = MagicMock(return_value=mock_create_instance)
mock_update_instance = MagicMock()
mock_update_instance.run.return_value = mock_dataset
mock_update_cls = MagicMock(return_value=mock_update_instance)
with (
patch(
"superset.commands.dataset.create.CreateDatasetCommand",
mock_create_cls,
),
patch(
"superset.commands.dataset.update.UpdateDatasetCommand",
mock_update_cls,
),
patch(
"superset.mcp_service.utils.url_utils.get_superset_base_url",
return_value="http://localhost:8088",
),
):
async with Client(mcp_server) as client:
request = CreateVirtualDatasetRequest(
database_id=1,
sql="SELECT col1 FROM t",
dataset_name="My Dataset",
metrics=[{"metric_name": "m1", "expression": "SUM(col1)"}],
calculated_columns=[{"column_name": "c1", "expression": "col1 + 1"}],
)
await client.call_tool(
"create_virtual_dataset", {"request": request.model_dump()}
)
# Verify create was called normally
props = mock_create_cls.call_args[0][0]
assert props["sql"] == "SELECT col1 FROM t"
# Verify update was called with the nested objects
mock_update_cls.assert_called_once()
update_id, update_props = mock_update_cls.call_args[0]
assert update_id == mock_dataset.id
assert "metrics" in update_props
assert len(update_props["metrics"]) == 2
# Verify existing metric is preserved with id and metric_name
assert update_props["metrics"][0]["id"] == 100
assert update_props["metrics"][0]["metric_name"] == "existing_m"
# Verify new metric is appended
assert update_props["metrics"][1]["metric_name"] == "m1"
assert "columns" in update_props
assert len(update_props["columns"]) == 2
# Verify existing column is preserved with id and column_name
assert update_props["columns"][0]["id"] == 1
assert update_props["columns"][0]["column_name"] == "col1"
# Verify new column is appended
assert update_props["columns"][1]["column_name"] == "c1"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exception_to_raise",
[
"DatasetUpdateFailedError",
"DatasetInvalidError",
],
)
async def test_create_virtual_dataset_update_failure_rollback(
mcp_server: object,
exception_to_raise: str,
) -> None:
"""
If UpdateDatasetCommand fails,
DeleteDatasetCommand should clean up the orphan dataset.
"""
from superset.commands.dataset.exceptions import (
DatasetInvalidError,
DatasetUpdateFailedError,
)
mock_dataset = _make_mock_virtual_dataset()
mock_create_instance = MagicMock()
mock_create_instance.run.return_value = mock_dataset
mock_create_cls = MagicMock(return_value=mock_create_instance)
mock_update_instance = MagicMock()
if exception_to_raise == "DatasetUpdateFailedError":
mock_update_instance.run.side_effect = DatasetUpdateFailedError()
else:
mock_update_instance.run.side_effect = DatasetInvalidError()
mock_update_cls = MagicMock(return_value=mock_update_instance)
mock_delete_instance = MagicMock()
mock_delete_cls = MagicMock(return_value=mock_delete_instance)
with (
patch(
"superset.commands.dataset.create.CreateDatasetCommand",
mock_create_cls,
),
patch(
"superset.commands.dataset.update.UpdateDatasetCommand",
mock_update_cls,
),
patch(
"superset.commands.dataset.delete.DeleteDatasetCommand",
mock_delete_cls,
),
patch(
"superset.mcp_service.utils.url_utils.get_superset_base_url",
return_value="http://localhost:8088",
),
):
async with Client(mcp_server) as client:
request = CreateVirtualDatasetRequest(
database_id=1,
sql="SELECT col1 FROM t",
dataset_name="My Dataset",
metrics=[{"metric_name": "m1", "expression": "SUM(col1)"}],
)
result = await client.call_tool(
"create_virtual_dataset", {"request": request.model_dump()}
)
# Verify create was called normally
mock_create_cls.assert_called_once()
# Verify update was attempted
mock_update_cls.assert_called_once()
# Verify delete was called to rollback
mock_delete_cls.assert_called_once_with([mock_dataset.id])
mock_delete_instance.run.assert_called_once()
# Verify the error response
data = json.loads(result.content[0].text)
assert data["id"] is None
assert "creation rolled back" in data["error"]
@pytest.mark.asyncio
async def test_create_virtual_dataset_metrics_only(
mcp_server: object,
) -> None:
"""
If only metrics are provided,
calculated_columns are omitted in the update props.
"""
mock_dataset = _make_mock_virtual_dataset()
mock_create_instance = MagicMock()
mock_create_instance.run.return_value = mock_dataset
mock_create_cls = MagicMock(return_value=mock_create_instance)
mock_update_instance = MagicMock()
mock_update_instance.run.return_value = mock_dataset
mock_update_cls = MagicMock(return_value=mock_update_instance)
with (
patch(
"superset.commands.dataset.create.CreateDatasetCommand",
mock_create_cls,
),
patch(
"superset.commands.dataset.update.UpdateDatasetCommand",
mock_update_cls,
),
patch(
"superset.mcp_service.utils.url_utils.get_superset_base_url",
return_value="http://localhost:8088",
),
):
async with Client(mcp_server) as client:
request = CreateVirtualDatasetRequest(
database_id=1,
sql="SELECT col1 FROM t",
dataset_name="My Dataset",
metrics=[{"metric_name": "m1", "expression": "SUM(col1)"}],
)
await client.call_tool(
"create_virtual_dataset", {"request": request.model_dump()}
)
mock_update_cls.assert_called_once()
update_props = mock_update_cls.call_args[0][1]
assert "metrics" in update_props
assert "columns" not in update_props
@pytest.mark.asyncio
async def test_create_virtual_dataset_columns_only(
mcp_server: object,
) -> None:
"""
If only calculated_columns are provided,
metrics are omitted in the update props.
"""
mock_dataset = _make_mock_virtual_dataset()
mock_create_instance = MagicMock()
mock_create_instance.run.return_value = mock_dataset
mock_create_cls = MagicMock(return_value=mock_create_instance)
mock_update_instance = MagicMock()
mock_update_instance.run.return_value = mock_dataset
mock_update_cls = MagicMock(return_value=mock_update_instance)
with (
patch(
"superset.commands.dataset.create.CreateDatasetCommand",
mock_create_cls,
),
patch(
"superset.commands.dataset.update.UpdateDatasetCommand",
mock_update_cls,
),
patch(
"superset.mcp_service.utils.url_utils.get_superset_base_url",
return_value="http://localhost:8088",
),
):
async with Client(mcp_server) as client:
request = CreateVirtualDatasetRequest(
database_id=1,
sql="SELECT col1 FROM t",
dataset_name="My Dataset",
calculated_columns=[{"column_name": "c1", "expression": "col1 + 1"}],
)
await client.call_tool(
"create_virtual_dataset", {"request": request.model_dump()}
)
mock_update_cls.assert_called_once()
update_props = mock_update_cls.call_args[0][1]
assert "columns" in update_props
assert "metrics" not in update_props
class TestListDatasetsCreatedByMe:
"""Tests for the created_by_me flag on ListDatasetsRequest."""

View File

@@ -62,106 +62,36 @@ def test_upgrade_catalog_perms(mocker: MockerFixture, session: Session) -> None:
The function is called when catalogs are introduced into a new DB engine spec.
"""
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
TableSchema,
TabState,
)
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.slice import Slice
from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState
engine = session.get_bind()
Database.metadata.create_all(engine)
Permission.metadata.create_all(engine)
PermissionView.metadata.create_all(engine)
ViewMenu.metadata.create_all(engine)
mocker.patch("superset.migrations.shared.catalogs.op")
db = mocker.patch("superset.migrations.shared.catalogs.db")
db.Session.return_value = session
# Mock current_app.config to ensure we don't skip non-default catalogs
mocker.patch.dict(
"superset.migrations.shared.catalogs.current_app.config",
{"CATALOGS_SIMPLIFIED_MIGRATION": False},
)
# Mock the db_engine_spec methods instead of the Database model methods
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.supports_catalog = True
mock_db_engine_spec.get_default_catalog.return_value = "db"
mock_db_engine_spec.get_all_schema_names.return_value = [
"public",
"information_schema",
]
mock_db_engine_spec.get_all_catalog_names.return_value = ["db", "other_catalog"]
mocker.patch.object(
Database,
"db_engine_spec",
new_callable=mocker.PropertyMock,
return_value=mock_db_engine_spec,
"get_all_schema_names",
return_value=["public", "information_schema"],
)
mocker.patch.object(
Database,
"get_default_catalog",
return_value="db",
"get_all_catalog_names",
return_value=["db", "other_catalog"],
)
# Create a mock database that can call the engine spec methods
def get_all_schema_names_mock(catalog=None):
if catalog == "other_catalog":
return ["public", "information_schema"]
return ["public", "information_schema"]
def get_all_catalog_names_mock():
return ["db", "other_catalog"]
database = Database(
id=1,
database_name="my_db",
sqlalchemy_uri="postgresql://localhost/db",
)
database.database_name = "my_db"
# Mock the methods instead of assigning
mocker.patch.object(database, "get_all_schema_names", get_all_schema_names_mock)
mocker.patch.object(database, "get_all_catalog_names", get_all_catalog_names_mock)
session.add(database)
session.commit()
# Create initial permissions for testing
db_perm = ViewMenu(name="[my_db].(id:1)")
table_perm = ViewMenu(name="[my_db].[my_table](id:1)")
schema_perm = ViewMenu(name="[my_db].[public]")
database_access = Permission(name="database_access")
datasource_access = Permission(name="datasource_access")
schema_access = Permission(name="schema_access")
session.add_all(
[
db_perm,
table_perm,
schema_perm,
database_access,
datasource_access,
schema_access,
]
)
session.commit()
# Create permission view associations
pv1 = PermissionView(permission_id=database_access.id, view_menu_id=db_perm.id)
pv2 = PermissionView(permission_id=datasource_access.id, view_menu_id=table_perm.id)
pv3 = PermissionView(permission_id=schema_access.id, view_menu_id=schema_perm.id)
session.add_all([pv1, pv2, pv3])
session.commit()
dataset = SqlaTable(
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
table_name="my_table",
database=database,
catalog=None,
schema="public",
catalog_perm=None,
@@ -171,26 +101,33 @@ def test_upgrade_catalog_perms(mocker: MockerFixture, session: Session) -> None:
session.commit()
chart = Slice(
slice_name="my_chart",
datasource_type="table",
datasource_id=dataset.id,
catalog_perm=None,
schema_perm="[my_db].[public]",
)
query = Query(
database_id=database.id,
client_id="foo",
database=database,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
db_id=database.id,
database=database,
sql="SELECT * FROM public.t",
catalog=None,
schema="public",
)
tab_state = TabState(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()
@@ -221,9 +158,8 @@ def test_upgrade_catalog_perms(mocker: MockerFixture, session: Session) -> None:
# add dataset/chart in new catalog
new_dataset = SqlaTable(
id=2,
database_id=database.id,
perm="[my_db].[my_table](id:2)",
table_name="my_table",
database=database,
catalog="other_catalog",
schema="public",
schema_perm="[my_db].[other_catalog].[public]",
@@ -232,17 +168,8 @@ def test_upgrade_catalog_perms(mocker: MockerFixture, session: Session) -> None:
session.add(new_dataset)
session.commit()
# Add permission for the new dataset
new_table_perm = ViewMenu(name="[my_db].[my_table](id:2)")
session.add(new_table_perm)
session.commit()
pv_new = PermissionView(
permission_id=datasource_access.id, view_menu_id=new_table_perm.id
)
session.add(pv_new)
session.commit()
new_chart = Slice(
slice_name="my_chart",
datasource_type="table",
datasource_id=new_dataset.id,
)
@@ -259,24 +186,21 @@ def test_upgrade_catalog_perms(mocker: MockerFixture, session: Session) -> None:
assert dataset.schema_perm == "[my_db].[db].[public]"
assert chart.catalog_perm == "[my_db].[db]"
assert chart.schema_perm == "[my_db].[db].[public]"
assert sorted(
assert (
session.query(ViewMenu.name, Permission.name)
.join(PermissionView, ViewMenu.id == PermissionView.view_menu_id)
.join(Permission, PermissionView.permission_id == Permission.id)
.all()
) == sorted(
[
("[my_db].(id:1)", "database_access"),
("[my_db].[my_table](id:1)", "datasource_access"),
("[my_db].[db].[public]", "schema_access"),
("[my_db].[db]", "catalog_access"),
("[my_db].[other_catalog]", "catalog_access"),
("[my_db].[other_catalog].[public]", "schema_access"),
("[my_db].[other_catalog].[information_schema]", "schema_access"),
("[my_db].[my_table](id:2)", "datasource_access"),
]
)
) == [
("[my_db].(id:1)", "database_access"),
("[my_db].[my_table](id:1)", "datasource_access"),
("[my_db].[db].[public]", "schema_access"),
("[my_db].[db]", "catalog_access"),
("[my_db].[other_catalog]", "catalog_access"),
("[my_db].[other_catalog].[public]", "schema_access"),
("[my_db].[other_catalog].[information_schema]", "schema_access"),
("[my_db].[my_table](id:2)", "datasource_access"),
]
# do a downgrade
downgrade_catalog_perms()
@@ -321,94 +245,32 @@ def test_upgrade_catalog_perms_graceful(
catalog browsing on the database (permissions are always synced on a DB update, see
`UpdateDatabaseCommand`).
"""
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
TableSchema,
TabState,
)
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.slice import Slice
from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState
engine = session.get_bind()
Database.metadata.create_all(engine)
Permission.metadata.create_all(engine)
PermissionView.metadata.create_all(engine)
ViewMenu.metadata.create_all(engine)
mocker.patch("superset.migrations.shared.catalogs.op")
db = mocker.patch("superset.migrations.shared.catalogs.db")
db.Session.return_value = session
# Mock the db_engine_spec to support catalogs but fail on get_all_schema_names
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.supports_catalog = True
mock_db_engine_spec.get_default_catalog.return_value = "db"
mocker.patch.object(
Database,
"db_engine_spec",
new_callable=mocker.PropertyMock,
return_value=mock_db_engine_spec,
"get_all_schema_names",
side_effect=Exception("Failed to connect to the database"),
)
mocker.patch.object(
Database,
"get_default_catalog",
return_value="db",
)
def get_all_schema_names_mock(catalog=None):
raise Exception("Failed to connect to the database")
mocker.patch("superset.migrations.shared.catalogs.op", session)
database = Database(
id=1,
database_name="my_db",
sqlalchemy_uri="postgresql://localhost/db",
)
database.database_name = "my_db"
session.add(database)
session.commit()
mocker.patch.object(
database,
"get_all_schema_names",
side_effect=get_all_schema_names_mock,
)
# Create initial permissions for testing
db_perm = ViewMenu(name="[my_db].(id:1)")
table_perm = ViewMenu(name="[my_db].[my_table](id:1)")
schema_perm = ViewMenu(name="[my_db].[public]")
database_access = Permission(name="database_access")
datasource_access = Permission(name="datasource_access")
schema_access = Permission(name="schema_access")
session.add_all(
[
db_perm,
table_perm,
schema_perm,
database_access,
datasource_access,
schema_access,
]
)
session.commit()
# Create permission view associations
pv1 = PermissionView(permission_id=database_access.id, view_menu_id=db_perm.id)
pv2 = PermissionView(permission_id=datasource_access.id, view_menu_id=table_perm.id)
pv3 = PermissionView(permission_id=schema_access.id, view_menu_id=schema_perm.id)
session.add_all([pv1, pv2, pv3])
session.commit()
dataset = SqlaTable(
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
table_name="my_table",
database=database,
catalog=None,
schema="public",
schema_perm="[my_db].[public]",
@@ -417,26 +279,31 @@ def test_upgrade_catalog_perms_graceful(
session.commit()
chart = Slice(
slice_name="my_chart",
datasource_type="table",
datasource_id=dataset.id,
catalog_perm=None,
schema_perm="[my_db].[public]",
)
query = Query(
database_id=database.id,
client_id="foo",
database=database,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
db_id=database.id,
database=database,
sql="SELECT * FROM public.t",
catalog=None,
schema="public",
)
tab_state = TabState(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()
@@ -503,21 +370,13 @@ def test_upgrade_catalog_perms_oauth_connection(
schemas. This step should be skipped if the database is set up using OAuth and not
raise an exception.
"""
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
TableSchema,
TabState,
)
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.slice import Slice
from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState
engine = session.get_bind()
Database.metadata.create_all(engine)
Permission.metadata.create_all(engine)
PermissionView.metadata.create_all(engine)
ViewMenu.metadata.create_all(engine)
mocker.patch("superset.migrations.shared.catalogs.op")
db = mocker.patch("superset.migrations.shared.catalogs.db")
@@ -527,64 +386,14 @@ def test_upgrade_catalog_perms_oauth_connection(
)
mocker.patch("superset.migrations.shared.catalogs.op", session)
# Mock the db_engine_spec for BigQuery with catalog support
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.supports_catalog = True
mock_db_engine_spec.get_default_catalog.return_value = "my-test-project"
mocker.patch.object(
Database,
"db_engine_spec",
new_callable=mocker.PropertyMock,
return_value=mock_db_engine_spec,
)
mocker.patch.object(
Database,
"get_default_catalog",
return_value="my-test-project",
)
database = Database(
id=1,
database_name="my_db",
sqlalchemy_uri="bigquery://my-test-project",
encrypted_extra=json.dumps({"oauth2_client_info": oauth2_config}),
)
database.database_name = "my_db"
session.add(database)
session.commit()
# Create initial permissions for testing
db_perm = ViewMenu(name="[my_db].(id:1)")
table_perm = ViewMenu(name="[my_db].[my_table](id:1)")
schema_perm = ViewMenu(name="[my_db].[public]")
database_access = Permission(name="database_access")
datasource_access = Permission(name="datasource_access")
schema_access = Permission(name="schema_access")
session.add_all(
[
db_perm,
table_perm,
schema_perm,
database_access,
datasource_access,
schema_access,
]
)
session.commit()
# Create permission view associations
pv1 = PermissionView(permission_id=database_access.id, view_menu_id=db_perm.id)
pv2 = PermissionView(permission_id=datasource_access.id, view_menu_id=table_perm.id)
pv3 = PermissionView(permission_id=schema_access.id, view_menu_id=schema_perm.id)
session.add_all([pv1, pv2, pv3])
session.commit()
dataset = SqlaTable(
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
table_name="my_table",
database=database,
catalog=None,
schema="public",
schema_perm="[my_db].[public]",
@@ -593,26 +402,31 @@ def test_upgrade_catalog_perms_oauth_connection(
session.commit()
chart = Slice(
slice_name="my_chart",
datasource_type="table",
datasource_id=dataset.id,
catalog_perm=None,
schema_perm="[my_db].[public]",
)
query = Query(
database_id=database.id,
client_id="foo",
database=database,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
db_id=database.id,
database=database,
sql="SELECT * FROM public.t",
catalog=None,
schema="public",
)
tab_state = TabState(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()
@@ -680,22 +494,14 @@ def test_upgrade_catalog_perms_simplified_migration(
This should only update existing permissions + create a new permission
for the default catalog.
"""
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
TableSchema,
TabState,
)
from superset.migrations.shared.security_converge import Base as SecurityBase
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.slice import Slice
from superset.models.sql_lab import Query, SavedQuery, TableSchema, TabState
engine = session.get_bind()
Database.metadata.create_all(engine)
SecurityBase.metadata.create_all(engine)
mocker.patch("superset.migrations.shared.catalogs.op")
db = mocker.patch("superset.migrations.shared.catalogs.db")
db.Session.return_value = session
@@ -704,65 +510,13 @@ def test_upgrade_catalog_perms_simplified_migration(
)
mocker.patch("superset.migrations.shared.catalogs.op", session)
# Mock the db_engine_spec for BigQuery with catalog support
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.supports_catalog = True
mock_db_engine_spec.get_default_catalog.return_value = "my-test-project"
mocker.patch.object(
Database,
"db_engine_spec",
new_callable=mocker.PropertyMock,
return_value=mock_db_engine_spec,
)
mocker.patch.object(
Database,
"get_default_catalog",
return_value="my-test-project",
)
database = Database(
id=1,
database_name="my_db",
sqlalchemy_uri="bigquery://my-test-project",
)
database.database_name = "my_db"
session.add(database)
session.commit()
# Create initial permissions for testing
db_perm = ViewMenu(name="[my_db].(id:1)")
table_perm = ViewMenu(name="[my_db].[my_table](id:1)")
schema_perm = ViewMenu(name="[my_db].[public]")
database_access = Permission(name="database_access")
datasource_access = Permission(name="datasource_access")
schema_access = Permission(name="schema_access")
catalog_access = Permission(name="catalog_access")
session.add_all(
[
db_perm,
table_perm,
schema_perm,
database_access,
datasource_access,
schema_access,
catalog_access,
]
)
session.commit()
# Create permission view associations
pv1 = PermissionView(permission_id=database_access.id, view_menu_id=db_perm.id)
pv2 = PermissionView(permission_id=datasource_access.id, view_menu_id=table_perm.id)
pv3 = PermissionView(permission_id=schema_access.id, view_menu_id=schema_perm.id)
session.add_all([pv1, pv2, pv3])
session.commit()
dataset = SqlaTable(
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
table_name="my_table",
database=database,
catalog=None,
schema="public",
schema_perm="[my_db].[public]",
@@ -771,26 +525,31 @@ def test_upgrade_catalog_perms_simplified_migration(
session.commit()
chart = Slice(
slice_name="my_chart",
datasource_type="table",
datasource_id=dataset.id,
catalog_perm=None,
schema_perm="[my_db].[public]",
)
query = Query(
database_id=database.id,
client_id="foo",
database=database,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
db_id=database.id,
database=database,
sql="SELECT * FROM public.t",
catalog=None,
schema="public",
)
tab_state = TabState(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database_id=database.id,
database=database,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()

View File

@@ -450,3 +450,93 @@ def test_stringify_extension_columns() -> None:
# plain binary BLOBs and other types are left untouched
assert pa.types.is_binary(result.schema.field("blob").type)
assert pa.types.is_integer(result.schema.field("n").type)
def test_stringify_values_dict_and_list_produce_valid_json() -> None:
"""
ClickHouse native JSON and Map types return Python dicts. When stringified for
Arrow array storage they must produce valid double-quoted JSON strings, not
Python's single-quoted repr. Single-quoted strings pass the cheap '{' prefix
check in the frontend's safeJsonObjectParse but then fail JSONbig.parse(),
so the SQL Lab cell viewer never activates.
"""
data = np.array(
[
{"key": "value", "nested": {"a": 1}},
# str() gives ['a', 'b'] (single-quoted, invalid JSON);
# json.dumps gives ["a", "b"] (double-quoted, valid JSON).
["a", "b"],
{"items": [1, 2, 3], "d": "Hello, World!"},
None,
],
dtype=object,
)
result = stringify_values(data)
# Must be valid JSON strings (double-quoted), not Python repr (single-quoted)
assert result[0] == '{"key": "value", "nested": {"a": 1}}'
assert result[1] == '["a", "b"]'
assert result[2] == '{"items": [1, 2, 3], "d": "Hello, World!"}'
assert result[3] is None
# Parseable by a JSON parser — confirms the frontend's JSON.parse would succeed
parsed = superset_json.loads(result[0])
assert parsed == {"key": "value", "nested": {"a": 1}}
parsed = superset_json.loads(result[1])
assert parsed == ["a", "b"]
def test_clickhouse_json_column_in_pa_table_is_valid_json() -> None:
"""
Verify that ClickHouse-style heterogeneous dict columns produce valid JSON
strings in the Arrow table used by the msgpack serialization path.
When clickhouse-connect returns Python dicts for JSON/Map type columns,
SupersetResultSet must serialize them with json.dumps (not str()) so that
the SQL Lab grid's cell viewer can call JSON.parse on the value.
"""
data = [
(1, {"a": {"b": 42}, "c": [1, 2, 3], "d": "Hello, World!"}),
(2, {"e": 5}),
(3, None),
]
description = [
("id", 3, None, None, None, None, None),
("json_col", None, None, None, None, None, None),
]
result_set = SupersetResultSet(data, description, BaseEngineSpec) # type: ignore
df_from_pa = SupersetResultSet.convert_table_to_df(result_set.pa_table)
val0 = df_from_pa["json_col"].iloc[0]
val1 = df_from_pa["json_col"].iloc[1]
# Values in pa_table must be valid JSON strings (parseable by JSON.parse)
assert isinstance(val0, str)
assert isinstance(val1, str)
# Double-quoted JSON, not single-quoted Python repr
parsed0 = superset_json.loads(val0)
assert parsed0 == {"a": {"b": 42}, "c": [1, 2, 3], "d": "Hello, World!"}
parsed1 = superset_json.loads(val1)
assert parsed1 == {"e": 5}
def test_stringify_values_non_serializable_dict_falls_back_to_str() -> None:
"""
When a dict/list contains a value that json.dumps cannot serialize (e.g. bytes),
stringify_values must fall back to str() rather than raising TypeError and crashing
the result-set construction path.
"""
class _Unserializable:
def __repr__(self) -> str:
return "unserializable"
data = np.array(
[{"key": _Unserializable()}],
dtype=object,
)
# Must not raise — falls back to str()
result = stringify_values(data)
assert result[0] == str({"key": _Unserializable()})

View File

@@ -16,6 +16,9 @@
# under the License.
from typing import Any
from unittest import mock
import pandas as pd
import pyarrow as pa
import pytest # noqa: F401
@@ -25,6 +28,7 @@ from superset.utils import csv, json
from superset.utils.core import GenericDataType
from superset.utils.csv import (
df_to_escaped_csv,
get_chart_csv_data,
get_chart_dataframe,
)
@@ -75,20 +79,33 @@ def test_escape_value():
assert result == "'\rfoo"
def fake_get_chart_csv_data_none(chart_url, auth_cookies=None):
def fake_get_chart_csv_data_none(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
"""Return ``None`` to mock a fetch that yields no payload."""
return None
def fake_get_chart_csv_data_empty(chart_url, auth_cookies=None):
# Return JSON with empty data so that the resulting DataFrame is empty
fake_result = {
def fake_get_chart_csv_data_empty(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
"""Return an encoded empty-result payload for dataframe-empty scenarios."""
fake_result: dict[str, Any] = {
"result": [{"data": {}, "coltypes": [], "colnames": [], "indexnames": []}]
}
return json.dumps(fake_result).encode("utf-8")
def fake_get_chart_csv_data_valid(chart_url, auth_cookies=None):
# Return JSON with non-temporal data and valid indexnames so that they are used.
def fake_get_chart_csv_data_valid(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
"""Return a non-temporal payload used to verify dataframe construction."""
fake_result = {
"result": [
{
@@ -103,7 +120,11 @@ def fake_get_chart_csv_data_valid(chart_url, auth_cookies=None):
return json.dumps(fake_result).encode("utf-8")
def fake_get_chart_csv_data_temporal(chart_url, auth_cookies=None):
def fake_get_chart_csv_data_temporal(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
"""
Return JSON with a temporal column and valid indexnames
so that a MultiIndex is built.
@@ -122,8 +143,12 @@ def fake_get_chart_csv_data_temporal(chart_url, auth_cookies=None):
return json.dumps(fake_result).encode("utf-8")
def fake_get_chart_csv_data_hierarchical(chart_url, auth_cookies=None):
# Return JSON with hierarchical column (list-based) and matching index names.
def fake_get_chart_csv_data_hierarchical(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
"""Return hierarchical-column mock data for MultiIndex assertions."""
fake_result = {
"result": [
{
@@ -138,7 +163,11 @@ def fake_get_chart_csv_data_hierarchical(chart_url, auth_cookies=None):
return json.dumps(fake_result).encode("utf-8")
def fake_get_chart_csv_data_with_na_values(chart_url, auth_cookies=None):
def fake_get_chart_csv_data_with_na_values(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
# Return JSON with data containing "NA" string value that will be treated as null
fake_result = {
"result": [
@@ -380,3 +409,41 @@ def test_get_chart_dataframe_preserves_na_string_values(
last_name_values = df[("last_name",)].values
assert last_name_values[0] == "Smith"
assert last_name_values[1] == "NA"
def test_get_chart_csv_data_passes_timeout_to_opener(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The timeout argument must reach the urllib opener's open() call."""
# Without a timeout the request blocks forever when the webserver is
# unreachable, wedging the report schedule in WORKING (issue #40047).
mock_response = mock.Mock()
mock_response.read.return_value = b"data"
mock_response.getcode.return_value = 200
mock_opener = mock.Mock()
mock_opener.open.return_value = mock_response
mock_opener.addheaders = []
monkeypatch.setattr(
"urllib.request.build_opener", mock.Mock(return_value=mock_opener)
)
get_chart_csv_data("http://dummy-url", auth_cookies={"session": "x"}, timeout=42)
mock_opener.open.assert_called_once_with("http://dummy-url", timeout=42)
def test_get_chart_dataframe_forwards_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_chart_dataframe must forward its timeout down to get_chart_csv_data."""
captured: dict[str, float | None] = {}
def fake(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
captured["timeout"] = timeout
return None
monkeypatch.setattr(csv, "get_chart_csv_data", fake)
get_chart_dataframe("http://dummy-url", timeout=99)
assert captured["timeout"] == 99

View File

@@ -273,6 +273,36 @@ class TestWebDriverSelenium:
# Should create driver without errors
mock_driver_class.assert_called_once()
@patch("superset.utils.webdriver.app")
def test_driver_sets_page_load_timeout(self, mock_app_patch: MagicMock) -> None:
"""driver.get() must be bounded so it can't block forever (#40047)."""
mock_app_patch.config = {
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_PAGE_LOAD_WAIT": 120,
}
mock_driver = MagicMock()
driver = WebDriverSelenium(driver_type="chrome", window=(800, 600))
with patch.object(driver, "_create", return_value=mock_driver):
assert driver.driver is mock_driver
mock_driver.set_page_load_timeout.assert_called_once_with(120)
@patch("superset.utils.webdriver.app")
def test_driver_skips_page_load_timeout_when_none(
self, mock_app_patch: MagicMock
) -> None:
"""Setting SCREENSHOT_PAGE_LOAD_WAIT to None disables the bound."""
mock_app_patch.config = {
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_PAGE_LOAD_WAIT": None,
}
mock_driver = MagicMock()
driver = WebDriverSelenium(driver_type="chrome", window=(800, 600))
with patch.object(driver, "_create", return_value=mock_driver):
assert driver.driver is mock_driver
mock_driver.set_page_load_timeout.assert_not_called()
class TestPlaywrightAvailabilityCheck:
"""Test comprehensive Playwright availability checking."""