Compare commits

..

5 Commits

Author SHA1 Message Date
Elizabeth Thompson
48f6baf8a5 Apply suggestion from @bito-code-review[bot]
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
2026-06-23 12:18:39 -07:00
Elizabeth Thompson
4106ee3636 Apply suggestion from @bito-code-review[bot]
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
2026-06-23 12:18:39 -07:00
Elizabeth Thompson
77621aaf85 Update tests/unit_tests/migrations/shared/catalogs_test.py
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
2026-06-23 12:18:39 -07:00
Elizabeth Thompson
636c52085d fix tests 2026-06-23 12:18:39 -07:00
Elizabeth Thompson
edc1777732 use local db model in migration 2026-06-23 12:18:39 -07:00
76 changed files with 631 additions and 1989 deletions

View File

@@ -42,7 +42,7 @@ runs:
fi
echo "python-version=$RESOLVED_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Python ${{ steps.set-python-version.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ steps.set-python-version.outputs.python-version }}
cache: ${{ inputs.cache }}

View File

@@ -31,7 +31,7 @@ jobs:
checks: write
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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.2.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for file changes
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
@@ -110,7 +110,7 @@ jobs:
id-token: write
steps:
- name: Checkout Code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -157,7 +157,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -207,7 +207,7 @@ jobs:
- 16379:6379
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive

View File

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

View File

@@ -30,7 +30,7 @@ jobs:
python: ${{ steps.check.outputs.python }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: recursive
@@ -61,7 +61,7 @@ jobs:
pull-requests: read
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install dependencies

View File

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

View File

@@ -32,7 +32,7 @@ jobs:
name: Generate Reports
steps:
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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.8.0",
"@jsonforms/vanilla-renderers": "^3.7.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.13.0",
"@swc/plugin-emotion": "^14.12.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.6",
"storybook": "10.4.5",
"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.8.0",
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.8.0.tgz",
"integrity": "sha512-XSvaZuQSs/MceG5nDDcrE879onPHkGBy0xEuLeZMUkSM/M8wc1dEUrJtMOZVNSITocm9YXFY1qQ5gnsPP38zAg==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
"integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.3",
"ajv": "^8.18.0",
"ajv": "^8.6.1",
"ajv-formats": "^2.1.0",
"lodash": "^4.17.21"
}
},
"node_modules/@jsonforms/react": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.8.0.tgz",
"integrity": "sha512-k81+yWLpCQl+3XizS1bLjXoBwYhW1OAkjSXFA8W5qNtfPZjSOXDgtiuMOGYDv4b60tu2e9RB8h2P2O7QhfkhiA==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
"integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"@jsonforms/core": "3.8.0",
"@jsonforms/core": "3.7.0",
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@jsonforms/vanilla-renderers": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.8.0.tgz",
"integrity": "sha512-s75TG4hSYgYLN9IRVhYtGjijqyhVXijgDhb2WnMqY+Ki7MQkLn9U7yg/l89NEpwzWS1sv0DxKUxriqVUq382og==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
"integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"@jsonforms/core": "3.8.0",
"@jsonforms/react": "3.8.0",
"@jsonforms/core": "3.7.0",
"@jsonforms/react": "3.7.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.13.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.13.0.tgz",
"integrity": "sha512-UT1l9tr934HjnktUiMGbw1rWrIXUhAByTB0DwZJwHmS8KWox+wNBIK4ZkJ2tKVU/PnQZRni+R9e6xklFkmgSYg==",
"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==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -39527,9 +39527,9 @@
}
},
"node_modules/storybook": {
"version": "10.4.6",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.6.tgz",
"integrity": "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==",
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.5.tgz",
"integrity": "sha512-QZuv1gS9Tf9RMCjDw5JOfv1XSB5IhU0uhSKQNS7l/N9zDpmSydirCspkCNT9e0zkFfPkZ9vmQUTzHY/BA07saA==",
"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 || ^0.28.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",
"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.8.0",
"@jsonforms/vanilla-renderers": "^3.7.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.13.0",
"@swc/plugin-emotion": "^14.12.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.6",
"storybook": "10.4.5",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",

View File

@@ -21,12 +21,10 @@
import d3 from 'd3';
import { extent as d3Extent } from 'd3-array';
import {
BinaryQueryObjectFilterClause,
CategoricalColorNamespace,
ContextMenuFilters,
DataMask,
ValueFormatter,
getNumberFormatter,
getSequentialSchemeRegistry,
CategoricalColorNamespace,
} from '@superset-ui/core';
import countries, { countryOptions } from './countries';
@@ -67,28 +65,9 @@ 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 {
@@ -96,15 +75,10 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
width,
height,
country,
entity,
linearColorScheme,
formatter,
colorScheme,
sliceId,
filterState,
emitCrossFilters,
onContextMenu,
setDataMask,
} = props;
const container = element;
@@ -125,15 +99,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
? colorScale(d.country_id, sliceId)
: (linearColorScale(d.metric) ?? '');
});
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 colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none';
const path = d3.geo.path();
const div = d3.select(container);
@@ -146,11 +112,6 @@ 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')
@@ -158,65 +119,40 @@ 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');
// Track mouse position to distinguish clicks from drags
let mousedownPos: { x: number; y: number } | null = null;
let centered: GeoFeature | null;
// Cross-filter support
const getCrossFilterDataMask = (
source: GeoFeature,
): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => {
if (!entity) return undefined;
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;
const selected = filterState?.selectedValues || [];
const iso = source?.properties?.ISO;
if (!iso) return undefined;
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,
};
};
// Handle right-click context menu
const handleContextMenu = (feature: GeoFeature): void => {
const pointerEvent = d3.event;
if (typeof onContextMenu === 'function') {
pointerEvent?.preventDefault();
if (hasCenter) {
const centroid = path.centroid(d);
[x, y] = centroid;
k = 4;
centered = d;
} else {
x = halfWidth;
y = halfHeight;
k = 1;
centered = null;
}
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' },
});
g.transition()
.duration(750)
.attr(
'transform',
`translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`,
);
};
backgroundRect.on('click', clicked);
const getNameOfRegion = function getNameOfRegion(
feature: GeoFeature,
): string {
@@ -229,7 +165,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
return '';
};
const updatePopupPosition = (): void => {
const updatePopupPosition = () => {
const svgHeight = svg.node().getBoundingClientRect().height;
const [x, y] = d3.mouse(svg.node());
hoverPopup
@@ -239,135 +175,36 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
};
const mouseenter = function mouseenter(
this: SVGPathElement,
d: GeoFeature,
): void {
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
// Darken color
let c: string = colorFn(d);
if (c) {
if (c !== 'none') {
c = d3.rgb(c).darker().toString();
}
d3.select(this).style('fill', c);
// 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))) : '';
const result = data.filter(
region => region.country_id === d.properties.ISO,
);
hoverPopup
.style('display', 'block')
.html(`<div><strong>${regionName}</strong><br>${metricValue}</div>`);
.html(
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
);
updatePopupPosition();
};
// Mouse move handler to update tooltip position
const mousemove = function mousemove(): void {
const mousemove = function mousemove() {
updatePopupPosition();
};
const mouseout = function mouseout(this: SVGPathElement): void {
d3.select(this).style('fill', (d: GeoFeature) => colorFn(d));
const mouseout = function mouseout(this: SVGPathElement) {
d3.select(this).style('fill', colorFn);
hoverPopup.style('display', 'none');
};
// 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 {
function drawMap(mapData: GeoData) {
const { features } = mapData;
const center = d3.geo.centroid(mapData);
const scale = 100;
@@ -378,11 +215,13 @@ 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 = Math.min(hscale, vscale);
const newScale = hscale < vscale ? hscale : vscale;
// Compute bounds and offset using the updated scale.
projection.scale(newScale);
const newBounds = path.bounds(mapData);
projection.translate([
@@ -390,45 +229,20 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
height - (newBounds[0][1] + newBounds[1][1]) / 2,
]);
const sel = mapLayer.selectAll('path.region').data(features);
sel
// Draw each province as a path
mapLayer
.selectAll('path')
.data(features)
.enter()
.append('path')
.attr('class', 'region')
.attr('vector-effect', 'non-scaling-stroke');
// Apply attributes and event handlers to all elements (enter + update)
mapLayer
.selectAll('path.region')
.attr('d', path)
.attr('class', 'region')
.attr('vector-effect', 'non-scaling-stroke')
.style('fill', colorFn)
.on('mouseenter', mouseenter)
.on('mousemove', mousemove)
.on('mouseout', mouseout)
.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();
.on('click', clicked);
}
const map = maps[country];

View File

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

View File

@@ -19,18 +19,8 @@
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,
@@ -59,8 +49,6 @@ export default function transformProps(chartProps: ChartProps) {
detectedCurrency,
);
const { onContextMenu, setDataMask } = hooks;
return {
width,
height,
@@ -71,10 +59,5 @@ export default function transformProps(chartProps: ChartProps) {
colorScheme,
sliceId,
formatter,
entity,
onContextMenu,
setDataMask,
emitCrossFilters,
filterState,
};
}

View File

@@ -133,11 +133,10 @@ describe('CountryMap (legacy d3)', () => {
expect(popup!).toHaveStyle({ display: 'none' });
});
test('emits a cross-filter data mask when a region is clicked', () => {
test('shows tooltip on mouseenter/mousemove/mouseout', async () => {
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
cb(null, mockMapData),
);
const setDataMask = jest.fn();
render(
<ReactCountryMap
@@ -148,101 +147,19 @@ 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();
// 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!);
const popup = document.querySelector('.hover-popup');
expect(popup).not.toBeNull();
expect(setDataMask).toHaveBeenCalledTimes(1);
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }],
},
filterState: expect.objectContaining({ value: ['CAN'] }),
}),
);
});
fireEvent.mouseEnter(region!);
expect(popup!).toHaveStyle({ display: 'block' });
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',
});
fireEvent.mouseOut(region!);
expect(popup!).toHaveStyle({ display: 'none' });
});
});

View File

@@ -1,76 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
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,20 +160,6 @@ 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,7 +74,6 @@ export default function transformProps(
yAxisTitlePosition,
sliceId,
zoomable,
yAxisSlider,
} = formData as BoxPlotQueryFormData;
const refs: Refs = {};
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
@@ -258,28 +257,6 @@ 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,
@@ -321,7 +298,15 @@ export default function transformProps(
},
},
},
dataZoom,
dataZoom: zoomable
? [
{
type: 'inside',
zoomOnMouseWheel: false,
moveOnMouseWheel: true,
},
]
: [],
};
return {

View File

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

View File

@@ -71,15 +71,6 @@ 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({
@@ -134,41 +125,4 @@ 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;
}
export const buildQuery: BuildQuery<TableChartFormData> = (
const buildQuery: BuildQuery<TableChartFormData> = (
formData: TableChartFormData,
options,
) => {
@@ -217,17 +217,6 @@ export 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 || '') ||
@@ -240,24 +229,11 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
}
if (!isDownloadQuery && formDataCopy.server_pagination) {
// 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;
const pageSize = ownState.pageSize ?? formDataCopy.server_page_length;
const currentPage = ownState.currentPage ?? 0;
moreProps.row_limit =
configuredRowLimit > 0 ? Math.min(pageSize, remainingRows) : pageSize;
moreProps.row_offset = rowOffset;
moreProps.row_limit = pageSize;
moreProps.row_offset = currentPage * pageSize;
}
// getting sort by in case of server pagination from own state
@@ -287,19 +263,11 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !==
JSON.stringify(queryObject.filters)
) {
// 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,
};
queryObject = { ...queryObject, row_offset: 0 };
const modifiedOwnState = {
...options?.ownState,
currentPage: 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,
pageSize: queryObject.row_limit ?? 0,
};
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
}

View File

@@ -17,9 +17,7 @@
* under the License.
*/
import { QueryMode, TimeGranularity, VizType } from '@superset-ui/core';
import buildQuery, {
buildQuery as buildQueryUncached,
} from '../src/buildQuery';
import buildQuery from '../src/buildQuery';
import { TableChartFormData } from '../src/types';
const basicFormData: TableChartFormData = {
@@ -280,172 +278,6 @@ 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,27 +200,6 @@ 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,20 +604,8 @@ 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 === normalizedId &&
unsavedQueryEditor?.id === action.queryEditor!.id &&
unsavedQueryEditor.sql === action.sql
) {
return state;
@@ -630,7 +618,7 @@ export default function sqlLabReducer(
sql: action.sql ?? undefined,
...(action.queryId && { latestQueryId: action.queryId }),
},
normalizedId,
action.queryEditor!.id!,
),
};
},

View File

@@ -524,11 +524,7 @@ class BaseReportState:
self._update_query_context()
try:
csv_data = get_chart_csv_data(
chart_url=url,
auth_cookies=auth_cookies,
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
)
csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies)
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
logger.info(
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
@@ -578,11 +574,7 @@ class BaseReportState:
self._update_query_context()
try:
dataframe = get_chart_dataframe(
url,
auth_cookies,
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
)
dataframe = get_chart_dataframe(url, auth_cookies)
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,12 +1153,6 @@ 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
@@ -1742,11 +1736,6 @@ 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
@@ -2095,12 +2084,6 @@ 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
@@ -2145,12 +2128,6 @@ 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,20 +294,6 @@ 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,12 +31,7 @@ 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,
parse_boolean_string,
SqlExpressionType,
)
from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType
from superset.views.base_api import BaseSupersetApi, statsd_metrics
logger = logging.getLogger(__name__)
@@ -130,63 +125,13 @@ 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"
@@ -200,31 +145,6 @@ 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,16 +139,6 @@ 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,7 +75,6 @@ _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,10 +120,8 @@ 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")
@@ -410,30 +408,6 @@ 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."""
@@ -538,16 +512,6 @@ 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,55 +30,6 @@ 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",
@@ -105,8 +56,8 @@ async def create_virtual_dataset(
3. Use the returned ``columns`` list to pick columns for the chart config
"""
await ctx.info(
f"Creating virtual dataset: database_id={request.database_id}, "
f"dataset_name={request.dataset_name!r}"
"Creating virtual dataset: database_id=%s, dataset_name=%r"
% (request.database_id, request.dataset_name)
)
try:
@@ -114,7 +65,6 @@ 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
@@ -135,14 +85,6 @@ 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 = (
@@ -151,8 +93,8 @@ async def create_virtual_dataset(
)
await ctx.info(
f"Virtual dataset created: id={dataset.id}, "
f"dataset_name={dataset.table_name!r}, columns={columns}"
"Virtual dataset created: id=%s, dataset_name=%r, columns=%s"
% (dataset.id, dataset.table_name, columns)
)
return CreateVirtualDatasetResponse(
@@ -166,7 +108,7 @@ async def create_virtual_dataset(
except DatasetInvalidError as exc:
messages = exc.normalized_messages()
await ctx.warning(f"Virtual dataset validation failed: {messages}")
await ctx.warning("Virtual dataset validation failed: %s" % (messages,))
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
@@ -177,7 +119,7 @@ async def create_virtual_dataset(
error=str(messages),
)
except DatasetCreateFailedError as exc:
await ctx.error(f"Virtual dataset creation failed: {exc}")
await ctx.error("Virtual dataset creation failed: %s" % (str(exc),))
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
@@ -187,19 +129,9 @@ 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(
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
"Unexpected error creating virtual dataset: %s: %s"
% (type(exc).__name__, str(exc))
)
raise

View File

@@ -27,7 +27,9 @@ from flask import current_app
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import lazyload, Session
from superset import db, security_manager
# 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.db_engine_specs.base import GenericDBException
from superset.migrations.shared.security_converge import (
add_pvms,
@@ -35,13 +37,80 @@ 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, timezone
from datetime import datetime, timedelta
from typing import (
Any,
Callable,
@@ -2765,15 +2765,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if tf:
if tf in {"epoch_ms", "epoch_s"}:
# 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())
seconds_since_epoch = int(dttm.timestamp())
if tf == "epoch_s":
return str(seconds_since_epoch)
return str(seconds_since_epoch * 1000)

View File

@@ -73,21 +73,9 @@ def stringify_values(array: NDArray[Any]) -> NDArray[Any]:
obj[na_obj] = None
else:
try:
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)
# for simple string conversions
# this handles odd character types better
obj[...] = obj.astype(str)
except ValueError:
obj[...] = stringify(obj)

View File

@@ -543,28 +543,11 @@ 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: query
name: tagIds
- in: path
schema:
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
type: integer
name: tag_id
responses:
200:
description: List of tagged objects associated with a Tag

View File

@@ -938,11 +938,6 @@ 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:")
@@ -953,27 +948,17 @@ 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, timeout=smtp_timeout
)
smtplib.SMTP_SSL(smtp_host, smtp_port, context=ssl_context)
if smtp_ssl
else smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
else smtplib.SMTP(smtp_host, smtp_port)
)
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
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()
def recipients_string_to_list(address_string: str | None) -> list[str]:

View File

@@ -90,18 +90,14 @@ 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,
timeout: Optional[float] = None,
chart_url: str, auth_cookies: Optional[dict[str, str]] = 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))
# 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)
response = opener.open(chart_url)
content = response.read()
if response.getcode() != 200:
raise URLError(response.getcode())
@@ -111,13 +107,11 @@ def get_chart_csv_data(
def get_chart_dataframe(
chart_url: str,
auth_cookies: Optional[dict[str, str]] = None,
timeout: Optional[float] = None,
chart_url: str, auth_cookies: Optional[dict[str, str]] = 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, timeout)
content = get_chart_csv_data(chart_url, auth_cookies)
if content is None:
return None

View File

@@ -48,11 +48,7 @@ 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"],
timeout=app.config["SLACK_API_TIMEOUT"],
)
client = WebClient(token=token, proxy=app.config["SLACK_PROXY"])
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,23 +502,9 @@ class WebDriverSelenium(WebDriverProxy):
self._driver = self._create()
if not self._driver:
raise RuntimeError("WebDriver creation failed")
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
self._driver.set_window_size(*self._window)
if self._user:
self._auth(self._user)
return self._driver
def _create_firefox_driver(

View File

@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from unittest.mock import ANY, patch
import pytest
@@ -24,21 +23,12 @@ 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)
@@ -215,123 +205,6 @@ 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,9 +193,7 @@ 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"],
timeout=current_app.config["SMTP_TIMEOUT"],
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
)
assert mock_smtp.return_value.starttls.called
mock_smtp.return_value.login.assert_called_with(
@@ -220,7 +218,6 @@ 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")
@@ -238,7 +235,6 @@ 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
@@ -270,9 +266,7 @@ class TestEmailSmtp(SupersetTestCase):
)
assert not mock_smtp_ssl.called
mock_smtp.assert_called_with(
current_app.config["SMTP_HOST"],
current_app.config["SMTP_PORT"],
timeout=current_app.config["SMTP_TIMEOUT"],
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
)
assert not mock_smtp.login.called
current_app.config["SMTP_USER"] = smtp_user
@@ -287,36 +281,6 @@ 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,11 +2007,7 @@ 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", # noqa: S106
proxy=None,
timeout=30,
)
slack_client_mock_class.assert_called_with(token="cool_code", proxy=None) # noqa: S106
assert_log(ReportState.SUCCESS)

View File

@@ -810,64 +810,6 @@ 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,34 +1805,14 @@ 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 and metrics."""
"""Create a mock virtual dataset object with configurable columns."""
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
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
dataset.columns = [MagicMock(column_name=c) for c in column_names]
return dataset
@@ -2097,250 +2077,6 @@ 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,36 +62,106 @@ 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.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
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
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
mocker.patch.object(
Database,
"get_all_schema_names",
return_value=["public", "information_schema"],
)
mocker.patch.object(
Database,
"get_all_catalog_names",
return_value=["db", "other_catalog"],
# 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,
)
mocker.patch.object(
Database,
"get_default_catalog",
return_value="db",
)
# 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(
database_name="my_db",
id=1,
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(
table_name="my_table",
database=database,
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
catalog=None,
schema="public",
catalog_perm=None,
@@ -101,33 +171,26 @@ 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(
client_id="foo",
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
database=database,
sql="SELECT * FROM public.t",
db_id=database.id,
catalog=None,
schema="public",
)
tab_state = TabState(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()
@@ -158,8 +221,9 @@ def test_upgrade_catalog_perms(mocker: MockerFixture, session: Session) -> None:
# add dataset/chart in new catalog
new_dataset = SqlaTable(
table_name="my_table",
database=database,
id=2,
database_id=database.id,
perm="[my_db].[my_table](id:2)",
catalog="other_catalog",
schema="public",
schema_perm="[my_db].[other_catalog].[public]",
@@ -168,8 +232,17 @@ 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,
)
@@ -186,21 +259,24 @@ 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 (
assert sorted(
session.query(ViewMenu.name, Permission.name)
.join(PermissionView, ViewMenu.id == PermissionView.view_menu_id)
.join(Permission, PermissionView.permission_id == Permission.id)
.all()
) == [
("[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"),
]
) == 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"),
]
)
# do a downgrade
downgrade_catalog_perms()
@@ -245,32 +321,94 @@ def test_upgrade_catalog_perms_graceful(
catalog browsing on the database (permissions are always synced on a DB update, see
`UpdateDatabaseCommand`).
"""
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
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
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,
"get_all_schema_names",
side_effect=Exception("Failed to connect to the database"),
"db_engine_spec",
new_callable=mocker.PropertyMock,
return_value=mock_db_engine_spec,
)
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(
database_name="my_db",
id=1,
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(
table_name="my_table",
database=database,
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
catalog=None,
schema="public",
schema_perm="[my_db].[public]",
@@ -279,31 +417,26 @@ 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(
client_id="foo",
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
database=database,
sql="SELECT * FROM public.t",
db_id=database.id,
catalog=None,
schema="public",
)
tab_state = TabState(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()
@@ -370,13 +503,21 @@ 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.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
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
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")
@@ -386,14 +527,64 @@ 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(
database_name="my_db",
id=1,
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(
table_name="my_table",
database=database,
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
catalog=None,
schema="public",
schema_perm="[my_db].[public]",
@@ -402,31 +593,26 @@ 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(
client_id="foo",
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
database=database,
sql="SELECT * FROM public.t",
db_id=database.id,
catalog=None,
schema="public",
)
tab_state = TabState(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()
@@ -494,14 +680,22 @@ def test_upgrade_catalog_perms_simplified_migration(
This should only update existing permissions + create a new permission
for the default catalog.
"""
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
from superset.migrations.shared.catalogs import (
Database,
Query,
SavedQuery,
Slice,
SqlaTable,
TableSchema,
TabState,
)
from superset.migrations.shared.security_converge import Base as SecurityBase
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
@@ -510,13 +704,65 @@ 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(
database_name="my_db",
id=1,
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(
table_name="my_table",
database=database,
id=1,
database_id=database.id,
perm="[my_db].[my_table](id:1)",
catalog=None,
schema="public",
schema_perm="[my_db].[public]",
@@ -525,31 +771,26 @@ 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(
client_id="foo",
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
saved_query = SavedQuery(
database=database,
sql="SELECT * FROM public.t",
db_id=database.id,
catalog=None,
schema="public",
)
tab_state = TabState(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
table_schema = TableSchema(
database=database,
database_id=database.id,
catalog=None,
schema="public",
)
session.add_all([chart, query, saved_query, tab_state, table_schema])
session.commit()

View File

@@ -450,93 +450,3 @@ 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,9 +16,6 @@
# under the License.
from typing import Any
from unittest import mock
import pandas as pd
import pyarrow as pa
import pytest # noqa: F401
@@ -28,7 +25,6 @@ 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,
)
@@ -79,33 +75,20 @@ def test_escape_value():
assert result == "'\rfoo"
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."""
def fake_get_chart_csv_data_none(chart_url, auth_cookies=None):
return None
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] = {
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 = {
"result": [{"data": {}, "coltypes": [], "colnames": [], "indexnames": []}]
}
return json.dumps(fake_result).encode("utf-8")
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."""
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.
fake_result = {
"result": [
{
@@ -120,11 +103,7 @@ def fake_get_chart_csv_data_valid(
return json.dumps(fake_result).encode("utf-8")
def fake_get_chart_csv_data_temporal(
chart_url: str,
auth_cookies: dict[str, str] | None = None,
timeout: float | None = None,
) -> bytes | None:
def fake_get_chart_csv_data_temporal(chart_url, auth_cookies=None):
"""
Return JSON with a temporal column and valid indexnames
so that a MultiIndex is built.
@@ -143,12 +122,8 @@ def fake_get_chart_csv_data_temporal(
return json.dumps(fake_result).encode("utf-8")
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."""
def fake_get_chart_csv_data_hierarchical(chart_url, auth_cookies=None):
# Return JSON with hierarchical column (list-based) and matching index names.
fake_result = {
"result": [
{
@@ -163,11 +138,7 @@ def fake_get_chart_csv_data_hierarchical(
return json.dumps(fake_result).encode("utf-8")
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:
def fake_get_chart_csv_data_with_na_values(chart_url, auth_cookies=None):
# Return JSON with data containing "NA" string value that will be treated as null
fake_result = {
"result": [
@@ -409,41 +380,3 @@ 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,36 +273,6 @@ 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."""