Compare commits

..
Author SHA1 Message Date
Diego PucciandClaude Opus 5 47f0e5f28f fix(mcp): snapshot chart fields in get_chart_data before session expiry
get_chart_data reads the chart Slice throughout a long async function.
Exiting an event_logger.log_context() commits the session
(DBEventLogger.log -> db.session.commit), and with the default
expire_on_commit=True that commit expires every loaded attribute. If the
instance is also detached before the next read, that read raises
DetachedInstanceError, which the broad SQLAlchemyError handler turns into
a confusing internal-session error instead of chart data.

Copy the values the function needs into plain locals immediately after
the lookup, while the instance is still attached. Locals are immune to
both expiry and detachment. Chart reads here are interleaved with four
committing log_context blocks, so keeping the ORM object alive across
them is not workable; db.session.refresh() in particular does not help,
because the next commit expires exactly what it just loaded.

The export helpers and the two chart_helpers query builders also read
chart attributes long after those commits, so they are handed a
_ChartFacts NamedTuple instead of the ORM instance. Its field names match
the Slice columns, so helpers reading chart.viz_type or
getattr(chart, "datasource_id", None) behave identically. Audited: those
consumers touch only id, slice_name, viz_type, datasource_id and
datasource_type. guest_scope still receives the real Slice, since
query_context.slice_ needs the mapped object.

The subqueryload(Slice.table).subqueryload(SqlaTable.metrics) eager-load
from 39483 is untouched.

The not-found return moves inside the lookup block so the snapshot can be
unconditional; the log context still records the action on that path.

Regression test drives the tool end to end with a chart that detaches at
the end of the lookup block, for json, csv and excel. It fails against
master's get_chart_data on all three and passes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:41:55 +03:00
94 changed files with 5844 additions and 6709 deletions
-127
View File
@@ -1,127 +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.
# Publishes superset-frontend's Storybook to Chromatic for visual
# regression testing. See https://www.chromatic.com/docs/github-actions
#
# Runs on pushes to master (keeps the Chromatic baseline in sync with
# mainline) and on pull requests that touch superset-frontend. Fork PRs
# don't receive CHROMATIC_PROJECT_TOKEN -- GitHub withholds repository
# secrets from pull_request runs triggered by a fork -- so the publish
# steps below no-op for them (via the CHROMATIC_PROJECT_TOKEN != '' guard)
# rather than failing.
#
# Non-blocking for now (exitZeroOnChanges: true): visual changes are
# surfaced as a PR check/comment for review, not enforced as a merge gate.
# A prior Chromatic setup here (#21095) was removed in #27232 for being
# unmaintained and overlapping with Applitools (since also discontinued).
# Keep this one simple and watch it before considering a required check.
name: Chromatic
on:
push:
branches:
- master
paths:
- "superset-frontend/**"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
paths:
- "superset-frontend/**"
workflow_dispatch: {}
# cancel previous workflow jobs for PRs
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
env:
TAG: apache/superset:chromatic-${{ github.run_id }}
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
permissions:
contents: read
jobs:
chromatic:
runs-on: ubuntu-26.04
timeout-minutes: 30
# pull-requests: write lets chromaui/action post its check and PR
# comment. Withheld automatically by GitHub for fork-triggered
# pull_request runs, same as CHROMATIC_PROJECT_TOKEN above.
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout Code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# TurboSnap (onlyChanged below) needs history to diff against the
# baseline commit, but NOT fetch-depth: 0. This Chromatic project
# was dormant for ~2 years (Chromatic here shipped in #21095,
# removed in #27232) before this workflow, so its last known
# baseline predates thousands of commits on a very active repo.
# With full history, Chromatic's CLI tries to `git log` every
# commit back to that ancient baseline as individual CLI args and
# hits the OS ARG_MAX limit (E2BIG) -- see
# https://github.com/chromaui/chromatic-cli/issues/432, where the
# Chromatic team's own recommended workaround is exactly this: a
# bounded depth, since a multi-year-old baseline isn't useful
# anyway. 500 is far more than any realistic PR needs once a
# recent baseline exists (i.e. after this workflow's own first
# successful run on master).
fetch-depth: 500
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Build Docker Image
if: ${{ env.CHROMATIC_PROJECT_TOKEN != '' }}
run: |
docker buildx build \
-t $TAG \
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
--target superset-node-ci \
.
# --webpack-stats-json writes preview-stats.json into storybook-static.
# TurboSnap (onlyChanged below) needs it to trace which stories a
# changed file affects; without it, chromaui/action fails with "Could
# not retrieve dependent story files" since storybookBuildDir points
# at an already-built Storybook it can't inject its own stats
# collection into.
- name: Build Storybook
if: ${{ env.CHROMATIC_PROJECT_TOKEN != '' }}
run: |
mkdir -p ${{ github.workspace }}/superset-frontend/storybook-static
docker run \
-v ${{ github.workspace }}/superset-frontend/storybook-static:/app/superset-frontend/storybook-static \
--rm $TAG \
bash -c "npm i && npm run build-storybook -- --webpack-stats-json"
- name: Publish to Chromatic
if: ${{ env.CHROMATIC_PROJECT_TOKEN != '' }}
uses: chromaui/action@6b31c4307e3f5a150ab5345b051bb40a62923a5f # v18.7.3
with:
projectToken: ${{ env.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
workingDir: superset-frontend
storybookBuildDir: storybook-static
# TurboSnap: only re-snapshot stories affected by files changed
# since the baseline build.
onlyChanged: true
exitZeroOnChanges: true
zip: true
@@ -49,7 +49,7 @@ jobs:
# allowlist (only v8.1.0+ are, at apache/infrastructure-actions'
# actions.yml). Needs an INFRA request before this can de-vendor too.
- name: Set up chart-testing
uses: $/.github/actions/chart-testing-action
uses: ./.github/actions/chart-testing-action
- name: Run chart-testing (list-changed)
id: list-changed
+8 -43
View File
@@ -42,7 +42,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: $/.github/actions/change-detector/
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -109,25 +109,13 @@ jobs:
submodules: recursive
# -------------------------------------------------------
- name: Setup Python
uses: $/.github/actions/setup-backend/
uses: ./.github/actions/setup-backend/
- name: Setup postgres
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: playwright_testdata
- name: Setup Node.js
@@ -137,42 +125,19 @@ jobs:
cache: "npm"
cache-dependency-path: "superset-frontend/package-lock.json"
- name: Install npm dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Install Playwright
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright (Experimental Tests)
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
@@ -34,7 +34,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: $/.github/actions/change-detector/
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -75,15 +75,9 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: $/.github/actions/setup-backend/
uses: ./.github/actions/setup-backend/
- name: Setup MySQL
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: setup-mysql
- name: Start Celery worker
+25 -56
View File
@@ -231,6 +231,20 @@ RUN /app/docker/apt-install.sh \
# The database file will be created at runtime when examples are loaded from Parquet files
RUN mkdir -p /app/data && chown -R superset:superset /app/data
# Copy compiled things from previous stages
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
# TODO in the meantime, remove the .po files
RUN rm superset/translations/*/*/*.po
# Merging translations from backend and frontend stages
COPY --from=superset-node /app/superset/translations superset/translations
COPY --from=python-translation-compiler /app/translations_mo superset/translations
# --- Realtime WebSocket server (part of the official image) ---------------
# The realtime transport (superset-websocket) is a Node service, bundled by
# esbuild into a single self-contained file. Copy the Node runtime plus that
@@ -253,10 +267,7 @@ EXPOSE ${SUPERSET_PORT}
######################################################################
FROM python-common AS lean
# Install Python dependencies using docker/pip-install.sh.
# Requirements are installed *before* the application source is copied
# below so that source-only changes don't bust this (slow, network-bound)
# cache layer or defeat --cache-from.
# Install Python dependencies using docker/pip-install.sh
COPY requirements/base.txt requirements/
# Copy superset-core package needed for editable install in base.txt
@@ -264,27 +275,9 @@ COPY superset-core superset-core
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
/app/docker/pip-install.sh --requires-build-essential -r requirements/base.txt
# Copy compiled frontend assets and application source now that
# dependencies have been resolved and cached above.
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionally as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
# TODO in the meantime, remove the .po files
RUN rm superset/translations/*/*/*.po
# Merging translations from backend and frontend stages
COPY --from=superset-node /app/superset/translations superset/translations
COPY --from=python-translation-compiler /app/translations_mo superset/translations
# Install the superset package itself. --no-deps because its dependencies
# were already installed from requirements/base.txt above, so this layer
# stays fast even though the source copy above changes on every edit.
# Install the superset package
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
uv pip install -e . --no-deps
uv pip install -e .
RUN python -m compileall /app/superset
USER superset
@@ -300,46 +293,22 @@ RUN /app/docker/apt-install.sh \
pkg-config \
default-libmysqlclient-dev
# Copy development requirements and install them *before* the application
# source is copied below, so source-only edits don't bust this cache layer.
# Copy development requirements and install them
COPY requirements/*.txt requirements/
# Copy local packages needed for editable installs in development.txt
COPY superset-core superset-core
COPY superset-extensions-cli superset-extensions-cli
# requirements/development.txt is generated by `uv pip compile` and embeds
# `-e .` (an editable install of this same package) as its first line. That
# self-reference needs the full superset/ source tree, which hasn't been
# copied in yet at this point, so it's stripped here; the real editable
# install of `.` runs below, once the source is present.
# Install Python dependencies using docker/pip-install.sh
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
grep -vxF -- "-e ." requirements/development.txt > requirements/development-deps.txt && \
/app/docker/pip-install.sh --requires-build-essential -r requirements/development-deps.txt
# Copy compiled frontend assets and application source now that
# dependencies have been resolved and cached above.
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionally as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
# TODO in the meantime, remove the .po files
RUN rm superset/translations/*/*/*.po
# Merging translations from backend and frontend stages
COPY --from=superset-node /app/superset/translations superset/translations
COPY --from=python-translation-compiler /app/translations_mo superset/translations
# Install the superset package together with its postgres extra, using the
# same uv cache mount as the requirements install above. --no-deps because
# all dependencies (including the postgres extra's psycopg2-binary) are
# already installed from requirements/development.txt above.
# NOTE: source is bind-mounted over /app/superset in DEV_MODE, so a
# compileall pass here would be wasted work; unlike `lean`, `dev` skips it.
/app/docker/pip-install.sh --requires-build-essential -r requirements/development.txt
# Install the superset package
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
uv pip install -e .[postgres] --no-deps
uv pip install -e .
RUN uv pip install .[postgres]
RUN python -m compileall /app/superset
USER superset
-2
View File
@@ -1233,8 +1233,6 @@ Custom time ranges that use the "Now" or "Today" anchor (for the Start, End, or
Charts and dashboards using these anchors will compute a different (correct) timestamp after upgrading; if a chart's filters or drill-downs were tuned to compensate for the old offset, review them after upgrading.
- [43916](https://github.com/apache/superset/pull/43916): The `docker-compose` dev loop now skips re-running `superset load_examples` on every `docker compose up` once the example data and dashboards are present in the databases (set `SUPERSET_FORCE_LOAD_EXAMPLES=yes` to reload them anyway), and the `superset-node` service now defaults `DISABLE_TS_CHECKER=true` like `docker-compose-light.yml` already did, skipping webpack's TypeScript type-checking pass in dev by default.
## 6.1.0
### ClickHouse minimum driver version bump
-1
View File
@@ -138,7 +138,6 @@ services:
condition: service_started
volumes: *superset-volumes
environment:
SUPERSET_FORCE_LOAD_EXAMPLES: "${SUPERSET_FORCE_LOAD_EXAMPLES:-}"
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
-3
View File
@@ -183,8 +183,6 @@ services:
condition: service_started
user: *superset-user
volumes: *superset-volumes
environment:
SUPERSET_FORCE_LOAD_EXAMPLES: "${SUPERSET_FORCE_LOAD_EXAMPLES:-}"
healthcheck:
disable: true
@@ -204,7 +202,6 @@ services:
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
-6
View File
@@ -73,12 +73,6 @@ SUPERSET_ENV=development
# Swagger UI is opt-in (off by default); enable it for local development.
SUPERSET_ENABLE_SWAGGER_UI=true
SUPERSET_LOAD_EXAMPLES=yes
# Once the example data and dashboards are present in the databases,
# `docker-init.sh` skips `superset load_examples` on later runs. Set to "yes"
# (or run `SUPERSET_FORCE_LOAD_EXAMPLES=yes docker compose up`) to reload the
# examples anyway, e.g. after changing the example datasets or after a partial
# load.
#SUPERSET_FORCE_LOAD_EXAMPLES=no
CYPRESS_CONFIG=false
SUPERSET_PORT=8088
MAPBOX_API_KEY=''
+3 -33
View File
@@ -66,44 +66,14 @@ echo_step "3" "Starting" "Setting up roles and perms"
superset init
echo_step "3" "Complete" "Setting up roles and perms"
# Loading examples parses and inserts every example dataset, chart and
# dashboard and is one of the slowest steps of `docker compose up`. Rather
# than trusting a marker file (which goes stale as soon as the database volume
# is recreated), ask the databases themselves: when both the example data and
# the dashboards imported from it are present, the previous load completed and
# there is nothing left to redo. Any failure here (missing tables, unreachable
# database, import error) simply reports "not loaded" so the full load runs.
examples_already_loaded() {
python - <<'PY' 2>/dev/null
import sys
from superset.app import create_app
from superset.sql.parse import Table
app = create_app()
with app.app_context():
from superset import db
from superset.models.dashboard import Dashboard
from superset.utils.database import get_example_database
has_dashboard = (
db.session.query(Dashboard).filter_by(slug="world_health").first() is not None
)
has_data = get_example_database().has_table(Table("wb_health_population"))
sys.exit(0 if has_dashboard and has_data else 1)
PY
}
if [ "$SUPERSET_LOAD_EXAMPLES" = "yes" ]; then
# Load some data to play with
echo_step "4" "Starting" "Loading examples"
# Cypress runs always load, since they need a distinct set of test data
# (`--load-test-data`) in a separate database. Set
# SUPERSET_FORCE_LOAD_EXAMPLES=yes to reload the examples regardless.
# If Cypress run which consumes superset_test_config load required data for tests
if [ "$CYPRESS_CONFIG" == "true" ]; then
superset load_examples --load-test-data
elif [ "$SUPERSET_FORCE_LOAD_EXAMPLES" != "yes" ] && examples_already_loaded; then
echo "Examples already loaded, skipping (set SUPERSET_FORCE_LOAD_EXAMPLES=yes to reload them)"
else
superset load_examples
fi
@@ -196,9 +196,7 @@ One important variable is `SUPERSET_LOAD_EXAMPLES` which determines whether the
container will populate example data and visualizations into the metadata database. These examples
are helpful for learning and testing out Superset but unnecessary for experienced users and
production deployments. The loading process can sometimes take a few minutes and a good amount of
CPU, so you may want to disable it on a resource-constrained device. Once the example data and
dashboards are present in the databases, later `superset_init` runs skip loading them; run
`SUPERSET_FORCE_LOAD_EXAMPLES=yes docker compose up` to reload the examples anyway.
CPU, so you may want to disable it on a resource-constrained device.
For more advanced or dynamic configurations that are typically managed in a `superset_config.py` file
located in your `PYTHONPATH`, note that it can be done by providing a
@@ -99,16 +99,11 @@ Affecting the Docker build process:
- **INCLUDE_CHROMIUM (default=false):** whether to include the Chromium headless browser in the build
- **BUILD_TRANSLATIONS(default=false):** whether to compile the translations from the .po files available
- **SUPERSET_LOAD_EXAMPLES (default=yes):** whether to load the examples into the database upon startup,
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`. Once the example
data and dashboards are present in the databases, later `docker compose up` runs skip loading
them; run `SUPERSET_FORCE_LOAD_EXAMPLES=yes docker compose up` to reload the examples anyway.
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`
- **SUPERSET_LOG_LEVEL (default=info)**: Can be set to debug, info, warning, error, critical
for more verbose logging
- **SUPERSET_DEBUG_ENABLED (default=false)**: Enable Werkzeug debugger with interactive console.
Set to `true` for debugging: `SUPERSET_DEBUG_ENABLED=true docker compose up`
- **DISABLE_TS_CHECKER (default=true)**: whether the `superset-node` webpack dev server skips
TypeScript type-checking, which speeds up rebuilds and saves several GB of memory. Set to
`false` to have webpack surface type errors during development.
For more env vars that affect your configuration, see this
[superset_config.py](https://github.com/apache/superset/blob/master/docker/pythonpath_dev/superset_config.py)
+1 -1
View File
@@ -93,7 +93,7 @@ Look through the GitHub issues. Issues tagged with
Superset could always use better documentation,
whether as part of the official Superset docs,
in docstrings, Markdown files in `docs/`, or even on the web as blog posts or
in docstrings, `docs/*.rst` or even on the web as blog posts or
articles. See [Documentation](./howtos.md#contributing-to-documentation) for more details.
### Add Translations
+1 -1
View File
@@ -88,7 +88,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"oxfmt": "^0.66.0",
"oxlint": "^1.81.0",
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"typescript": "7.0.2",
"webpack": "^5.110.3"
+99 -99
View File
@@ -3203,100 +3203,100 @@
resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2001.tgz#814bcdd2707fa8ab1ae0f0b51a7243b034d2833a"
integrity sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==
"@oxlint/binding-android-arm-eabi@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz#86a0305480e680431429c8259c5701ac5592e4a7"
integrity sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==
"@oxlint/binding-android-arm-eabi@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz#924b041cbcea4e934fd9ef66a2d2b7d7463c0180"
integrity sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==
"@oxlint/binding-android-arm64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz#dba51cb2a1258f37eaca15a4e74d5d6ee19f238c"
integrity sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==
"@oxlint/binding-android-arm64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz#d1716d2be903de06b4fdb2fdaa121a2943695c35"
integrity sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==
"@oxlint/binding-darwin-arm64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz#f384ae1eddd399429281f88a0073729e287ec0b1"
integrity sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==
"@oxlint/binding-darwin-arm64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz#475eb4061db4d4fe9349e92f15962c0203e8ebec"
integrity sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==
"@oxlint/binding-darwin-x64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz#7167e3b4c18852fcd1ddade536d232d73d55d30c"
integrity sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==
"@oxlint/binding-darwin-x64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz#602fa5681dd746c0fb5ca7a0e4a81d0dd07b7f90"
integrity sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==
"@oxlint/binding-freebsd-x64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz#64f230765343bfe7e2280b071bb191bb666303ac"
integrity sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==
"@oxlint/binding-freebsd-x64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz#4e0490c344726fd0b1a027afc129fb9c78f069ec"
integrity sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==
"@oxlint/binding-linux-arm-gnueabihf@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz#5095f021a28009146f7536a7ccd518cdfcbfc3bf"
integrity sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==
"@oxlint/binding-linux-arm-gnueabihf@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz#82d215fc05e046a0ec085821c1da8ab99d35fbdc"
integrity sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==
"@oxlint/binding-linux-arm-musleabihf@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz#d894dcfb6fb1b8b224b79e6ce07b17403f413749"
integrity sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==
"@oxlint/binding-linux-arm-musleabihf@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz#92068e3b51cd50fc5a83a3ebfba7925313d6cd11"
integrity sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==
"@oxlint/binding-linux-arm64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz#bf8e362c87e7828ba28c2ec36bae73ad82989ef1"
integrity sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==
"@oxlint/binding-linux-arm64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz#6c81ddc85dd5b79070f87401d61813172068666d"
integrity sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==
"@oxlint/binding-linux-arm64-musl@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz#2aa5214eb5b032e8f77d94a27c9bd3f6dbc446f3"
integrity sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==
"@oxlint/binding-linux-arm64-musl@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz#11216704b606d67e946a868850243dc2739ec92e"
integrity sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==
"@oxlint/binding-linux-ppc64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz#a0af1af6fd309214555f33b4fa0432f3ec45f242"
integrity sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==
"@oxlint/binding-linux-ppc64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz#125302a0e6732e32e4a7d53f0212d58f1b13af01"
integrity sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==
"@oxlint/binding-linux-riscv64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz#60413a335a63e0911a625d047690e41c03f2a833"
integrity sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==
"@oxlint/binding-linux-riscv64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz#03846614f184ed3dcbab4bffad7417fd54cd0967"
integrity sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==
"@oxlint/binding-linux-riscv64-musl@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz#5f1dd7bc2bdbe92fa5454df081ee91fa2c499948"
integrity sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==
"@oxlint/binding-linux-riscv64-musl@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz#49e0fb90e1c8358429b9989a407f4020c56d8ea9"
integrity sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==
"@oxlint/binding-linux-s390x-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz#830c5bdd145ad39c9b5371e3214d0a9f9c1f46b1"
integrity sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==
"@oxlint/binding-linux-s390x-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz#7aee2ae2426f7bdcae77969073a015a6b3f8373e"
integrity sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==
"@oxlint/binding-linux-x64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz#7f72ea3c9ae70a76c1a92e56a5755869f335e756"
integrity sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==
"@oxlint/binding-linux-x64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz#0c850b00faed2f884cf8665fe9c391f9f653d6ca"
integrity sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==
"@oxlint/binding-linux-x64-musl@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz#758fd6ea69123c6f4c1ba160bfc28f6b84548c11"
integrity sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==
"@oxlint/binding-linux-x64-musl@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz#91f54f1b0cc93a7e75ce4118aa85f9fd112f25c0"
integrity sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==
"@oxlint/binding-openharmony-arm64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz#e492b7a1875b70ea55ffe13f685d4302e6b1900e"
integrity sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==
"@oxlint/binding-openharmony-arm64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz#7ab37c21e547812177bffdbd97c8a34493eedd7b"
integrity sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==
"@oxlint/binding-win32-arm64-msvc@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz#9247fdc1b4c86ecc718747c5f8c68902969677d1"
integrity sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==
"@oxlint/binding-win32-arm64-msvc@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz#5a470fa82339044ad9cceef2bb8c62d88695e394"
integrity sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==
"@oxlint/binding-win32-ia32-msvc@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz#dfa4eed78612c5f4df788eb056fd5d1b05a75bb1"
integrity sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==
"@oxlint/binding-win32-ia32-msvc@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz#06f6559998f1a53a8f5ace78d91f217a54f6a963"
integrity sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==
"@oxlint/binding-win32-x64-msvc@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz#27a474cc288e47a0e5c2d9c922bee64de0cfd614"
integrity sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==
"@oxlint/binding-win32-x64-msvc@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz#d3abbf1a7a09b9039ca5ca570c9689908850efc4"
integrity sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==
"@parcel/watcher-android-arm64@2.5.6":
version "2.5.6"
@@ -11580,30 +11580,30 @@ oxlint-tsgolint@^7.0.2001:
"@oxlint-tsgolint/win32-arm64" "7.0.2001"
"@oxlint-tsgolint/win32-x64" "7.0.2001"
oxlint@^1.81.0:
version "1.81.0"
resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.81.0.tgz#7b20ada29a171883de4517d041ea5b057fb48ab5"
integrity sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==
oxlint@^1.80.0:
version "1.80.0"
resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.80.0.tgz#228271087d3f04e391e383ccdc0e840458d8b653"
integrity sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==
optionalDependencies:
"@oxlint/binding-android-arm-eabi" "1.81.0"
"@oxlint/binding-android-arm64" "1.81.0"
"@oxlint/binding-darwin-arm64" "1.81.0"
"@oxlint/binding-darwin-x64" "1.81.0"
"@oxlint/binding-freebsd-x64" "1.81.0"
"@oxlint/binding-linux-arm-gnueabihf" "1.81.0"
"@oxlint/binding-linux-arm-musleabihf" "1.81.0"
"@oxlint/binding-linux-arm64-gnu" "1.81.0"
"@oxlint/binding-linux-arm64-musl" "1.81.0"
"@oxlint/binding-linux-ppc64-gnu" "1.81.0"
"@oxlint/binding-linux-riscv64-gnu" "1.81.0"
"@oxlint/binding-linux-riscv64-musl" "1.81.0"
"@oxlint/binding-linux-s390x-gnu" "1.81.0"
"@oxlint/binding-linux-x64-gnu" "1.81.0"
"@oxlint/binding-linux-x64-musl" "1.81.0"
"@oxlint/binding-openharmony-arm64" "1.81.0"
"@oxlint/binding-win32-arm64-msvc" "1.81.0"
"@oxlint/binding-win32-ia32-msvc" "1.81.0"
"@oxlint/binding-win32-x64-msvc" "1.81.0"
"@oxlint/binding-android-arm-eabi" "1.80.0"
"@oxlint/binding-android-arm64" "1.80.0"
"@oxlint/binding-darwin-arm64" "1.80.0"
"@oxlint/binding-darwin-x64" "1.80.0"
"@oxlint/binding-freebsd-x64" "1.80.0"
"@oxlint/binding-linux-arm-gnueabihf" "1.80.0"
"@oxlint/binding-linux-arm-musleabihf" "1.80.0"
"@oxlint/binding-linux-arm64-gnu" "1.80.0"
"@oxlint/binding-linux-arm64-musl" "1.80.0"
"@oxlint/binding-linux-ppc64-gnu" "1.80.0"
"@oxlint/binding-linux-riscv64-gnu" "1.80.0"
"@oxlint/binding-linux-riscv64-musl" "1.80.0"
"@oxlint/binding-linux-s390x-gnu" "1.80.0"
"@oxlint/binding-linux-x64-gnu" "1.80.0"
"@oxlint/binding-linux-x64-musl" "1.80.0"
"@oxlint/binding-openharmony-arm64" "1.80.0"
"@oxlint/binding-win32-arm64-msvc" "1.80.0"
"@oxlint/binding-win32-ia32-msvc" "1.80.0"
"@oxlint/binding-win32-x64-msvc" "1.80.0"
p-cancelable@^3.0.0:
version "3.0.0"
+213 -58
View File
@@ -244,7 +244,7 @@
"html-webpack-plugin": "^5.6.8",
"imports-loader": "^5.0.0",
"jest": "^30.5.1",
"jest-environment-jsdom": "^30.5.1",
"jest-environment-jsdom": "^30.5.0",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
@@ -5933,18 +5933,18 @@
}
},
"node_modules/@jest/environment-jsdom-abstract": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.1.tgz",
"integrity": "sha512-J395vmP3Fb2Te0JmF7pe4si4jpfbXef1YsY4UpHYL6OOxS2molu9Dsie1VmIiUalXdtmz1P5QRgc+5hBD+ssBg==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.5.0.tgz",
"integrity": "sha512-825vac4Dmysbn2kU7VUQPoKuj/HNUpSTgv98KCByMOSPvHuj1/HpVZeLRsP/itDB2HFiDcoTUrsg8fSu3PxKBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/environment": "30.5.1",
"@jest/fake-timers": "30.5.1",
"@jest/types": "30.5.1",
"@jest/environment": "30.5.0",
"@jest/fake-timers": "30.5.0",
"@jest/types": "30.5.0",
"@types/node": "*",
"jest-mock": "30.5.1",
"jest-util": "30.5.1"
"jest-mock": "30.5.0",
"jest-util": "30.5.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -5961,34 +5961,34 @@
}
},
"node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/environment": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.1.tgz",
"integrity": "sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz",
"integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/fake-timers": "30.5.1",
"@jest/types": "30.5.1",
"@jest/fake-timers": "30.5.0",
"@jest/types": "30.5.0",
"@types/node": "*",
"jest-mock": "30.5.1"
"jest-mock": "30.5.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.1.tgz",
"integrity": "sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz",
"integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.5.1",
"@jest/types": "30.5.0",
"@sinonjs/fake-timers": "^15.4.0",
"@types/node": "*",
"jest-message-util": "30.5.1",
"jest-mock": "30.5.1",
"jest-util": "30.5.1"
"jest-message-util": "30.5.0",
"jest-mock": "30.5.0",
"jest-util": "30.5.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -6028,20 +6028,20 @@
}
},
"node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz",
"integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz",
"integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@jest/types": "30.5.1",
"@jest/types": "30.5.0",
"@types/stack-utils": "^2.0.3",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"jest-util": "30.5.1",
"jest-util": "30.5.0",
"picomatch": "^4.0.3",
"pretty-format": "30.5.1",
"pretty-format": "30.5.0",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
},
@@ -6063,9 +6063,9 @@
}
},
"node_modules/@jest/environment-jsdom-abstract/node_modules/pretty-format": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz",
"integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz",
"integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -27311,6 +27311,32 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-config/node_modules/@jest/transform": {
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.0.tgz",
"integrity": "sha512-n1cYhoByyULEIXi64wbT4Lq91qeT1E6bwpM//sprFXhw955qaiHTdAmy1c1rNFGB6fCf1J+nxDUSf3RGwgZP5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.27.4",
"@jest/types": "30.5.0",
"@jridgewell/trace-mapping": "^0.3.31",
"babel-plugin-istanbul": "^8.0.0",
"chalk": "^4.1.2",
"convert-source-map": "^2.0.0",
"fast-json-stable-stringify": "^2.1.0",
"graceful-fs": "^4.2.11",
"jest-haste-map": "30.5.0",
"jest-regex-util": "30.5.0",
"jest-util": "30.5.0",
"pirates": "^4.0.7",
"slash": "^3.0.0",
"write-file-atomic": "^5.0.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-config/node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
@@ -27659,6 +27685,48 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jest-config/node_modules/babel-jest": {
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.0.tgz",
"integrity": "sha512-PrhPHlKC+MsLnuNzgIH/y1dkz1f6cSfKWaQeaG8WxLMuG44dYWQ8E9uRrsBbAGCU/3+BEFYPN4d6G3Zc5Y+waA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/transform": "30.5.0",
"@types/babel__core": "^7.20.5",
"babel-plugin-istanbul": "^8.0.0",
"babel-preset-jest": "30.5.0",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"slash": "^3.0.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
},
"peerDependencies": {
"@babel/core": "^7.11.0 || ^8.0.0-0"
}
},
"node_modules/jest-config/node_modules/babel-plugin-istanbul": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz",
"integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==",
"dev": true,
"license": "BSD-3-Clause",
"workspaces": [
"test/babel-8"
],
"dependencies": {
"@babel/helper-plugin-utils": "^7.0.0",
"@istanbuljs/load-nyc-config": "^1.0.0",
"@istanbuljs/schema": "^0.1.3",
"istanbul-lib-instrument": "^6.0.2",
"test-exclude": "^7.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jest-config/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -27896,6 +27964,93 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jest-config/node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jest-config/node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/jest-config/node_modules/test-exclude/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-config/node_modules/test-exclude/node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-config/node_modules/test-exclude/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jest-config/node_modules/test-exclude/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-config/node_modules/unrs-resolver": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -28058,14 +28213,14 @@
}
},
"node_modules/jest-environment-jsdom": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.1.tgz",
"integrity": "sha512-8lzKbC/SRbQE24wr1OOJV+aYtDAuVNKBryN6YcFiCcaZZ3I7grcZY7w91BwNvGET0ubKDmomEHZFHMaF+6pAlA==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.5.0.tgz",
"integrity": "sha512-VVHN/G3zrxsQR398jvMalM76ALX6YBAftsYLCGtTeKRmz4f42YJAP05AGpk0VF5SLtoHdkfKunYGfnKPnmcEOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/environment": "30.5.1",
"@jest/environment-jsdom-abstract": "30.5.1",
"@jest/environment": "30.5.0",
"@jest/environment-jsdom-abstract": "30.5.0",
"@types/jsdom": "^21.1.7",
"jsdom": "^26.1.0"
},
@@ -28082,34 +28237,34 @@
}
},
"node_modules/jest-environment-jsdom/node_modules/@jest/environment": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.1.tgz",
"integrity": "sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.0.tgz",
"integrity": "sha512-HUaqexIauIh69IQ4NTuPDEUCB8g8T4TOPSIzQOS18mwI/KEHKQk1j013K2o6ra031szZE2t5jGmVx3xbzdjgKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/fake-timers": "30.5.1",
"@jest/types": "30.5.1",
"@jest/fake-timers": "30.5.0",
"@jest/types": "30.5.0",
"@types/node": "*",
"jest-mock": "30.5.1"
"jest-mock": "30.5.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.1.tgz",
"integrity": "sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.0.tgz",
"integrity": "sha512-sg8xIbYwe5GdB/vT3/0qrDIpO7Ov9mazHi++M95uynmDKEZ70G1r169AWct73H07VrTZhrz1SJEfLtjYv8tE3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.5.1",
"@jest/types": "30.5.0",
"@sinonjs/fake-timers": "^15.4.0",
"@types/node": "*",
"jest-message-util": "30.5.1",
"jest-mock": "30.5.1",
"jest-util": "30.5.1"
"jest-message-util": "30.5.0",
"jest-mock": "30.5.0",
"jest-util": "30.5.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
@@ -28176,20 +28331,20 @@
}
},
"node_modules/jest-environment-jsdom/node_modules/jest-message-util": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz",
"integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.0.tgz",
"integrity": "sha512-dBYMhplGfspKaCnVk9TUy1cZnknWubpuPNEputjz0YJk1G/92R45rn45BvbPMPMtC5LVcIdxJGPOaOSQTiuzJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@jest/types": "30.5.1",
"@jest/types": "30.5.0",
"@types/stack-utils": "^2.0.3",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"jest-util": "30.5.1",
"jest-util": "30.5.0",
"picomatch": "^4.0.3",
"pretty-format": "30.5.1",
"pretty-format": "30.5.0",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
},
@@ -28251,9 +28406,9 @@
}
},
"node_modules/jest-environment-jsdom/node_modules/pretty-format": {
"version": "30.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz",
"integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==",
"version": "30.5.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.0.tgz",
"integrity": "sha512-mzNzBErpHwM0zpmWS7ExOv62yhQhvd546nUuFqVR0dmnJB59tfrw9sjDF0DJknwsr59OXP0buwJ7PaKguczHSg==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -321,7 +321,7 @@
"html-webpack-plugin": "^5.6.8",
"imports-loader": "^5.0.0",
"jest": "^30.5.1",
"jest-environment-jsdom": "^30.5.1",
"jest-environment-jsdom": "^30.5.0",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
@@ -113,19 +113,8 @@ export interface BackendOwnState {
* Each chart plugin can implement this to convert its internal state representation
* to the standardized backend format.
*/
export interface ChartStateConverterOptions {
// Set when converting for a download/export query rather than the chart's
// live (re-)query. Some chart-specific state (e.g. AG Grid's client-side
// sort/filter) is normally excluded from the live query's ownState to
// avoid triggering an unnecessary requery, but a downloaded file has no
// client-side pass to apply that state, so it still needs to be converted
// for exports to reproduce the displayed view.
forExport?: boolean;
}
export type ChartStateConverter<TChartState = JsonObject> = (
chartState: TChartState,
options?: ChartStateConverterOptions,
) => Partial<BackendOwnState>;
export interface PlainObject {
@@ -222,7 +222,6 @@ export type {
GridState,
GridReadyEvent,
CellClickedEvent,
CellContextMenuEvent,
CellKeyDownEvent,
CellClassParams,
IMenuActionParams,
@@ -20,15 +20,6 @@ import type { DataRecordValue } from '../query/types/QueryResponse';
import type { TimeFormatFunction } from './types';
import normalizeTimestamp from './utils/normalizeTimestamp';
/**
* A missing date can arrive as either `null`/`undefined` or an empty string
* (e.g. a blank cell in an otherwise-numeric epoch column, which also has the
* side effect of degrading the whole column's formatter to `String` - see
* `isNumeric` in transformProps.ts). Both should be treated as "no value".
*/
export const isEmptyDateInput = (input: DataRecordValue): boolean =>
input === null || input === undefined || input === '';
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
@@ -19,10 +19,7 @@
export { default as TimeFormats, LOCAL_PREFIX } from './TimeFormats';
export { default as TimeFormatter, PREVIEW_TIME } from './TimeFormatter';
export {
default as DateWithFormatter,
isEmptyDateInput,
} from './DateWithFormatter';
export { default as DateWithFormatter } from './DateWithFormatter';
export { DEFAULT_D3_TIME_FORMAT } from './D3FormatConfig';
export {
@@ -79,5 +79,4 @@ export interface AgGridChartState {
columnOrder?: string[];
pageSize?: number;
currentPage?: number;
serverPagination?: boolean;
}
@@ -40,7 +40,6 @@ import {
GridReadyEvent,
GridState,
CellClickedEvent,
CellContextMenuEvent,
CellKeyDownEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
@@ -60,13 +59,9 @@ import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
import getInitialFilterModel from '../utils/getInitialFilterModel';
import reconcileColumnState from '../utils/reconcileColumnState';
import getColumnStateSignature from '../utils/getColumnStateSignature';
import { PAGE_SIZE_OPTIONS, ROW_NUMBER_COL_ID } from '../consts';
import {
getCompleteFilterState,
type FilterState,
} from '../utils/filterStateManager';
import { PAGE_SIZE_OPTIONS } from '../consts';
import { getCompleteFilterState } from '../utils/filterStateManager';
import { copyCellValueOnKeyDown } from '../utils/copyCellValue';
import type { ClientViewSnapshot } from '../utils/externalAPIs';
export interface AgGridState extends Partial<GridState> {
timestamp?: number;
@@ -82,6 +77,7 @@ export type AgGridChartStateWithMetadata = Partial<AgGridChartState> & {
export interface AgGridTableProps {
gridTheme?: string;
isDarkMode?: boolean;
gridHeight?: number;
updateInterval?: number;
data?: any[];
onGridReady?: (params: GridReadyEvent) => void;
@@ -104,20 +100,17 @@ export interface AgGridTableProps {
serverPageLength: number;
hasServerPageLengthChanged: boolean;
handleCellClicked: (event: CellClickedEvent) => void;
handleCellContextMenu?: (event: CellContextMenuEvent) => void;
handleSelectionChanged: (event: SelectionChangedEvent) => void;
filters?: Record<string, DataRecordValue[]> | null;
isActiveFilterValue?: (key: string, val: DataRecordValue) => boolean;
renderTimeComparisonDropdown: () => JSX.Element | null;
cleanedTotals: DataRecord;
showTotals: boolean;
width: number;
onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void;
onFilterChanged?: (completeFilterState: FilterState) => void;
onFilterChanged?: (filterModel: Record<string, any>) => void;
metricColumns?: string[];
gridRef?: RefObject<AgGridReact>;
chartState?: AgGridChartState;
onClientViewChange?: (snapshot: ClientViewSnapshot) => void;
}
ModuleRegistry.registerModules([AllCommunityModule, ClientSideRowModelModule]);
@@ -126,6 +119,7 @@ const isSearchFocused = new Map<string, boolean>();
const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
({
gridHeight,
data = [],
colDefsFromProps,
includeSearch,
@@ -146,10 +140,8 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
serverPageLength,
hasServerPageLengthChanged,
handleCellClicked,
handleCellContextMenu,
handleSelectionChanged,
filters,
isActiveFilterValue,
renderTimeComparisonDropdown,
cleanedTotals,
showTotals,
@@ -158,14 +150,12 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
onFilterChanged,
metricColumns = [],
chartState,
onClientViewChange,
}) => {
const gridRef = useRef<AgGridReact>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowData = useMemo(() => data, [data]);
const containerRef = useRef<HTMLDivElement>(null);
const lastCapturedStateRef = useRef<string | null>(null);
const hasCapturedInitialGridStateRef = useRef(false);
const filterOperationVersionRef = useRef(0);
const searchId = `search-${id}`;
@@ -199,26 +189,13 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
[],
);
// Fills the full height allotted by the chart container (StyledChartContainer);
// the search/time-comparison controls and pagination bar take their natural
// height and the grid flexes into whatever space remains (see gridFlexStyles),
// instead of a hardcoded pixel height that drifts from the actual chrome height.
// Memoize container style
const containerStyles = useMemo(
() => ({
height: '100%',
height: gridHeight,
width,
display: 'flex',
flexDirection: 'column' as const,
}),
[width],
);
const gridFlexStyles = useMemo(
() => ({
flex: '1 1 auto',
minHeight: 0,
}),
[],
[gridHeight, width],
);
const [quickFilterText, setQuickFilterText] = useState<string>();
@@ -316,7 +293,6 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
sortModel,
filterModel,
timestamp: Date.now(),
serverPagination: true,
});
}
@@ -345,85 +321,49 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
[serverPagination, gridInitialState, percentMetrics, onSortChange],
);
const captureGridState = useCallback(() => {
const { api } = gridRef.current ?? {};
if (!api) return null;
const columnState = api.getColumnState ? api.getColumnState() : [];
const filterModel = api.getFilterModel ? api.getFilterModel() : {};
const sortModel = columnState
.filter(col => col.sort)
.map(col => ({
colId: col.colId,
sort: col.sort as 'asc' | 'desc',
sortIndex: col.sortIndex || 0,
}))
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0));
return {
stateToSave: {
columnState,
sortModel,
filterModel,
timestamp: Date.now(),
serverPagination: !!serverPagination,
},
stateHash: getColumnStateSignature(columnState, sortModel, filterModel),
};
}, [serverPagination]);
const persistGridStateChange = useCallback(
const handleGridStateChange = useCallback(
debounce(() => {
if (!onColumnStateChange) return;
try {
const captured = captureGridState();
if (!captured) return;
const { stateToSave, stateHash } = captured;
if (onColumnStateChange && gridRef.current?.api) {
try {
const { api } = gridRef.current;
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
const columnState = api.getColumnState ? api.getColumnState() : [];
onColumnStateChange(stateToSave);
const filterModel = api.getFilterModel ? api.getFilterModel() : {};
const sortModel = columnState
.filter(col => col.sort)
.map(col => ({
colId: col.colId,
sort: col.sort as 'asc' | 'desc',
sortIndex: col.sortIndex || 0,
}))
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0));
const stateToSave = {
columnState,
sortModel,
filterModel,
timestamp: Date.now(),
};
const stateHash = getColumnStateSignature(
columnState,
sortModel,
filterModel,
);
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
onColumnStateChange(stateToSave);
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
}
}, Constants.SLOW_DEBOUNCE),
[onColumnStateChange, captureGridState],
);
const handleGridStateChange = useCallback(() => {
// AG Grid fires onStateUpdated once as it applies the initial
// column/sort/filter state on mount, before any user interaction.
// That first event just reflects the state the grid was initialized
// with (chartState/gridInitialState) - not a user-driven change - so
// it's captured synchronously as the baseline rather than persisted.
// This check runs on every raw call, before debouncing, so a real
// user action that lands inside the same debounce window as this
// first call is never coalesced into it and dropped.
if (!hasCapturedInitialGridStateRef.current) {
hasCapturedInitialGridStateRef.current = true;
try {
const captured = captureGridState();
if (captured) {
lastCapturedStateRef.current = captured.stateHash;
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
}
return;
}
persistGridStateChange();
}, [captureGridState, persistGridStateChange]);
useEffect(
() =>
// Cleanup debounced grid-state capture
() => {
persistGridStateChange.cancel();
},
[persistGridStateChange],
[onColumnStateChange],
);
const handleFilterChanged = useCallback(async () => {
@@ -476,81 +416,6 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
serverPaginationData?.agGridFilterModel,
]);
// Captures the "current view" (post-filter/sort, all rows across all
// pages) for the "Export Current View" menu, mirroring Table V1's
// clientView snapshot. Client-side mode only: in server pagination mode
// the grid only ever holds a single page's rows, so a client-derived
// snapshot can't represent the full filtered/sorted result and export
// falls back to a fresh backend query instead (see useExploreAdditionalActionsMenu).
const lastClientViewSignatureRef = useRef<string | null>(null);
// Unlike handleGridStateChange's columnState/sortModel/filterModel,
// clientView is excluded from ownState re-query comparisons on both the
// Explore (ExploreViewContainer) and dashboard (activeAllDashboardFilters)
// paths, so publishing it - including the very first snapshot right
// after mount - can't trigger a requery/remount loop. It's therefore
// always persisted below rather than having its initial value skipped;
// skipping it would leave "Export Current View" without a snapshot to
// export until some later grid event changes the signature.
// Debounced (like handleGridStateChange below) because the full
// filtered+sorted traversal is O(n) and onModelUpdated can fire rapidly
// in succession (e.g. while typing into a quick filter); only the
// trailing update needs to recompute the snapshot.
const handleModelUpdated = useCallback(
debounce(() => {
if (serverPagination || !onClientViewChange || !gridRef.current?.api) {
return;
}
const { api } = gridRef.current;
const displayedColumns = api
.getAllDisplayedColumns()
.filter(column => column.getColId() !== ROW_NUMBER_COL_ID);
const columns = displayedColumns.map(column => {
const colDef = column.getColDef();
// For comparison columns, colId has "Main " stripped for display,
// but row data is still keyed by the unstripped original field
// (colDef.context.dataKey, set in useColDefs) -- use that to read
// row values so exported rows aren't blank for the main metric.
const dataKey = colDef.context?.dataKey ?? column.getColId();
return {
key: dataKey,
label: colDef.headerName || column.getColId(),
};
});
const rows: Record<string, unknown>[] = [];
api.forEachNodeAfterFilterAndSort(node => {
if (node.data) {
rows.push(node.data);
}
});
// Without a getRowId callback, AG Grid's node ids are purely
// positional and reset to 0..n-1 on every setRowData call, so they
// don't identify a row's content across a data refresh — hashing
// the actual filtered+sorted row content (which this function
// already has to visit to build `rows`) is what actually detects
// both value changes (e.g. a refresh with the same row count) and
// order changes (e.g. a pure sort), not just count/column changes.
const signature = `${JSON.stringify(rows)}|${columns.map(c => c.key).join(',')}`;
if (signature === lastClientViewSignatureRef.current) {
return;
}
lastClientViewSignatureRef.current = signature;
onClientViewChange({ rows, columns, count: rows.length });
}, Constants.SLOW_DEBOUNCE),
[serverPagination, onClientViewChange],
);
useEffect(
() =>
// Cleanup debounced client-view snapshot capture
() => {
handleModelUpdated.cancel();
},
[handleModelUpdated],
);
useEffect(() => {
if (
hasServerPageLengthChanged &&
@@ -571,32 +436,14 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
}
}, [width]);
// Row highlighting must reflect the active cross filter regardless of how
// it was applied (cell click, context menu, or an external dashboard
// filter), so it survives re-renders and server-side re-queries rather
// than only reflecting whichever handler last called setSelected.
useEffect(() => {
const api = gridRef.current?.api;
if (!api) return;
if (!filters || Object.keys(filters).length === 0) {
if (api.getSelectedRows().length) {
api.deselectAll();
}
return;
if (
(!filters || Object.keys(filters).length === 0) &&
gridRef.current?.api?.getSelectedRows().length
) {
gridRef.current.api.deselectAll();
}
if (!isActiveFilterValue) return;
api.forEachNode(node => {
const matches = Object.keys(filters).some(key =>
isActiveFilterValue(key, node.data?.[key] as DataRecordValue),
);
if (node.isSelected() !== matches) {
node.setSelected(matches, false, 'api');
}
});
}, [filters, isActiveFilterValue, rowData]);
}, [filters]);
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
@@ -664,130 +511,126 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
)}
</div>
<div style={gridFlexStyles}>
<ThemedAgGridReact
ref={gridRef}
onGridReady={onGridReady}
className="ag-container"
rowData={rowData}
headerHeight={36}
rowHeight={30}
columnDefs={colDefsFromProps}
defaultColDef={defaultColDef}
onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellContextMenu={handleCellContextMenu}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onModelUpdated={handleModelUpdated}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
// Clicking a cell should select (focus) the cell rather than select
// its text content (#106389). enableCellTextSelection forces browser
// text selection on click, which suppresses the cell-focus behavior.
// Because the Enterprise clipboard module isn't registered, native
// text selection was the only way to copy a value, so onCellKeyDown
// (above) restores Ctrl/Cmd+C copy for the focused cell. Full
// multi-cell range selection still requires AG Grid Enterprise, which
// is not available in the Community build used here.
enableCellTextSelection={false}
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}
paginationPageSize={pageSize}
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
tooltipShowDelay={500}
localeText={{
// Pagination controls
next: t('Next'),
previous: t('Previous'),
page: t('Page'),
more: t('More'),
to: t('to'),
of: t('of'),
first: t('First'),
last: t('Last'),
loadingOoo: t('Loading...'),
// Set Filter
selectAll: t('Select All'),
searchOoo: t('Search...'),
blanks: t('Blanks'),
// Filter operations
filterOoo: t('Filter'),
applyFilter: t('Apply Filter'),
equals: t('Equals'),
notEqual: t('Not Equal'),
lessThan: t('Less Than'),
greaterThan: t('Greater Than'),
lessThanOrEqual: t('Less Than or Equal'),
greaterThanOrEqual: t('Greater Than or Equal'),
inRange: t('In Range'),
contains: t('Contains'),
notContains: t('Not Contains'),
startsWith: t('Starts With'),
endsWith: t('Ends With'),
// Logical conditions
andCondition: t('AND'),
orCondition: t('OR'),
// Panel and group labels
group: t('Group'),
columns: t('Columns'),
filters: t('Filters'),
valueColumns: t('Value Columns'),
pivotMode: t('Pivot Mode'),
groups: t('Groups'),
values: t('Values'),
pivots: t('Pivots'),
toolPanelButton: t('Tool Panel'),
// Enterprise menu items
pinColumn: t('Pin Column'),
valueAggregation: t('Value Aggregation'),
autosizeThiscolumn: t('Autosize This Column'),
autosizeAllColumns: t('Autosize All Columns'),
groupBy: t('Group By'),
ungroupBy: t('Ungroup By'),
resetColumns: t('Reset Columns'),
expandAll: t('Expand All'),
collapseAll: t('Collapse All'),
toolPanel: t('Tool Panel'),
export: t('Export'),
csvExport: t('CSV Export'),
excelExport: t('Excel Export'),
excelXmlExport: t('Excel XML Export'),
// Aggregation functions
sum: t('Sum'),
min: t('Min'),
max: t('Max'),
none: t('None'),
count: t('Count'),
average: t('Average'),
// Standard menu items
copy: t('Copy'),
copyWithHeaders: t('Copy with Headers'),
paste: t('Paste'),
// Column menu and sorting
sortAscending: t('Sort Ascending'),
sortDescending: t('Sort Descending'),
sortUnSort: t('Clear Sort'),
}}
context={{
onColumnHeaderClicked: handleColumnHeaderClick,
initialSortState: getInitialSortState(
serverPaginationData?.sortBy || [],
),
lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
lastFilteredInputPosition:
serverPaginationData?.lastFilteredInputPosition,
}}
/>
</div>
<ThemedAgGridReact
ref={gridRef}
onGridReady={onGridReady}
className="ag-container"
rowData={rowData}
headerHeight={36}
rowHeight={30}
columnDefs={colDefsFromProps}
defaultColDef={defaultColDef}
onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
// Clicking a cell should select (focus) the cell rather than select
// its text content (#106389). enableCellTextSelection forces browser
// text selection on click, which suppresses the cell-focus behavior.
// Because the Enterprise clipboard module isn't registered, native
// text selection was the only way to copy a value, so onCellKeyDown
// (above) restores Ctrl/Cmd+C copy for the focused cell. Full
// multi-cell range selection still requires AG Grid Enterprise, which
// is not available in the Community build used here.
enableCellTextSelection={false}
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}
paginationPageSize={pageSize}
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
tooltipShowDelay={500}
localeText={{
// Pagination controls
next: t('Next'),
previous: t('Previous'),
page: t('Page'),
more: t('More'),
to: t('to'),
of: t('of'),
first: t('First'),
last: t('Last'),
loadingOoo: t('Loading...'),
// Set Filter
selectAll: t('Select All'),
searchOoo: t('Search...'),
blanks: t('Blanks'),
// Filter operations
filterOoo: t('Filter'),
applyFilter: t('Apply Filter'),
equals: t('Equals'),
notEqual: t('Not Equal'),
lessThan: t('Less Than'),
greaterThan: t('Greater Than'),
lessThanOrEqual: t('Less Than or Equal'),
greaterThanOrEqual: t('Greater Than or Equal'),
inRange: t('In Range'),
contains: t('Contains'),
notContains: t('Not Contains'),
startsWith: t('Starts With'),
endsWith: t('Ends With'),
// Logical conditions
andCondition: t('AND'),
orCondition: t('OR'),
// Panel and group labels
group: t('Group'),
columns: t('Columns'),
filters: t('Filters'),
valueColumns: t('Value Columns'),
pivotMode: t('Pivot Mode'),
groups: t('Groups'),
values: t('Values'),
pivots: t('Pivots'),
toolPanelButton: t('Tool Panel'),
// Enterprise menu items
pinColumn: t('Pin Column'),
valueAggregation: t('Value Aggregation'),
autosizeThiscolumn: t('Autosize This Column'),
autosizeAllColumns: t('Autosize All Columns'),
groupBy: t('Group By'),
ungroupBy: t('Ungroup By'),
resetColumns: t('Reset Columns'),
expandAll: t('Expand All'),
collapseAll: t('Collapse All'),
toolPanel: t('Tool Panel'),
export: t('Export'),
csvExport: t('CSV Export'),
excelExport: t('Excel Export'),
excelXmlExport: t('Excel XML Export'),
// Aggregation functions
sum: t('Sum'),
min: t('Min'),
max: t('Max'),
none: t('None'),
count: t('Count'),
average: t('Average'),
// Standard menu items
copy: t('Copy'),
copyWithHeaders: t('Copy with Headers'),
paste: t('Paste'),
// Column menu and sorting
sortAscending: t('Sort Ascending'),
sortDescending: t('Sort Descending'),
sortUnSort: t('Clear Sort'),
}}
context={{
onColumnHeaderClicked: handleColumnHeaderClick,
initialSortState: getInitialSortState(
serverPaginationData?.sortBy || [],
),
lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
lastFilteredInputPosition:
serverPaginationData?.lastFilteredInputPosition,
}}
/>
{serverPagination && (
<Pagination
currentPage={serverPaginationData?.currentPage || 0}
@@ -18,28 +18,16 @@
*/
import { t } from '@apache-superset/core/translation';
import {
BinaryQueryObjectFilterClause,
DataRecord,
DataRecordValue,
DateWithFormatter,
extractTextFromHTML,
getTimeFormatterForGranularity,
isEmptyDateInput,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useMemo,
} from 'react';
import { debounce, isEqual } from 'lodash-es';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { isEqual } from 'lodash-es';
import {
CellClickedEvent,
CellContextMenuEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import {
@@ -49,18 +37,20 @@ import {
SortByItem,
} from './types';
import AgGridDataTable from './AgGridTable';
import { updateTableOwnState, ClientViewSnapshot } from './utils/externalAPIs';
import { updateTableOwnState } from './utils/externalAPIs';
import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVisibility';
import { useColDefs } from './utils/useColDefs';
import {
buildSelectionCrossFilterDataMask,
getCrossFilterDataMask,
} from './utils/getCrossFilterDataMask';
import { buildSelectionCrossFilterDataMask } from './utils/getCrossFilterDataMask';
import { StyledChartContainer } from './styles';
import type { FilterState } from './utils/filterStateManager';
import { formatColumnValue } from './utils/formatValue';
import getTimeRangeFromGranularity from './utils/getTimeRangeFromGranularity';
import getScrollBarSize from './utils/getScrollBarSize';
const getGridHeight = (height: number, includeSearch: boolean | undefined) => {
let calculatedGridHeight = height;
if (includeSearch) {
calculatedGridHeight -= 16;
}
return calculatedGridHeight - 80;
};
export default function TableChart<D extends DataRecord = DataRecord>(
props: AgGridTableChartTransformedProps<D> & {},
@@ -71,7 +61,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
data,
includeSearch,
allowRearrangeColumns,
allowRenderHtml,
pageSize,
serverPagination,
rowCount,
@@ -99,60 +88,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
metricSqlExpressions,
rawSummaryColumns,
showNumberedColumn,
onContextMenu,
formData,
} = props;
// The dashboard's layout engine reports a burst of close-but-not-identical
// width/height values while it settles on initial load. Committing each
// intermediate value resizes the chart container and re-fits AG Grid's
// columns once per value; for any column with wrapText/autoHeight (the
// default - see useColDefs), each re-fit can flip a borderline cell across
// its wrap boundary and change that row's height, which is what actually
// reads as "flicker" rather than the container resize itself.
//
// A scrollbar-sized threshold (matching plugin-chart-table/v1's guard)
// filters out sub-pixel noise, but genuine multi-step settling still gets
// through as several real width values in quick succession. Debouncing
// every commit after the first collapses that burst into the single final
// value once it stops changing, while still painting the first available
// size immediately so the chart isn't blank while it waits.
const [tableSize, setTableSize] = useState({ width: 0, height: 0 });
const hasCommittedInitialSize = useRef(false);
const debouncedSetTableSize = useMemo(
() =>
debounce((size: { width: number; height: number }) => {
setTableSize(size);
}, 250),
[],
);
useEffect(
() =>
// Cleanup debounced size commit
() => {
debouncedSetTableSize.cancel();
},
[debouncedSetTableSize],
);
useLayoutEffect(() => {
const scrollBarSize = getScrollBarSize();
const sizeChanged =
Math.abs(width - tableSize.width) > scrollBarSize ||
Math.abs(height - tableSize.height) > scrollBarSize;
if (!sizeChanged) {
return;
}
if (!hasCommittedInitialSize.current) {
hasCommittedInitialSize.current = true;
setTableSize({ width, height });
} else {
debouncedSetTableSize({ width, height });
}
}, [width, height, tableSize, debouncedSetTableSize]);
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
// Extract metric column names for SQL conversion
@@ -177,27 +114,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
}, [columns]);
// Tracks the most recently written ownState so that writes triggered
// asynchronously (e.g. clientView from AG Grid's onModelUpdated, which can
// fire with a stale closure) merge onto the latest known state instead of
// a stale render-time serverPaginationData snapshot. updateTableOwnState
// replaces ownState wholesale, so merging at write time - rather than at
// render time - is what keeps concurrent writers from clobbering one
// another's keys.
const ownStateRef = useRef(serverPaginationData);
useEffect(() => {
ownStateRef.current = serverPaginationData;
}, [serverPaginationData]);
const writeOwnState = useCallback(
(patch: Record<string, unknown>) => {
const nextOwnState = { ...ownStateRef.current, ...patch };
ownStateRef.current = nextOwnState;
updateTableOwnState(setDataMask, nextOwnState);
},
[setDataMask],
);
// A single effect owns every ownState write derived from render state.
// updateTableOwnState replaces ownState wholesale, so separate effects that
// each spread serverPaginationData in the same render would clobber one
@@ -205,7 +121,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// columns and nudging a re-query for missing totals must be one combined
// delta.
useEffect(() => {
const patch: Record<string, unknown> = {};
const nextOwnState = { ...serverPaginationData };
let changed = false;
if (serverPagination && serverPaginationData && rowCount !== undefined) {
@@ -216,7 +132,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// last remaining page.
const clampedPage = Math.max(0, Math.min(currentPage, totalPages - 1));
if (clampedPage !== currentPage) {
patch.currentPage = clampedPage;
nextOwnState.currentPage = clampedPage;
changed = true;
}
}
@@ -224,22 +140,22 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const primed = (serverPaginationData?.rawSummaryColumns ?? []) as string[];
const requested = Boolean(serverPaginationData?.totalsRequested);
if (isRawRecords && showTotals && !isEqual(primed, rawSummaryColumns)) {
patch.rawSummaryColumns = rawSummaryColumns;
nextOwnState.rawSummaryColumns = rawSummaryColumns;
changed = true;
}
// A renderTrigger toggle re-renders without re-querying; requesting totals
// through ownState dispatches the standard re-query whose buildQuery
// carries the totals query for the active mode.
if (showTotals && totals === undefined && !requested) {
patch.totalsRequested = true;
nextOwnState.totalsRequested = true;
changed = true;
} else if (!showTotals && requested) {
patch.totalsRequested = false;
nextOwnState.totalsRequested = false;
changed = true;
}
if (changed) {
writeOwnState(patch);
updateTableOwnState(setDataMask, nextOwnState);
}
}, [
serverPagination,
@@ -250,7 +166,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
totals,
rawSummaryColumns,
serverPaginationData,
writeOwnState,
setDataMask,
]);
const comparisonColumns = [
@@ -293,7 +209,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
// Prepare modified own state for server pagination
writeOwnState({
const modifiedOwnState = {
...serverPaginationData,
agGridFilterModel:
completeFilterState.originalFilterModel &&
Object.keys(completeFilterState.originalFilterModel).length > 0
@@ -306,11 +223,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
lastFilteredInputPosition: completeFilterState.inputPosition,
currentPage: 0, // Reset to first page when filtering
metricSqlExpressions,
});
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[
writeOwnState,
setDataMask,
serverPagination,
serverPaginationData,
onChartStateChange,
chartState,
metricSqlExpressions,
@@ -353,17 +273,15 @@ export default function TableChart<D extends DataRecord = DataRecord>(
colorPositiveNegative,
columnColorFormatters,
allowRearrangeColumns,
allowRenderHtml,
basicColorFormatters,
isUsingTimeComparison,
emitCrossFilters,
alignPositiveNegative,
slice_id,
conditionalFormatting: formData?.conditional_formatting,
comparisonColorEnabled: formData?.comparison_color_enabled,
comparisonColorScheme: formData?.comparison_color_scheme,
});
const gridHeight = getGridHeight(height, includeSearch);
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
if (!filters || !filters[key]) return false;
@@ -430,17 +348,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const handleSelectionChanged = useCallback(
(event: SelectionChangedEvent) => {
// Selection changes triggered by the highlight-sync effect (source
// 'api') reflect a filter that was already applied elsewhere (context
// menu, dashboard filter, etc.), so re-deriving and re-dispatching a
// mask from them here would use a stale activeColumnRef and could
// clobber that filter with the wrong column.
if (
!emitCrossFilters ||
!activeColumnRef.current ||
event.source === 'api'
)
return;
if (!emitCrossFilters || !activeColumnRef.current) return;
const key = activeColumnRef.current;
const selectedRows = event.api.getSelectedRows();
@@ -460,204 +368,75 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[emitCrossFilters, setDataMask, timeGrain, timestampFormatter],
);
const drillColumns = isUsingTimeComparison
? (filteredColumns as InputColumn[])
: (columns as InputColumn[]);
const handleContextMenu = useCallback(
(event: CellContextMenuEvent) => {
if (!onContextMenu || isRawRecords || !event.column || !event.data) {
return;
}
const nativeEvent = event.event as MouseEvent | null | undefined;
if (!nativeEvent) return;
nativeEvent.preventDefault();
nativeEvent.stopPropagation();
const rowData = event.data as Record<string, DataRecordValue>;
const key = event.column.getColId();
const cellValue = event.value as DataRecordValue;
const colDef = event.column.getColDef();
const isMetric = Boolean(
colDef.context?.isMetric || colDef.context?.isPercentMetric,
);
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
drillColumns.forEach(col => {
if (col.isMetric || col.isPercentMetric) return;
const dataRecordValue = rowData[col.key];
if (
dataRecordValue == null ||
(dataRecordValue instanceof DateWithFormatter &&
isEmptyDateInput(dataRecordValue.input))
) {
drillToDetailFilters.push({
col: col.key,
op: 'IS NULL' as any,
val: null,
});
} else if (col.dataType === GenericDataType.Temporal && timeGrain) {
const startTime =
dataRecordValue instanceof Date
? dataRecordValue
: new Date(dataRecordValue as string | number);
if (Number.isNaN(startTime.getTime())) {
// Malformed temporal value: fall back to an equality filter
// instead of building a TEMPORAL_RANGE, since toISOString()
// throws on an Invalid Date and would crash the context menu.
const sanitizedValue = extractTextFromHTML(dataRecordValue);
drillToDetailFilters.push({
col: col.key,
op: '==',
val: sanitizedValue as string | number | boolean,
formattedVal: formatColumnValue(col, sanitizedValue)[1],
});
} else {
const [rangeStartTime, rangeEndTime] = getTimeRangeFromGranularity(
startTime,
timeGrain,
);
const timeRangeValue = `${rangeStartTime.toISOString()} : ${rangeEndTime.toISOString()}`;
drillToDetailFilters.push({
col: col.key,
op: 'TEMPORAL_RANGE',
val: timeRangeValue,
grain: timeGrain,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
} else {
const sanitizedValue = extractTextFromHTML(dataRecordValue);
drillToDetailFilters.push({
col: col.key,
op: '==',
val: sanitizedValue as string | number | boolean,
formattedVal: formatColumnValue(col, sanitizedValue)[1],
});
}
});
const isCellValueNull =
cellValue == null ||
(cellValue instanceof DateWithFormatter &&
isEmptyDateInput(cellValue.input));
onContextMenu(nativeEvent.clientX, nativeEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: isMetric
? undefined
: getCrossFilterDataMask({
key,
value: cellValue,
filters,
timeGrain,
isActiveFilterValue,
timestampFormatter,
}),
drillBy: isMetric
? undefined
: {
filters: [
isCellValueNull
? { col: key, op: 'IS NULL' as any, val: null }
: {
col: key,
op: '==' as any,
val: extractTextFromHTML(cellValue),
},
],
groupbyFieldName: 'groupby',
},
});
},
[
onContextMenu,
isRawRecords,
drillColumns,
timeGrain,
filters,
isActiveFilterValue,
timestampFormatter,
],
);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
writeOwnState({
const modifiedOwnState = {
...serverPaginationData,
currentPage: pageNumber,
pageSize,
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
});
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[writeOwnState],
[setDataMask],
);
const handlePageSizeChange = useCallback(
(pageSize: number) => {
writeOwnState({
const modifiedOwnState = {
...serverPaginationData,
currentPage: 0,
pageSize,
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
});
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[writeOwnState],
[setDataMask],
);
const handleChangeSearchCol = (searchCol: string) => {
if (!isEqual(searchCol, ownStateRef.current?.searchColumn)) {
writeOwnState({
if (!isEqual(searchCol, serverPaginationData?.searchColumn)) {
const modifiedOwnState = {
...serverPaginationData,
searchColumn: searchCol,
searchText: '',
currentPage: 0, // Reset to first page when the search column changes
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
});
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
};
const handleSearch = useCallback(
(searchText: string) => {
writeOwnState({
const modifiedOwnState = {
...serverPaginationData,
searchColumn:
(ownStateRef.current?.searchColumn as string | undefined) ||
searchOptions[0]?.value,
serverPaginationData?.searchColumn || searchOptions[0]?.value,
searchText,
currentPage: 0, // Reset to first page when searching
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
});
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[writeOwnState, searchOptions],
[setDataMask, searchOptions],
);
const handleSortByChange = useCallback(
(sortBy: SortByItem[]) => {
if (!serverPagination) return;
writeOwnState({
const modifiedOwnState = {
...serverPaginationData,
sortBy,
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
});
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[writeOwnState, serverPagination],
);
// Feeds the "Export Current View" menu item (EXPORT_CURRENT_VIEW behavior),
// mirroring Table V1's clientView snapshot on ownState. Written through
// writeOwnState (rather than spreading serverPaginationData directly)
// because onModelUpdated can fire with a stale closure relative to other
// ownState writers (e.g. a just-applied filter), and updateTableOwnState
// replaces ownState wholesale.
const handleClientViewChange = useCallback(
(clientView: ClientViewSnapshot) => {
writeOwnState({ clientView });
},
[writeOwnState],
[setDataMask, serverPagination],
);
const renderTimeComparisonVisibility = (): JSX.Element => (
@@ -676,22 +455,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
.join('|');
return (
<StyledChartContainer
height={tableSize.height}
onContextMenu={event => {
// Safety net: AG Grid only calls handleContextMenu (which calls
// preventDefault) when it resolves the native contextmenu event to
// a cell. If that per-cell resolution ever misses - e.g. a second,
// near-duplicate contextmenu event dispatched in quick succession by
// some mice's right-button switches - the event still bubbles
// through this container, so the browser's native menu is
// suppressed here regardless of whether AG Grid's own handler ran.
if (!isRawRecords) {
event.preventDefault();
}
}}
>
<StyledChartContainer height={height}>
<AgGridDataTable
gridHeight={gridHeight}
key={descriptionsKey}
data={data || []}
colDefsFromProps={colDefs}
@@ -712,10 +478,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
metricColumns={metricColumns}
id={slice_id}
handleCellClicked={handleCellClicked}
handleCellContextMenu={handleContextMenu}
handleSelectionChanged={handleSelectionChanged}
filters={filters}
isActiveFilterValue={isActiveFilterValue}
percentMetrics={percentMetrics}
serverPageLength={serverPageLength}
hasServerPageLengthChanged={hasServerPageLengthChanged}
@@ -726,10 +490,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
showTotals={
showTotals && totals !== undefined && Object.keys(totals).length > 0
}
width={tableSize.width}
width={width}
onColumnStateChange={handleColumnStateChange}
chartState={chartState}
onClientViewChange={handleClientViewChange}
/>
</StyledChartContainer>
);
@@ -674,29 +674,6 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
}
}
// Build the "all records" percent-metric denominator query AFTER all
// filter mutations (interactive group-by, search, AG Grid WHERE/HAVING)
// above, so its denominator reflects the same filtered result set as the
// main query instead of a stale pre-filter snapshot.
const calculationMode = formData.percent_metric_calculation || 'row_limit';
if (
calculationMode === 'all_records' &&
percentMetrics &&
percentMetrics.length > 0
) {
extraQueries.push({
...queryObject,
columns: [],
metrics: percentMetrics,
post_processing: [],
row_limit: 0,
row_offset: 0,
orderby: [],
is_timeseries: false,
});
}
// Create totals query AFTER all filters (including AG Grid filters) are applied
// This ensures we can properly exclude AG Grid WHERE filters from the totals
// In raw records mode the summary is a SUM over the numeric columns primed
@@ -737,21 +714,33 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
: undefined;
if (showAggregateTotals || rawSummaryColumns.length > 0) {
// Start from the original, pre-filter extras (captured before any
// AG Grid WHERE/HAVING or download sqlClauses were merged in above)
// rather than trying to subtract those fragments back out of the
// now-combined `queryObject.extras` string. AG Grid filters can
// reference calculated columns that aren't available once the
// totals subquery drops all grouping columns (columns: []), and that
// applies to HAVING just as much as WHERE, and to the download
// sqlClauses path just as much as the live agGridComplexWhere path —
// starting clean avoids having to special-case each source.
const totalsExtras = { ...extras };
if (!totalsExtras.where) {
delete totalsExtras.where;
}
if (!totalsExtras.having) {
delete totalsExtras.having;
// Create a copy of extras without the AG Grid WHERE clause
// AG Grid filters in extras.where can reference calculated columns
// which aren't available in the totals subquery
const totalsExtras = { ...queryObject.extras };
if (ownState.agGridComplexWhere) {
// Remove AG Grid WHERE clause from totals query
const whereClause = totalsExtras.where;
if (whereClause) {
// Remove the AG Grid filter part from the WHERE clause using string methods
const agGridWhere = ownState.agGridComplexWhere;
let newWhereClause = whereClause;
// Try to remove with " AND " before
newWhereClause = newWhereClause.replace(` AND ${agGridWhere}`, '');
// Try to remove with " AND " after
newWhereClause = newWhereClause.replace(`${agGridWhere} AND `, '');
// If it's the only clause, remove it entirely
if (newWhereClause === agGridWhere) {
newWhereClause = '';
}
if (newWhereClause.trim()) {
totalsExtras.where = newWhereClause;
} else {
delete totalsExtras.where;
}
}
}
extraQueries.push({
@@ -39,8 +39,6 @@ import {
shouldSkipMetricColumn,
isRegularMetric,
isPercentMetric,
ConditionalFormattingConfig,
ObjectFormattingEnum,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
@@ -196,23 +194,6 @@ const percentMetricsControl: typeof sharedControls.metrics = {
validators: [],
};
const percentMetricCalculationControl: ControlConfig<'SelectControl'> = {
type: 'SelectControl',
label: t('Percentage metric calculation'),
description: t(
'Row Limit: percentages are calculated based on the subset of data retrieved, respecting the row limit. ' +
'All Records: Percentages are calculated based on the total dataset, ignoring the row limit.',
),
default: 'row_limit',
clearable: false,
choices: [
['row_limit', t('Row limit')],
['all_records', t('All records')],
],
visibility: isAggMode,
renderTrigger: false,
};
/*
Options for row limit control
*/
@@ -450,12 +431,6 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'percent_metric_calculation',
config: percentMetricCalculationControl,
},
],
],
},
{
@@ -506,36 +481,6 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'allow_rearrange_columns',
config: {
type: 'CheckboxControl',
label: t('Allow columns to be rearranged'),
renderTrigger: true,
default: false,
description: t(
"Allow end user to drag-and-drop column headers to rearrange them. Note their changes won't persist for the next time they open the chart.",
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isEmpty(controls?.time_compare?.value),
},
},
],
[
{
name: 'allow_render_html',
config: {
type: 'CheckboxControl',
label: t('Render columns in HTML format'),
renderTrigger: true,
default: true,
description: t(
'Renders table cells as HTML when applicable. For example, HTML <a> tags will be rendered as hyperlinks.',
),
},
},
],
],
},
{
@@ -628,14 +573,11 @@ const config: ControlPanelConfig = {
const updatedColtypes: GenericDataType[] = [];
colnames
.map(
(colname, index) => [colname, index] as [string, number],
)
.filter(
([colname]) =>
colname =>
last(colname.split('__')) !== timeComparisonValue,
)
.forEach(([colname, originalIndex]) => {
.forEach((colname, index) => {
if (
shouldSkipMetricColumn({
colname,
@@ -672,12 +614,7 @@ const config: ControlPanelConfig = {
});
} else {
updatedColnames.push(colname);
// Look up by the column's original position in
// colnames/coltypes, not its position after the
// filter above — those diverge whenever any
// earlier column is a comparison-suffixed one that
// got filtered out.
updatedColtypes.push(coltypes[originalIndex]);
updatedColtypes.push(coltypes[index]);
childColumnMap[colname] = false;
timeComparisonColumnMap[colname] = false;
}
@@ -812,71 +749,24 @@ const config: ControlPanelConfig = {
: [];
const chartStatus = chart?.chartStatus;
// Normalize legacy `toAllRow`/`toTextColor` flags saved before
// `columnFormatting`/`objectFormatting` existed, so "entire row"
// formatters set under the old schema keep working.
const value = _?.value ?? [];
if (value && Array.isArray(value)) {
value.forEach(
(item: ConditionalFormattingConfig, index, array) => {
if (
item.colorScheme &&
(typeof item.colorScheme !== 'string' ||
!['Green', 'Red'].includes(item.colorScheme))
) {
if (item.columnFormatting === undefined) {
// eslint-disable-next-line no-param-reassign
array[index] = {
...item,
...(item.toTextColor === true && {
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
}),
...(item.toAllRow === true && {
columnFormatting: ObjectFormattingEnum.ENTIRE_ROW,
}),
};
}
}
},
);
}
const { colnames, coltypes } =
chart?.queriesResponse?.[0] ?? {};
const hasColumns =
Array.isArray(colnames) && Array.isArray(coltypes);
const allColumns = hasColumns
? [
{
value: ObjectFormattingEnum.ENTIRE_ROW,
label: t('entire row'),
dataType: GenericDataType.String,
},
...colnames.map((colname: string, index: number) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap?.[colname] ?? colname),
dataType: coltypes[index],
})),
]
: [];
const numericColumns = hasColumns
? colnames
.filter(
(colname: string, index: number) =>
coltypes[index] === GenericDataType.Numeric,
)
.map((colname: string) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap?.[colname] ?? colname),
// Every entry here already passed the Numeric filter
// above, so the type is always Numeric — no need to
// re-look it up (which breaks on duplicate colnames).
dataType: GenericDataType.Numeric,
}))
: [];
const numericColumns =
Array.isArray(colnames) && Array.isArray(coltypes)
? colnames
.filter(
(colname: string, index: number) =>
coltypes[index] === GenericDataType.Numeric,
)
.map((colname: string) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
dataType:
colnames && coltypes[colnames?.indexOf(colname)],
}))
: [];
const columnOptions = hasTimeComparison
? processComparisonColumns(
numericColumns || [],
@@ -888,7 +778,6 @@ const config: ControlPanelConfig = {
removeIrrelevantConditions: chartStatus === 'success',
columnOptions,
verboseMap,
allColumns,
extraColorChoices,
serverPagination: Boolean(
explore?.controls?.server_pagination?.value,
@@ -44,7 +44,6 @@ const metadata = new ChartMetadata({
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
'EXPORT_CURRENT_VIEW' as Behavior,
],
category: t('Table'),
canBeAnnotationTypes: ['EVENT', 'INTERVAL'],
@@ -19,7 +19,6 @@
import {
BackendOwnState,
ChartStateConverterOptions,
QuerySortBy,
type AgGridChartState,
type AgGridSortModel,
@@ -354,24 +353,7 @@ export function convertFilterModel(
*/
export function convertAgGridStateToOwnState(
agGridState: AgGridChartState,
options: ChartStateConverterOptions = {},
): Partial<BackendOwnState> {
// In client mode, AG Grid handles sort/filter/pagination locally, so for
// the *live* query none of it needs to reach the backend -- folding it
// into ownState there would only trigger an unnecessary requery/remount.
// A *download* query has no client-side pass to apply that state though:
// dashboard doesn't consume the Explore-only clientView snapshot, so
// exports still need it converted to reproduce the displayed
// sort/filter/columns (options.forExport).
//
// Only an explicit `false` is treated as "definitely client mode":
// legacy persisted table_state/permalinks predate serverPagination and
// have it `undefined`, and treating that the same as `false` would
// silently drop their persisted server-side sort/filter on restore.
if (agGridState.serverPagination === false && !options.forExport) {
return {};
}
const ownState: Partial<BackendOwnState> = {};
const sortBy = convertSortModel(agGridState.sortModel);
@@ -184,7 +184,6 @@ export const PaginationContainer = styled.div`
color: ${theme.colorTextBase};
transform: translateY(-${theme.sizeUnit}px);
background: ${theme.colorBgBase};
flex-shrink: 0;
`}
`;
@@ -354,7 +353,6 @@ export const StyledChartContainer = styled.div<{
.dropdown-controls-container {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.time-comparison-dropdown {
@@ -29,7 +29,6 @@ import {
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
normalizeCurrency,
NumberFormats,
QueryMode,
SMART_DATE_ID,
@@ -61,11 +60,7 @@ const { DATABASE_DATETIME } = TimeFormats;
function isNumeric(key: string, data: DataRecord[] = []) {
return data.every(
x =>
x[key] === null ||
x[key] === undefined ||
x[key] === '' ||
typeof x[key] === 'number',
x => x[key] === null || x[key] === undefined || typeof x[key] === 'number',
);
}
@@ -173,33 +168,7 @@ const getComparisonColFormatter = (
return formatter;
};
// transformProps is a single module-level function shared by every mounted
// instance of this chart plugin on a dashboard (one plugin registration,
// not one per chart). memoizeOne only remembers the single most-recent
// call, so wrapping a function in it directly here means unrelated chart
// instances evict each other's cached result whenever they render in the
// same tick, forcing a full rebuild - with brand-new array/object
// references - even when a given chart's own inputs are unchanged. AG
// Grid treats a new colDefs identity as "columns changed" and re-measures
// autoHeight/wrapText rows, which is what actually reads as a layout
// flicker on a chart that never changed. Keying a separate memoized
// function per chart id isolates each chart's cache from its siblings.
function memoizePerChart<Args extends unknown[], R>(
fn: (...args: Args) => R,
isEqual?: (newArgs: Args, lastArgs: Args) => boolean,
) {
const memoizedByChart = new Map<number, (...args: Args) => R>();
return (sliceId: number, ...args: Args): R => {
let fnForChart = memoizedByChart.get(sliceId);
if (!fnForChart) {
fnForChart = isEqual ? memoizeOne(fn, isEqual) : memoizeOne(fn);
memoizedByChart.set(sliceId, fnForChart);
}
return fnForChart(...args);
};
}
const processComparisonDataRecords = memoizePerChart(
const processComparisonDataRecords = memoizeOne(
function processComparisonDataRecords(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
@@ -340,7 +309,7 @@ const processComparisonColumns = (
const serverPageLengthMap = new Map();
const processDataRecords = memoizePerChart(function processDataRecords(
const processDataRecords = memoizeOne(function processDataRecords(
data: DataRecord[] | undefined,
columns: DataColumnMeta[],
) {
@@ -367,16 +336,11 @@ const processDataRecords = memoizePerChart(function processDataRecords(
return data;
});
const processColumns = memoizePerChart(function processColumns(
const processColumns = memoizeOne(function processColumns(
props: TableChartProps,
) {
const {
datasource: {
columnFormats,
currencyFormats,
verboseMap,
currencyCodeColumn,
},
datasource: { columnFormats, currencyFormats, verboseMap },
rawFormData: {
table_timestamp_format: tableTimestampFormat,
metrics: metrics_,
@@ -388,12 +352,7 @@ const processColumns = memoizePerChart(function processColumns(
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
const {
data: records,
colnames,
coltypes,
detected_currency: detectedCurrency,
} = queriesData[0] || {};
const { data: records, colnames, coltypes } = queriesData[0] || {};
// convert `metrics` and `percentMetrics` to the key names in `data.records`
const metrics = (metrics_ ?? []).map(getMetricLabel);
const rawPercentMetrics = (percentMetrics_ ?? []).map(getMetricLabel);
@@ -404,18 +363,13 @@ const processColumns = memoizePerChart(function processColumns(
const rawPercentMetricsSet = new Set(rawPercentMetrics);
const columns: DataColumnMeta[] = (colnames || [])
.map((key: string, originalIndex: number) => ({ key, originalIndex }))
.filter(
({ key }) =>
key =>
// if a metric was only added to percent_metrics, they should not show up in the table.
!(rawPercentMetricsSet.has(key) && !metricsSet.has(key)),
)
.map(({ key, originalIndex }) => {
// Look up by the column's original position in colnames/coltypes,
// not its position after the filter above — those diverge whenever
// an earlier column (e.g. a percent-metric-only one) got filtered
// out, which would otherwise shift every later column's dataType.
const dataType = coltypes[originalIndex];
.map((key: string, i) => {
const dataType = coltypes[i];
const config = columnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as metrics
// because users can also add things like `MAX(str_col)` as a metric.
@@ -477,25 +431,10 @@ const processColumns = memoizePerChart(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
// Resolve AUTO currency when currency column isn't in query results
let resolvedCurrency = currency;
if (
currency?.symbol === 'AUTO' &&
detectedCurrency &&
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
) {
const normalizedCurrency = normalizeCurrency(detectedCurrency);
if (normalizedCurrency) {
resolvedCurrency = {
...currency,
symbol: normalizedCurrency,
};
}
}
formatter = resolvedCurrency?.symbol
formatter = currency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,
currency: resolvedCurrency,
currency,
})
: getNumberFormatter(numberFormat);
}
@@ -509,7 +448,6 @@ const processColumns = memoizePerChart(function processColumns(
formatter,
config,
description,
currencyCodeColumn,
};
})
.sort((a, b) => {
@@ -556,7 +494,7 @@ const transformProps = (
queriesData = [],
ownState: serverPaginationData,
filterState,
hooks: { setDataMask = () => {}, onChartStateChange, onContextMenu },
hooks: { setDataMask = () => {}, onChartStateChange },
emitCrossFilters,
theme,
} = chartProps;
@@ -588,10 +526,10 @@ const transformProps = (
comparison_color_enabled: comparisonColorEnabled = false,
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
show_numbered_column: showNumberedColumn = false,
allow_rearrange_columns: allowRearrangeColumns = true,
allow_render_html: allowRenderHtml = true,
} = formData;
const allowRearrangeColumns = true;
// Calculate time comparison settings early since they're used in multiple places
const isUsingTimeComparison =
!isEmpty(time_compare) &&
@@ -744,7 +682,7 @@ const transformProps = (
hasServerPageLengthChanged = true;
}
const [, percentMetrics, columns] = processColumns(slice_id, chartProps);
const [, percentMetrics, columns] = processColumns(chartProps);
const timeGrain = extractTimegrain(formData);
@@ -762,34 +700,20 @@ const transformProps = (
);
}
// buildQuery.ts can append an "all records" percent-metric denominator
// query *and* a totals query, independently of each other, both landing
// in extraQueries before the totals one. A fixed totalQuery index would
// silently bind to the wrong query's data (or drop the totals query
// entirely) whenever both are present, so replicate buildQuery.ts's own
// gating condition here to know whether to skip that extra slot.
const hasAllRecordsExtraQuery = Boolean(
formData.percent_metrics?.length &&
(formData.percent_metric_calculation || 'row_limit') === 'all_records',
);
let baseQuery;
let countQuery;
let rowCount;
let totalQuery;
if (serverPagination) {
[baseQuery, countQuery] = queriesData;
totalQuery = hasAllRecordsExtraQuery ? queriesData[3] : queriesData[2];
[baseQuery, countQuery, totalQuery] = queriesData;
rowCount = (countQuery?.data?.[0]?.rowcount as number) ?? 0;
} else {
[baseQuery] = queriesData;
totalQuery = hasAllRecordsExtraQuery ? queriesData[2] : queriesData[1];
[baseQuery, totalQuery] = queriesData;
rowCount = baseQuery?.rowcount ?? 0;
}
const data = processDataRecords(slice_id, baseQuery?.data, columns);
const data = processDataRecords(baseQuery?.data, columns);
const comparisonData = processComparisonDataRecords(
slice_id,
baseQuery?.data,
columns,
comparisonSuffix,
@@ -869,12 +793,12 @@ const transformProps = (
// Map saved metric/calculated column labels to their SQL expressions for filter resolution
const metricSqlExpressions: Record<string, string> = {};
(chartProps.datasource?.metrics ?? []).forEach(metric => {
chartProps.datasource.metrics.forEach(metric => {
if (metric.metric_name && metric.expression) {
metricSqlExpressions[metric.metric_name] = metric.expression;
}
});
(chartProps.datasource?.columns ?? []).forEach(col => {
chartProps.datasource.columns.forEach(col => {
if (col.column_name && col.expression) {
metricSqlExpressions[col.column_name] = col.expression;
if (col.verbose_name && col.verbose_name !== col.column_name) {
@@ -887,7 +811,7 @@ const transformProps = (
// backed by a dataset (physical or calculated) column can be summed
// server-side; free-form SQL expression columns are excluded.
const datasetColumnNames = new Set(
(chartProps.datasource?.columns ?? [])
chartProps.datasource.columns
.map(col => col.column_name)
.filter((name): name is string => Boolean(name)),
);
@@ -925,7 +849,6 @@ const transformProps = (
filters: filterState.filters,
emitCrossFilters,
allowRearrangeColumns,
allowRenderHtml,
slice_id,
serverPagination,
rowCount,
@@ -950,7 +873,6 @@ const transformProps = (
chartState,
onChartStateChange,
showNumberedColumn,
onContextMenu,
};
};
@@ -39,7 +39,6 @@ import {
JsonObject,
Metric,
AgGridChartState,
ContextMenuFilters,
} from '@superset-ui/core';
import {
ColDef,
@@ -82,7 +81,6 @@ export type TableChartFormData = QueryFormData & {
time_grain_sqla?: TimeGranularity;
column_config?: Record<string, TableColumnConfig>;
allow_rearrange_columns?: boolean;
allow_render_html?: boolean;
show_numbered_column?: boolean;
};
@@ -136,11 +134,6 @@ export interface AgGridTableChartTransformedProps<
onChartStateChange?: (chartState: JsonObject) => void;
chartState?: AgGridChartState;
showNumberedColumn: boolean;
onContextMenu?: (
clientX: number,
clientY: number,
filters?: ContextMenuFilters,
) => void;
}
export interface SortState {
@@ -203,7 +196,6 @@ export interface InputColumn {
originalLabel?: string;
metricName?: string;
description?: string;
currencyCodeColumn?: string;
}
export type ValueRange = [number, number] | null;
@@ -20,17 +20,6 @@
import { SetDataMaskHook } from '@superset-ui/core';
import { SortByItem } from '../types';
export interface ClientViewColumn {
key: string;
label: string;
}
export interface ClientViewSnapshot {
rows: Record<string, unknown>[];
columns: ClientViewColumn[];
count: number;
}
interface TableOwnState {
currentPage?: number;
pageSize?: number;
@@ -40,7 +29,6 @@ interface TableOwnState {
sortBy?: SortByItem[];
rawSummaryColumns?: string[];
totalsRequested?: boolean;
clientView?: ClientViewSnapshot;
}
export const updateTableOwnState = (
@@ -17,11 +17,9 @@
* under the License.
*/
import {
CurrencyFormatter,
DataRecordValue,
getSmallNumberFormatter,
isDefined,
isEmptyDateInput,
isProbablyHTML,
sanitizeHtml,
DateWithFormatter,
@@ -39,8 +37,6 @@ import { DataColumnMeta, InputColumn } from '../types';
function formatValue(
formatter: DataColumnMeta['formatter'],
value: DataRecordValue,
rowData?: Record<string, DataRecordValue>,
currencyColumn?: string,
): [boolean, string] {
// render undefined as empty string
if (value === undefined) {
@@ -49,17 +45,13 @@ function formatValue(
// render null as `N/A`
if (
value === null ||
// null/empty values in temporal columns are wrapped in a Date object, so make
// sure we handle them here too
(value instanceof DateWithFormatter && isEmptyDateInput(value.input))
// null values in temporal columns are wrapped in a Date object, so make sure we
// handle them here too
(value instanceof DateWithFormatter && value.input === null)
) {
return [false, 'N/A'];
}
if (formatter) {
// If formatter is a CurrencyFormatter, pass row context for AUTO mode
if (formatter instanceof CurrencyFormatter) {
return [false, formatter(value as number, rowData, currencyColumn)];
}
return [false, formatter(value as number)];
}
if (typeof value === 'string') {
@@ -71,9 +63,8 @@ function formatValue(
export function formatColumnValue(
column: DataColumnMeta,
value: DataRecordValue,
rowData?: Record<string, DataRecordValue>,
) {
const { dataType, formatter, config = {}, currencyCodeColumn } = column;
const { dataType, formatter, config = {} } = column;
const isNumber = dataType === GenericDataType.Numeric;
const smallNumberFormatter = getSmallNumberFormatter(
formatter,
@@ -85,8 +76,6 @@ export function formatColumnValue(
? smallNumberFormatter
: formatter,
value,
rowData,
currencyCodeColumn,
);
}
@@ -94,24 +83,13 @@ export const valueFormatter = (
params: ValueFormatterParams,
col: InputColumn,
): string => {
const { value, node, data } = params;
const { value, node } = params;
if (
isDefined(value) &&
value !== '' &&
!(value instanceof DateWithFormatter && isEmptyDateInput(value.input))
!(value instanceof DateWithFormatter && value.input === null)
) {
// Fall back to String(value) rather than the raw value: value can be a
// DateWithFormatter/Date (or other object) when col.formatter is unset or
// returns a falsy result, and returning that raw object here - though it
// satisfies this function's `: string` signature at compile time since
// `value`'s param type is loosely typed - crashes React with "Objects are
// not valid as a React child" once a cell renderer renders it directly.
if (col.formatter instanceof CurrencyFormatter) {
return (
col.formatter(value, data, col.currencyCodeColumn) || String(value)
);
}
return col.formatter?.(value) || String(value);
return col.formatter?.(value) || value;
}
if (node?.level === -1) {
return '';
@@ -55,81 +55,29 @@ const getCellStyle = (params: CellStyleParams) => {
let backgroundColor;
let color;
if (hasColumnColorFormatters) {
const applyFormatter = (
formatter: ColorFormatters[number],
valueToFormat: typeof value,
) => {
const formatterResult =
valueToFormat || valueToFormat === 0
? formatter.getColorFromValue(valueToFormat)
: false;
if (formatterResult) {
if (
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor
) {
color = formatterResult;
} else if (
formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR
) {
backgroundColor = formatterResult;
columnColorFormatters!
.filter(formatter => {
const colTitle = formatter?.column?.includes('Main')
? formatter?.column?.replace('Main', '').trim()
: formatter?.column;
return colTitle === colDef.field;
})
.forEach(formatter => {
const formatterResult =
value || value === 0 ? formatter.getColorFromValue(value) : false;
if (formatterResult) {
if (
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor
) {
color = formatterResult;
} else if (
formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR
) {
backgroundColor = formatterResult;
}
}
}
};
// formatter.column can be a legacy display label ("Main colname") for
// time-comparison columns rather than the row's actual data key, so
// resolve it to the real field id before using it to read row values.
const resolveColumnKey = (columnKey: string) =>
columnKey.startsWith('Main ')
? columnKey.slice('Main '.length)
: columnKey;
// Formatters with no formatting target color their own source column,
// keyed off this cell's own value. Excludes legacy v1 `toAllRow` rules,
// which are entire-row formatters handled below.
columnColorFormatters!
.filter(
formatter =>
!formatter.columnFormatting &&
!formatter.toAllRow &&
resolveColumnKey(formatter.column) === colDef.field,
)
.forEach(formatter => applyFormatter(formatter, value));
// Formatters with a real target column color that target column,
// keyed off the value in the formatter's own (source) column.
columnColorFormatters!
.filter(
formatter =>
formatter.columnFormatting &&
formatter.columnFormatting !== ObjectFormattingEnum.ENTIRE_ROW &&
resolveColumnKey(formatter.columnFormatting) === colDef.field,
)
.forEach(formatter =>
applyFormatter(
formatter,
node?.data?.[resolveColumnKey(formatter.column)],
),
);
// Entire-row formatters apply to every cell in the row, keyed off the
// value in the formatter's own column rather than this cell's column.
// `toAllRow` is the legacy v1 flag for the same behavior; migrated
// charts carry it over unchanged rather than being rewritten to
// `columnFormatting: ENTIRE_ROW`, so both are honored here.
columnColorFormatters!
.filter(
formatter =>
formatter.columnFormatting === ObjectFormattingEnum.ENTIRE_ROW ||
formatter.toAllRow,
)
.forEach(formatter =>
applyFormatter(
formatter,
node?.data?.[resolveColumnKey(formatter.column)],
),
);
});
}
if (
@@ -1,48 +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.
*/
let cached: number | undefined;
const css = (x: TemplateStringsArray) => x.join('\n');
export default function getScrollBarSize(forceRefresh = false) {
if (typeof document === 'undefined') {
return 0;
}
if (cached === undefined || forceRefresh) {
const inner = document.createElement('div');
const outer = document.createElement('div');
inner.style.cssText = css`
width: auto;
height: 100%;
overflow: scroll;
`;
outer.style.cssText = css`
position: absolute;
visibility: hidden;
overflow: hidden;
width: 100px;
height: 50px;
`;
outer.append(inner);
document.body.append(outer);
cached = outer.clientWidth - inner.clientWidth;
outer.remove();
}
return cached;
}
@@ -1,80 +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 { TimeGranularity } from '@superset-ui/core';
/**
* Calculates the inclusive/exclusive temporal range for a bucket.
* standard SQL range pattern: [start, end)
*/
export default function getTimeRangeFromGranularity(
startTime: Date,
granularity: TimeGranularity,
): [Date, Date] {
const time = startTime.getTime();
const date = startTime.getUTCDate();
const month = startTime.getUTCMonth();
const year = startTime.getUTCFullYear();
// Constants
const MS_IN_SECOND = 1000;
const MS_IN_MINUTE = 60 * MS_IN_SECOND;
const MS_IN_HOUR = 60 * MS_IN_MINUTE;
switch (granularity) {
case TimeGranularity.SECOND:
return [startTime, new Date(time + MS_IN_SECOND)];
case TimeGranularity.MINUTE:
return [startTime, new Date(time + MS_IN_MINUTE)];
case TimeGranularity.FIVE_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 5)];
case TimeGranularity.TEN_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 10)];
case TimeGranularity.FIFTEEN_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 15)];
case TimeGranularity.THIRTY_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 30)];
case TimeGranularity.HOUR:
return [startTime, new Date(time + MS_IN_HOUR)];
case TimeGranularity.DAY:
case TimeGranularity.DATE:
return [startTime, new Date(Date.UTC(year, month, date + 1))];
case TimeGranularity.WEEK:
case TimeGranularity.WEEK_STARTING_SUNDAY:
case TimeGranularity.WEEK_STARTING_MONDAY:
return [startTime, new Date(Date.UTC(year, month, date + 7))];
case TimeGranularity.WEEK_ENDING_SATURDAY:
case TimeGranularity.WEEK_ENDING_SUNDAY:
// Week-ending buckets are labeled by the bucket's final day.
return [
new Date(Date.UTC(year, month, date - 6)),
new Date(Date.UTC(year, month, date + 1)),
];
case TimeGranularity.MONTH:
return [startTime, new Date(Date.UTC(year, month + 1, 1))];
case TimeGranularity.QUARTER:
return [
startTime,
new Date(Date.UTC(year, Math.floor(month / 3) * 3 + 3, 1)),
];
case TimeGranularity.YEAR:
return [startTime, new Date(Date.UTC(year + 1, 0, 1))];
default:
return [startTime, new Date(Date.UTC(year, month, date + 1))];
}
}
@@ -17,7 +17,6 @@
* under the License.
*/
import { isEqualArray } from '@superset-ui/core';
import { isEqual } from 'lodash-es';
import { TableChartProps } from '../types';
const getDescriptions = (props: TableChartProps) => {
@@ -48,55 +47,23 @@ export default function isEqualColumns(
const descA = getDescriptions(a);
const descB = getDescriptions(b);
// Every field below is read with optional chaining because this comparator
// also runs against partial/mock props in tests; production TableChartProps
// always has these populated.
const checks = {
// These three are plain, serializable per-column config maps. Superset's
// core datasource pipeline can rebuild them with a new object reference
// on renders that don't actually change any formatting, so compare by
// value here - otherwise an incidental new reference looks like a real
// change and forces a full AG Grid column/row rebuild downstream.
columnFormats: isEqual(
a.datasource?.columnFormats,
b.datasource?.columnFormats,
),
currencyFormats: isEqual(
a.datasource?.currencyFormats,
b.datasource?.currencyFormats,
),
verboseMap: isEqual(a.datasource?.verboseMap, b.datasource?.verboseMap),
currencyCodeColumn:
a.datasource?.currencyCodeColumn === b.datasource?.currencyCodeColumn,
detectedCurrency:
a.queriesData?.[0]?.detected_currency ===
b.queriesData?.[0]?.detected_currency,
tableTimestampFormat:
a.formData?.tableTimestampFormat === b.formData?.tableTimestampFormat,
timeGrainSqla: a.formData?.timeGrainSqla === b.formData?.timeGrainSqla,
columnConfig:
JSON.stringify(a.formData?.columnConfig || null) ===
JSON.stringify(b.formData?.columnConfig || null),
metrics: isEqualArray(a.formData?.metrics, b.formData?.metrics),
colnames: isEqualArray(
a.queriesData?.[0]?.colnames,
b.queriesData?.[0]?.colnames,
),
coltypes: isEqualArray(
a.queriesData?.[0]?.coltypes,
b.queriesData?.[0]?.coltypes,
),
extraFilters:
JSON.stringify(a.formData?.extraFilters || null) ===
JSON.stringify(b.formData?.extraFilters || null),
extraFormData:
JSON.stringify(a.formData?.extraFormData || null) ===
JSON.stringify(b.formData?.extraFormData || null),
rawColumnConfig:
JSON.stringify(a.rawFormData?.column_config || null) ===
JSON.stringify(b.rawFormData?.column_config || null),
descriptions: JSON.stringify(descA) === JSON.stringify(descB),
};
return Object.values(checks).every(Boolean);
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.currencyFormats === b.datasource.currencyFormats &&
a.datasource.verboseMap === b.datasource.verboseMap &&
a.formData.tableTimestampFormat === b.formData.tableTimestampFormat &&
a.formData.timeGrainSqla === b.formData.timeGrainSqla &&
JSON.stringify(a.formData.columnConfig || null) ===
JSON.stringify(b.formData.columnConfig || null) &&
isEqualArray(a.formData.metrics, b.formData.metrics) &&
isEqualArray(a.queriesData?.[0]?.colnames, b.queriesData?.[0]?.colnames) &&
isEqualArray(a.queriesData?.[0]?.coltypes, b.queriesData?.[0]?.coltypes) &&
JSON.stringify(a.formData.extraFilters || null) ===
JSON.stringify(b.formData.extraFilters || null) &&
JSON.stringify(a.formData.extraFormData || null) ===
JSON.stringify(b.formData.extraFormData || null) &&
JSON.stringify(a.rawFormData.column_config || null) ===
JSON.stringify(b.rawFormData.column_config || null) &&
JSON.stringify(descA) === JSON.stringify(descB)
);
}
@@ -28,15 +28,11 @@ import { useCallback, useMemo } from 'react';
import {
DataRecordValue,
DateWithFormatter,
isEmptyDateInput,
JsonObject,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useTheme } from '@apache-superset/core/theme';
import {
ColorFormatters,
ConditionalFormattingConfig,
} from '@superset-ui/chart-controls';
import { ColorFormatters } from '@superset-ui/chart-controls';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import {
BasicColorFormatterType,
@@ -75,15 +71,11 @@ type UseColDefsProps = {
colorPositiveNegative: boolean;
columnColorFormatters: ColorFormatters;
allowRearrangeColumns?: boolean;
allowRenderHtml?: boolean;
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
isUsingTimeComparison?: boolean;
emitCrossFilters?: boolean;
alignPositiveNegative: boolean;
slice_id: number;
conditionalFormatting?: ConditionalFormattingConfig[];
comparisonColorEnabled?: boolean;
comparisonColorScheme?: string;
};
function getValueRange(
@@ -139,7 +131,7 @@ const getFilterType = (col: InputColumn) => {
/**
* Filter value getter for temporal columns.
* Returns null for DateWithFormatter objects with null/empty input,
* Returns null for DateWithFormatter objects with null input,
* enabling AG Grid's blank filter to correctly identify null dates.
*/
const dateFilterValueGetter = (params: {
@@ -147,8 +139,8 @@ const dateFilterValueGetter = (params: {
colDef: { field?: string };
}) => {
const value = params.data?.[params.colDef.field as string];
// Return null for DateWithFormatter with null/empty input so AG Grid blank filter works
if (value instanceof DateWithFormatter && isEmptyDateInput(value.input)) {
// Return null for DateWithFormatter with null input so AG Grid blank filter works
if (value instanceof DateWithFormatter && value.input === null) {
return null;
}
return value;
@@ -244,37 +236,13 @@ export const useColDefs = ({
colorPositiveNegative,
columnColorFormatters,
allowRearrangeColumns,
allowRenderHtml,
basicColorFormatters,
isUsingTimeComparison,
emitCrossFilters,
alignPositiveNegative,
slice_id,
conditionalFormatting,
comparisonColorEnabled,
comparisonColorScheme,
}: UseColDefsProps) => {
const theme = useTheme();
// transformProps.ts computes these fresh on every call (no memoization),
// so a reference-based dependency here would recreate getCommonColProps -
// and therefore colDefs - on every render regardless of whether the
// formatting actually changed. Compare by content instead.
//
// columnColorFormatters/basicColorFormatters can't be stringified directly:
// each entry's getColorFromValue closes over the rule's operator/
// thresholds/gradient/color, none of which are mirrored as serializable
// fields on the entry itself, so JSON.stringify drops them and two
// differently-configured rules for the same column serialize identically.
// Depend on the raw, fully-serializable formData that produced those
// formatters instead.
const stringifiedColumnColorFormatters = JSON.stringify(
conditionalFormatting,
);
const stringifiedBasicColorFormatters = JSON.stringify([
conditionalFormatting,
comparisonColorEnabled,
comparisonColorScheme,
]);
const getCommonColProps = useCallback(
(
col: InputColumn,
@@ -419,7 +387,7 @@ export const useColDefs = ({
cellRenderer: (p: CellRendererProps) =>
isTextColumn ? TextCellRenderer(p) : NumericCellRenderer(p),
cellRendererParams: {
allowRenderHtml,
allowRenderHtml: true,
columns,
hasBasicColorFormatters,
col,
@@ -433,12 +401,6 @@ export const useColDefs = ({
isMetric,
isPercentMetric,
isNumeric,
// colId (`field` above) has "Main " stripped for comparison
// columns, but row data is still keyed by the unstripped
// originalKey -- consumers reading row values by column (e.g. the
// "Export Current View" snapshot) need this to look values up
// correctly.
dataKey: originalKey,
},
lockPinned: !allowRearrangeColumns,
sortable: !serverPagination || !isPercentMetric,
@@ -465,15 +427,14 @@ export const useColDefs = ({
columns,
data,
defaultAlignPN,
stringifiedColumnColorFormatters,
stringifiedBasicColorFormatters,
columnColorFormatters,
basicColorFormatters,
showCellBars,
colorPositiveNegative,
isUsingTimeComparison,
isRawRecords,
emitCrossFilters,
allowRearrangeColumns,
allowRenderHtml,
serverPagination,
alignPositiveNegative,
theme.colorBgBase,
@@ -17,14 +17,7 @@
* under the License.
*/
import '@testing-library/jest-dom';
import {
render,
screen,
waitFor,
fireEvent,
within,
userEvent,
} from '@superset-ui/core/spec';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -266,59 +259,6 @@ test('AgGridTableChart renders Search by dropdown if includeSearch is true and t
expect(screen.getByText(/Search by/i)).toBeInTheDocument();
});
test('AgGridTableChart resets currentPage when the search column changes', async () => {
const props = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
server_pagination: true,
include_search: true,
},
});
props.serverPagination = true;
props.includeSearch = true;
props.rowCount = 50;
props.serverPaginationData = {
currentPage: 1,
pageSize: 20,
};
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
const searchByContainer = await waitFor(() => {
const container = document.querySelector('.search-select');
expect(container).toBeInTheDocument();
return container as HTMLElement;
});
const searchByDropdown = within(searchByContainer).getByRole('combobox');
await userEvent.click(searchByDropdown);
const otherOption = await waitFor(() =>
within(screen.getByRole('listbox')).getByText('abc.com'),
);
await userEvent.click(otherOption);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
searchColumn: 'abc.com',
currentPage: 0,
}),
}),
);
});
});
test('AgGridTableChart does not render Search by dropdown if includeSearch is true but searchOptions is empty', async () => {
const noStringColumnsData = {
...testData.basic,
@@ -933,24 +873,9 @@ test('AgGridTableChart emits column state with aggFunc through the debounced sav
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
// The very first onStateUpdated after mount just reflects the chartState
// the grid was initialized with, so it must not trigger a save on its own
// (persisting it unconditionally caused a mount -> save -> remount ->
// mount loop). Let that initial debounced capture settle before
// simulating a real user action - clicking a sortable header - so it
// isn't coalesced into the same debounce window and mistaken for the
// initial, ignorable capture.
await new Promise(resolve => setTimeout(resolve, 1500));
const sortableHeaderLabel = document.querySelector(
'.ag-header-cell-sortable .ag-header-cell-label',
);
expect(sortableHeaderLabel).toBeTruthy();
fireEvent.click(sortableHeaderLabel!);
// The save path is debounced (SLOW_DEBOUNCE = 500ms); wait for a capture.
await waitFor(() => expect(onChartStateChange).toHaveBeenCalled(), {
timeout: 5000,
timeout: 3000,
});
const savedState =
@@ -964,66 +889,3 @@ test('AgGridTableChart emits column state with aggFunc through the debounced sav
// (SharedAggregation) module; the community modules always report null.
expect(savedColumn).toMatchObject({ aggFunc: null });
});
test('AgGridTableChart renders a temporal column with a blank row without crashing', async () => {
// Regression test: a raw-mode temporal column backed by numeric epoch
// values, where one row's raw value is '' rather than null/undefined/a
// number, used to flip isNumeric() false for the whole column (see
// transformProps.ts), degrading its formatter to plain `String`. That made
// DateWithFormatter.toString() return String('') for the blank row, which
// is falsy - and valueFormatter's old `|| value` fallback then rendered the
// raw Date object directly, crashing React with "Objects are not valid as
// a React child (found: [object Date])".
const props = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Raw,
table_timestamp_format: SMART_DATE_ID,
server_pagination: false,
},
queriesData: [
{
...testData.basic.queriesData[0],
colnames: ['__timestamp', 'name'],
coltypes: [GenericDataType.Temporal, GenericDataType.String],
data: [
{ __timestamp: 1069113600000, name: 'foo' },
{ __timestamp: 1057016400000, name: 'bar' },
{ __timestamp: '', name: 'baz' },
],
},
],
});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
const reactChildError = errorSpy.mock.calls
.map(call => call.join(' '))
.find(message =>
message.includes('Objects are not valid as a React child'),
);
errorSpy.mockRestore();
expect(reactChildError).toBeUndefined();
const cells = document.querySelectorAll('[col-id="__timestamp"]');
const cellText = Array.from(cells).map(cell => cell.textContent);
expect(cellText).toContain('N/A');
expect(cellText).not.toContain('');
});
@@ -834,51 +834,6 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.extras).toBeDefined();
});
test('should exclude AG Grid HAVING filters from totals query', () => {
const { queries } = buildQuery(
{
...basicFormData,
server_pagination: true,
show_totals: true,
query_mode: QueryMode.Aggregate,
},
{
ownState: {
agGridHavingClause: 'count > 10',
},
},
);
const mainQuery = queries[0];
const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals
expect(mainQuery.extras?.having).toBe('count > 10');
expect(totalsQuery.extras?.having).toBeUndefined();
});
test('should exclude download HAVING filters (sqlClauses) from totals query', () => {
const { queries } = buildQuery(
{
...basicFormData,
show_totals: true,
query_mode: QueryMode.Aggregate,
result_format: 'csv',
},
{
ownState: {
sqlClauses: { count: 'count > 10' },
},
},
);
const mainQuery = queries[0];
// Downloads never get a rowcount query, so totals is queries[1].
const totalsQuery = queries[1];
expect(mainQuery.extras?.having).toBe('count > 10');
expect(totalsQuery.extras?.having).toBeUndefined();
});
test('should not modify totals query when no AG Grid filters applied', () => {
const { queries } = buildQuery(
{
@@ -898,43 +853,6 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.row_limit).toBe(0);
});
test('all_records percent-metric denominator reflects AG Grid filters but totals do not', () => {
// Regression test: the all_records denominator query is built from
// the post-filter queryObject (so it matches the main query's result
// set), while the totals query intentionally strips AG Grid
// WHERE/HAVING so it summarizes the unfiltered chart-level data.
const { queries } = buildQuery(
{
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
percent_metric_calculation: 'all_records',
show_totals: true,
server_pagination: true,
query_mode: QueryMode.Aggregate,
},
{
ownState: {
agGridComplexWhere: 'age > 18',
},
},
);
// [main, rowcount, all_records denominator, totals]
const allRecordsQuery = queries[2];
const totalsQuery = queries[3];
expect(allRecordsQuery.extras?.where).toBe('age > 18');
expect(allRecordsQuery.columns).toEqual([]);
expect(allRecordsQuery.metrics).toEqual(['count']);
expect(allRecordsQuery.row_limit).toBe(0);
expect(allRecordsQuery.row_offset).toBe(0);
expect(allRecordsQuery.orderby).toEqual([]);
expect(allRecordsQuery.is_timeseries).toBe(false);
expect(totalsQuery.extras?.where).toBeUndefined();
});
test('should reapply percent-metric contribution op to totals query', () => {
// Regression test for #37627: when a percent metric is configured and
// Show Summary (show_totals) is enabled, the totals query must rename
@@ -184,45 +184,3 @@ test('every Visual formatting control is a renderTrigger', () => {
expect(control.config.renderTrigger).toBe(true);
});
});
function findControl(
panel: ControlPanelConfig,
controlName: string,
): CustomControlItem {
const item = (panel.controlPanelSections || [])
.flatMap(section => section?.controlSetRows || [])
.flat()
.find(c => isCustomControlItem(c) && c.name === controlName);
if (!item || !isCustomControlItem(item)) {
throw new Error(`Control "${controlName}" not found`);
}
return item;
}
test('allow_rearrange_columns defaults to false, matching v1, and hides while time_compare is set', () => {
const control = findControl(config, 'allow_rearrange_columns');
expect(control.config.type).toBe('CheckboxControl');
expect(control.config.default).toBe(false);
expect(control.config.renderTrigger).toBe(true);
const vis = control.config.visibility as VisibilityFn;
expect(
vis({
controls: { time_compare: { value: [] } },
} as unknown as ControlPanelsContainerProps),
).toBe(true);
expect(
vis({
controls: { time_compare: { value: ['1 year ago'] } },
} as unknown as ControlPanelsContainerProps),
).toBe(false);
});
test('allow_render_html defaults to true, matching v1, and has no visibility gate', () => {
const control = findControl(config, 'allow_render_html');
expect(control.config.type).toBe('CheckboxControl');
expect(control.config.default).toBe(true);
expect(control.config.renderTrigger).toBe(true);
expect(control.config.visibility).toBeUndefined();
});
@@ -178,38 +178,6 @@ test('extraColorChoices not included when time_compare is empty array', () => {
expect(result.extraColorChoices).toEqual([]);
});
test('numericColumns resolves dataType by position, not a stale name lookup', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const explore = createMockExplore(undefined);
// Two columns share the name "metric" (e.g. a dimension and a metric
// both aliased the same way); only the second occurrence is Numeric.
const chart = {
chartStatus: 'success' as const,
queriesResponse: [
{
colnames: ['metric', 'metric'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
},
],
};
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
// Resolving dataType via `colnames.indexOf(colname)` would always find
// the first "metric" (String) and misclassify this numeric column.
expect(result.columnOptions).toEqual([
expect.objectContaining({
value: 'metric',
dataType: GenericDataType.Numeric,
}),
]);
});
test('consistency between extraColorChoices and columnOptions', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
@@ -1,290 +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 { render, waitFor } from '@superset-ui/core/spec';
import { DateWithFormatter, TimeGranularity } from '@superset-ui/core';
import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers';
import testData from '../../plugin-chart-table/test/testData';
// Only the context-menu handler is exercised below; the mock below fakes
// its event argument rather than a real ag-grid CellContextMenuEvent, so
// it's typed loosely (unknown) rather than pinned to that library type.
interface CapturedGridProps {
onCellContextMenu?: (event: Record<string, unknown>) => void;
}
// Capture the props the grid is rendered with, so we can invoke the
// onCellContextMenu handler directly without depending on AG Grid's DOM
// rendering or the (unregistered) Enterprise context-menu module.
const captured: { props?: CapturedGridProps } = {};
jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
__esModule: true,
ThemedAgGridReact: (props: CapturedGridProps) => {
captured.props = props;
return null;
},
AgGridReact: function AgGridReact() {
return null;
},
AllCommunityModule: {},
ClientSideRowModelModule: {},
ModuleRegistry: { registerModules: () => undefined },
setupAGGridModules: () => undefined,
defaultModules: [],
themeQuartz: {},
colorSchemeDark: {},
colorSchemeLight: {},
}));
// Imported after the mock is declared (jest.mock is hoisted above imports).
// eslint-disable-next-line import/first
import AgGridTableChart from '../src/AgGridTableChart';
// eslint-disable-next-line import/first
import transformProps from '../src/transformProps';
function renderChart(
onContextMenu: jest.Mock,
propsOverrides: Record<string, unknown> = {},
) {
captured.props = undefined;
const props = {
...transformProps({
...testData.basic,
hooks: { ...testData.basic.hooks, onContextMenu },
emitCrossFilters: true,
}),
...propsOverrides,
};
render(
ProviderWrapper({
children: (
<AgGridTableChart {...props} setDataMask={jest.fn()} slice_id={1} />
),
}),
);
}
function makeColumn(colId: string, context: Record<string, unknown> = {}) {
return {
getColId: () => colId,
getColDef: () => ({ context }),
};
}
test('wires an onCellContextMenu handler when onContextMenu is provided', async () => {
renderChart(jest.fn());
await waitFor(() => expect(captured.props).toBeDefined());
expect(typeof captured.props?.onCellContextMenu).toBe('function');
});
test('right-clicking a dimension cell emits drillToDetail, crossFilter and drillBy', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const rowData = {
__timestamp: null,
name: 'Michael',
sum__num: 2467063,
'abc.com': 'foo',
};
captured.props?.onCellContextMenu?.({
column: makeColumn('name'),
data: rowData,
value: 'Michael',
event: {
preventDefault,
stopPropagation,
clientX: 10,
clientY: 20,
},
});
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [clientX, clientY, filters] = onContextMenu.mock.calls[0];
expect(clientX).toBe(10);
expect(clientY).toBe(20);
// Non-temporal, non-null column → exact-match filter.
expect(filters.drillToDetail).toEqual(
expect.arrayContaining([
expect.objectContaining({ col: 'name', op: '==', val: 'Michael' }),
expect.objectContaining({ col: 'abc.com', op: '==', val: 'foo' }),
]),
);
// Null column → IS NULL filter, not an exact match on null.
expect(filters.drillToDetail).toEqual(
expect.arrayContaining([
expect.objectContaining({ col: '__timestamp', op: 'IS NULL' }),
]),
);
expect(filters.crossFilter).toBeDefined();
expect(filters.drillBy).toEqual({
filters: [{ col: 'name', op: '==', val: 'Michael' }],
groupbyFieldName: 'groupby',
});
});
test('right-clicking a null cell emits an IS NULL drillBy filter with a null val', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('__timestamp'),
data: { __timestamp: null, name: 'Michael', sum__num: 2467063 },
value: null,
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
// op and val must agree: IS NULL must carry a null val, not the clicked
// cell's (possibly wrapped) value.
expect(filters.drillBy).toEqual({
filters: [{ col: '__timestamp', op: 'IS NULL', val: null }],
groupbyFieldName: 'groupby',
});
});
test('right-clicking a blank (empty-string) date cell emits IS NULL, not an equality filter on an invalid date', async () => {
// A blank temporal value arrives wrapped as DateWithFormatter(input: ''),
// not null/undefined -- the null checks below must treat that the same
// as null rather than falling through to the temporal/equality branches,
// which would build an invalid Date or serialize the filter value as null
// under an '==' op instead of an 'IS NULL' op.
const onContextMenu = jest.fn();
const blankDate = new DateWithFormatter('');
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('__timestamp'),
data: { __timestamp: blankDate, name: 'Michael', sum__num: 2467063 },
value: blankDate,
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
expect(filters.drillToDetail).toEqual(
expect.arrayContaining([
expect.objectContaining({ col: '__timestamp', op: 'IS NULL' }),
]),
);
expect(filters.drillBy).toEqual({
filters: [{ col: '__timestamp', op: 'IS NULL', val: null }],
groupbyFieldName: 'groupby',
});
});
test('right-clicking a metric cell omits crossFilter and drillBy', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('sum__num', { isMetric: true }),
data: { name: 'Michael', sum__num: 2467063 },
value: 2467063,
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
expect(filters.crossFilter).toBeUndefined();
expect(filters.drillBy).toBeUndefined();
// drillToDetail is still populated from the row's dimension columns.
expect(filters.drillToDetail.length).toBeGreaterThan(0);
});
test('right-clicking a temporal cell with a time grain emits a TEMPORAL_RANGE filter', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu, { timeGrain: TimeGranularity.DAY });
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('name'),
data: {
__timestamp: '2020-01-01T12:34:56.000Z',
name: 'Michael',
sum__num: 2467063,
},
value: 'Michael',
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
const timestampFilter = filters.drillToDetail.find(
(f: { col: string }) => f.col === '__timestamp',
);
expect(timestampFilter.op).toBe('TEMPORAL_RANGE');
// DAY granularity's range starts at the row's own timestamp (not
// truncated to midnight) and ends at the start of the next UTC day.
expect(timestampFilter.val).toBe(
'2020-01-01T12:34:56.000Z : 2020-01-02T00:00:00.000Z',
);
});
test('does not call onContextMenu in raw records mode', async () => {
const onContextMenu = jest.fn();
// isRawRecords is derived from query_mode inside transformProps; force it
// here to isolate the handler's own guard from that derivation.
renderChart(onContextMenu, { isRawRecords: true });
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('name'),
data: { name: 'Michael' },
value: 'Michael',
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
expect(onContextMenu).not.toHaveBeenCalled();
});
@@ -1,172 +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 { CurrencyFormatter, DateWithFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { ValueFormatterParams } from '@superset-ui/core/components/ThemedAgGridReact';
import {
formatColumnValue,
valueFormatter,
valueGetter,
} from '../src/utils/formatValue';
import { DataColumnMeta, InputColumn } from '../src/types';
const baseCol: InputColumn = {
key: 'order_date',
label: 'order_date',
dataType: GenericDataType.Temporal,
isNumeric: false,
isMetric: false,
isPercentMetric: false,
config: {},
};
function makeParams(value: unknown, node?: { level?: number }) {
return {
value,
node,
data: {},
} as unknown as ValueFormatterParams;
}
test('valueFormatter never returns a raw Date/object when col.formatter is unset', () => {
// Regression test: order_date (or any temporal column) is wrapped into a
// DateWithFormatter instance before reaching this function. If col.formatter
// is undefined - or returns a falsy result - the old `|| value` fallback
// returned that raw object, which crashes React with "Objects are not valid
// as a React child" once a cell renderer renders it directly.
const date = new DateWithFormatter(1069113600000);
const result = valueFormatter(makeParams(date), {
...baseCol,
formatter: undefined,
});
expect(typeof result).toBe('string');
expect(result).not.toBe(date);
});
test('valueFormatter falls back to a string when the formatter returns a falsy result', () => {
const date = new DateWithFormatter(1069113600000);
const formatter = jest.fn().mockReturnValue('');
const result = valueFormatter(makeParams(date), {
...baseCol,
formatter: formatter as unknown as InputColumn['formatter'],
});
expect(typeof result).toBe('string');
expect(result).not.toBe(date);
});
test('valueFormatter falls back to a string when the CurrencyFormatter returns a falsy result', () => {
const currencyFormatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
jest.spyOn(currencyFormatter, 'format').mockReturnValue('');
const result = valueFormatter(makeParams(42), {
...baseCol,
dataType: GenericDataType.Numeric,
formatter: currencyFormatter,
});
expect(typeof result).toBe('string');
expect(result).toBe('42');
});
test('valueFormatter uses the formatter result when it is truthy', () => {
const formatter = jest.fn().mockReturnValue('2003-11-18');
const result = valueFormatter(
makeParams(new DateWithFormatter(1069113600000)),
{
...baseCol,
formatter: formatter as unknown as InputColumn['formatter'],
},
);
expect(result).toBe('2003-11-18');
});
test('valueFormatter returns N/A for a DateWithFormatter wrapping a null input', () => {
const nullDate = new DateWithFormatter(null);
const result = valueFormatter(makeParams(nullDate), baseCol);
expect(result).toBe('N/A');
});
test('valueFormatter returns empty string for the root aggregation row', () => {
const result = valueFormatter(makeParams(undefined, { level: -1 }), baseCol);
expect(result).toBe('');
});
test('valueGetter returns the main column value when colDef.isMain is set', () => {
const params = {
colDef: { isMain: true },
column: { getColId: () => 'sum__num' },
data: { 'Main sum__num': 42 },
} as unknown as Parameters<typeof valueGetter>[0];
expect(valueGetter(params, baseCol)).toBe(42);
});
test('valueGetter returns undefined for missing numeric column values', () => {
const params = {
column: { getColId: () => 'sum__num' },
data: {},
} as unknown as Parameters<typeof valueGetter>[0];
expect(valueGetter(params, { ...baseCol, isNumeric: true })).toBeUndefined();
});
test('valueGetter returns empty string for missing non-numeric column values', () => {
const params = {
column: { getColId: () => 'name' },
data: {},
} as unknown as Parameters<typeof valueGetter>[0];
expect(valueGetter(params, baseCol)).toBe('');
});
test('formatColumnValue applies the small-number formatter for values under 1 in AUTO currency mode', () => {
const column: DataColumnMeta = {
key: 'pct',
label: 'pct',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
isPercentMetric: false,
formatter: new CurrencyFormatter({
currency: { symbol: 'AUTO', symbolPosition: 'prefix' },
}),
config: {},
};
const [isHtml, formatted] = formatColumnValue(column, 0.005);
expect(isHtml).toBe(false);
expect(formatted).not.toBe('');
});
test('formatColumnValue renders null as N/A', () => {
const column: DataColumnMeta = {
...baseCol,
formatter: undefined,
};
expect(formatColumnValue(column, null)).toEqual([false, 'N/A']);
});
@@ -54,12 +54,11 @@ test('transformProps busts its memoization caches when sub-field inputs change (
const first = transformProps(testData.basic);
// `processColumns` is wrapped with a custom equality (`isEqualColumns`) that
// compares specific chartProps sub-fields by value — mutating only the
// top-level props reference is NOT enough to bust it, and neither is
// handing it a new-but-value-equal `columnFormats` reference (e.g. another
// `{}`). Here we supply a `datasource.columnFormats` with genuinely
// different content, forcing `processColumns` to recompute and return a
// new `columns` array.
// compares specific chartProps sub-fields by identity — mutating only the
// top-level props reference is NOT enough to bust it. Here we supply a fresh
// `datasource.columnFormats` reference, which `isEqualColumns` compares with
// `===`, forcing `processColumns` to recompute and return a new `columns`
// array.
//
// `processDataRecords` uses memoize-one's default referential equality on
// `(data, columns)`. We also hand it a fresh `queriesData[0].data` array, so
@@ -68,7 +67,7 @@ test('transformProps busts its memoization caches when sub-field inputs change (
...testData.basic,
datasource: {
...testData.basic.datasource,
columnFormats: { name: '.2f' },
columnFormats: {},
},
queriesData: [
{
@@ -16,16 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
convertFilterModel,
convertAgGridStateToOwnState,
} from '../src/stateConversion';
const baseAgGridState = {
columnState: [],
sortModel: [{ colId: 'name', sort: 'asc' as const, sortIndex: 0 }],
filterModel: {},
};
import { convertFilterModel } from '../src/stateConversion';
describe('convertFilterModel', () => {
test('emits a clause for a valid numeric comparison filter', () => {
@@ -80,38 +71,3 @@ describe('convertFilterModel', () => {
expect(result?.sqlClauses?.constructor).toBe('constructor = 5');
});
});
describe('convertAgGridStateToOwnState', () => {
test('suppresses client-mode state for the live query (serverPagination: false)', () => {
const result = convertAgGridStateToOwnState({
...baseAgGridState,
serverPagination: false,
});
expect(result).toEqual({});
});
test('converts client-mode state anyway when forExport is set, so a download reproduces the displayed sort/filter', () => {
const result = convertAgGridStateToOwnState(
{ ...baseAgGridState, serverPagination: false },
{ forExport: true },
);
expect(result.sortBy).toEqual([{ id: 'name', key: 'name', desc: false }]);
});
test('converts state when serverPagination is undefined, preserving legacy persisted table_state/permalinks saved before this field existed', () => {
const result = convertAgGridStateToOwnState(baseAgGridState);
expect(result.sortBy).toEqual([{ id: 'name', key: 'name', desc: false }]);
});
test('converts state for the live query when serverPagination is true', () => {
const result = convertAgGridStateToOwnState({
...baseAgGridState,
serverPagination: true,
});
expect(result.sortBy).toEqual([{ id: 'name', key: 'name', desc: false }]);
});
});
@@ -266,69 +266,6 @@ test('uses description from column even when verboseMap renames the column', ()
expect(columnMeta!.description).toBe('Original column description');
});
test('does not crash when datasource omits metrics/columns (drill-to-detail datasource)', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ col_x: 10 }],
colnames: ['col_x'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
datasource: {} as unknown as TableChartProps['datasource'],
});
expect(() => transformProps(props)).not.toThrow();
});
test('does not mistake the all_records percent-metric query for the totals query', () => {
// buildQuery.ts appends both an "all records" percent-metric denominator
// query and a totals query as independent extraQueries when percent
// metrics with percent_metric_calculation "all_records" and show_totals
// are both enabled — queriesData has 3 entries, not 2.
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['sum__num'],
percent_metrics: ['sum__num'],
percent_metric_calculation: 'all_records',
show_totals: true,
column_config: {},
table_timestamp_format: '',
},
queriesData: [
{
data: [{ name: 'a', sum__num: 1 }],
colnames: ['name', 'sum__num'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
// all_records extra query: raw percent-metric denominator, not totals.
{
data: [{ sum__num: 100 }],
colnames: ['sum__num'],
coltypes: [GenericDataType.Numeric],
},
// totals extra query: the real one.
{
data: [{ sum__num: 42 }],
colnames: ['sum__num'],
coltypes: [GenericDataType.Numeric],
},
] as unknown as TableChartProps['queriesData'],
});
const result = transformProps(props);
expect(result.totals).toEqual({ sum__num: 42 });
});
test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
// Green/Red rules are rendered via the increase/decrease path, so they must
// not reach getColorFormatters, which would treat the scheme name as a hex
@@ -379,59 +316,6 @@ test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
expect(formattedColumns).not.toContain('metric_a');
});
test('allowRearrangeColumns defaults to true when allow_rearrange_columns is unset', () => {
// Pre-existing v2 charts saved before this control existed have no
// allow_rearrange_columns key at all -- they must keep the always-on
// behavior v2 originally shipped with, not v1's false default.
const props = createMockChartProps();
const result = transformProps(props);
expect(result.allowRearrangeColumns).toBe(true);
});
test('allowRearrangeColumns is false when allow_rearrange_columns is explicitly false', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: [],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
allow_rearrange_columns: false,
} as unknown as TableChartProps['rawFormData'],
});
const result = transformProps(props);
expect(result.allowRearrangeColumns).toBe(false);
});
test('allowRenderHtml defaults to true when allow_render_html is unset', () => {
const props = createMockChartProps();
const result = transformProps(props);
expect(result.allowRenderHtml).toBe(true);
});
test('allowRenderHtml is false when allow_render_html is explicitly false', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: [],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
allow_render_html: false,
} as unknown as TableChartProps['rawFormData'],
});
const result = transformProps(props);
expect(result.allowRenderHtml).toBe(false);
});
test('retains saved percentage rules with automatic bounds when server pagination is enabled', () => {
const props = createMockChartProps({
rawFormData: {
@@ -94,39 +94,6 @@ test('applies the increase/decrease background when the column has one', () => {
expect(style.backgroundColor).toBe('#00ff00');
});
test('applies a cross-column formatter to its target column, keyed off the source column value', () => {
// Rule reads metric_a (source) and paints metric_b (target, via columnFormatting).
const crossColumnFormatter = {
column: 'metric_a',
columnFormatting: 'metric_b',
getColorFromValue: (v: number) => (v === 100 ? '#ff0000' : undefined),
objectFormatting: undefined,
toTextColor: false,
};
const targetStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_b' },
value: 999,
hasColumnColorFormatters: true,
columnColorFormatters: [crossColumnFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(targetStyle.backgroundColor).toBe('#ff0000');
const sourceStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_a' },
value: 100,
hasColumnColorFormatters: true,
columnColorFormatters: [crossColumnFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(sourceStyle.backgroundColor).toBe('');
});
test('does not apply basic formatting to the pinned summary row', () => {
const style = getCellStyle(
buildParams({
@@ -139,37 +106,3 @@ test('does not apply basic formatting to the pinned summary row', () => {
);
expect(style.backgroundColor).toBe('');
});
test('applies a legacy v1 toAllRow formatter to every cell in the row', () => {
// Migrated v1 charts carry `toAllRow: true` unchanged rather than being
// rewritten to `columnFormatting: ENTIRE_ROW`; both must color every cell.
const legacyEntireRowFormatter = {
column: 'metric_a',
toAllRow: true,
getColorFromValue: (v: number) => (v === 100 ? '#ff0000' : undefined),
objectFormatting: undefined,
toTextColor: false,
};
const otherColumnStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_b' },
value: 999,
hasColumnColorFormatters: true,
columnColorFormatters: [legacyEntireRowFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(otherColumnStyle.backgroundColor).toBe('#ff0000');
const sourceColumnStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_a' },
value: 100,
hasColumnColorFormatters: true,
columnColorFormatters: [legacyEntireRowFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(sourceColumnStyle.backgroundColor).toBe('#ff0000');
});
@@ -811,79 +811,6 @@ test('cellStyle defaults non-numeric columns to left alignment', () => {
});
});
test('cellStyle reflects an edited conditional-formatting rule (color/threshold change, same column)', () => {
// columnColorFormatters entries only carry a computed getColorFromValue
// closure -- the rule's operator/threshold/color aren't mirrored onto the
// entry itself. Memoizing on JSON.stringify(columnColorFormatters) alone
// would see the same "shape" on both renders and keep closing over the
// first render's (red) formatter. The memo must instead depend on the raw
// conditionalFormatting config, which does capture the color/threshold.
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
// getCommonColProps also depends on `columns`/`data` by reference (as it
// must, since transformProps.ts doesn't memoize them either), so those
// need to stay referentially stable across rerenders here -- otherwise a
// new array on every render would mask a broken formatter dependency by
// invalidating the memo for an unrelated reason.
const stableColumns = [numericCol];
const stableData = [{ count: 42 }];
const cellStyleParams = {
value: 42,
colDef: { field: 'count' },
rowIndex: 0,
node: {},
} as never;
const { result, rerender } = renderHook(
(props: { color: string; targetValue: number }) =>
useColDefs({
...defaultProps,
columns: stableColumns,
data: stableData,
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? props.color : undefined,
},
],
conditionalFormatting: [
{
column: 'count',
operator: '>',
targetValue: props.targetValue,
colorScheme: props.color,
} as never,
],
}),
{
wrapper: defaultThemeWrapper,
initialProps: { color: '#ff0000', targetValue: 0 },
},
);
const firstCellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(firstCellStyle(cellStyleParams)).toMatchObject({
backgroundColor: '#ff0000',
});
// Same column, edited threshold/color -- must produce a fresh colDef
// whose cellStyle uses the new formatter, not the stale red one.
rerender({ color: '#0000ff', targetValue: 10 });
const secondCellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(secondCellStyle(cellStyleParams)).toMatchObject({
backgroundColor: '#0000ff',
});
});
test('cellStyle respects explicit horizontal alignment overrides', () => {
const numericCol = makeColumn({
key: 'count',
@@ -25,7 +25,6 @@ import {
AxisType,
buildCustomFormatters,
CategoricalColorNamespace,
ComparisonType,
CurrencyFormatter,
DataRecordValue,
DTTM_ALIAS,
@@ -420,11 +419,6 @@ export default function transformProps(
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
// Series whose `label_map` entry led with a time offset, recorded before the shift
// below drops it. That leading column is the only structural marker distinguishing a
// derived comparison row from a base row whose dimension value happens to read like
// the offset, and it is gone from `labelMap` by the time the formatters run.
const derivedComparisonSeries = new Set<string>();
const labelMap: { [key: string]: string[] } = Object.entries(
label_map,
).reduce((acc, entry) => {
@@ -433,7 +427,6 @@ export default function transformProps(
Array.isArray(timeCompare) &&
timeCompare.includes(entry[1][0])
) {
derivedComparisonSeries.add(entry[0]);
entry[1].shift();
}
return { ...acc, [entry[0]]: entry[1] };
@@ -688,51 +681,6 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
// A Percentage or Ratio time comparison replaces the derived series' values with a
// dimensionless number, so that row is no longer in the source metric's units and
// must not inherit its currency/D3 format.
//
// `label_map` carries the structured identity behind a rendered series name, and
// `renameOperator` puts the offset at the front of a derived row's entry:
//
// derived '1 week ago, East' -> ['1 week ago', 'East']
// derived 'count, 1 year ago' -> ['1 year ago', 'count']
// base 'sum__num, East' -> ['sum__num', 'East']
//
// so the leading column says which it is. Matching the rendered name instead would
// misread a base series whose dimension value happens to equal the offset — a region
// literally named "1 week ago" gives 'sum__num, 1 week ago', which reads as derived.
const isDerivedComparisonSeries = (seriesKey: string) => {
// Recorded above, before the offset was shifted off the `label_map` entry.
if (derivedComparisonSeries.has(seriesKey)) {
return true;
}
const columns = labelMap?.[seriesKey];
// The shift only runs when `timeCompare` is populated; otherwise the entry still
// leads with the offset and can be read directly.
return columns?.length
? array.includes(columns[0])
: array.includes(seriesKey);
};
// Percentage yields `(s - c) / c`, which reads as a percentage. Ratio yields `s / c`,
// a plain multiplier, so it takes a unitless number format rather than a percent one.
const ratioFormatter = getNumberFormatter(NumberFormats.SMART_NUMBER);
const getComparisonFormatter = (seriesKey: string) => {
if (!isDerivedComparisonSeries(seriesKey)) {
return undefined;
}
switch (chartProps.rawFormData?.comparison_type) {
case ComparisonType.Percentage:
return percentFormatter;
case ComparisonType.Ratio:
return ratioFormatter;
default:
return undefined;
}
};
// With the "full range" time-shift option, offset series are outer-joined onto
// the main series, which inserts null rows into the main series wherever the
// comparison period has data the current period lacks. Connect nulls so the
@@ -1574,31 +1522,6 @@ export default function transformProps(
value.forecastTrend || value.forecastLower || value.forecastUpper,
);
// Resolve the value formatter per series so each metric keeps its own
// D3/currency format, matching how the series labels are formatted.
// Without the series key, `getCustomFormatter` returns undefined for
// multi-metric charts and every row falls back to `defaultFormatter`,
// rendering the y-axis/currency format for all metrics.
//
// The tooltip key is the rendered series name, so resolve it through
// `labelMap`, whose values lead with the raw metric label. Series
// renamed by a verbose_name are absent from that map, so fall back to
// the verbose-name inversion, as MixedTimeseries does. A Percentage or
// Ratio comparison row is dimensionless rather than a value in the
// metric's units, so it takes its own formatter instead of the metric's.
const getSeriesFormatter = (seriesKey: string) =>
forcePercentFormatter
? percentFormatter
: (getComparisonFormatter(seriesKey) ??
getCustomFormatter(
customFormatters,
metrics,
labelMap?.[seriesKey]?.[0] ?? inverted[seriesKey],
) ??
defaultFormatter);
// The total row aggregates every series, so it keeps the chart-level
// formatter rather than any single metric's format.
const formatter = forcePercentFormatter
? percentFormatter
: (getCustomFormatter(customFormatters, metrics) ?? defaultFormatter);
@@ -1629,7 +1552,7 @@ export default function transformProps(
const row = formatForecastTooltipSeries({
...value,
seriesName: key,
formatter: getSeriesFormatter(key),
formatter,
marker,
truncation: tooltipTruncation,
});
@@ -3576,582 +3576,3 @@ test('boundary label alignment is dropped when the orientation moves the time ax
expect(horizontal.axisLabel.showMinLabel).toBe(true);
expect(horizontal.axisLabel.showMaxLabel).toBe(true);
});
test('tooltip formats each series with its own metric format instead of the default formatter', () => {
// Two saved metrics with different formats: `pct_change` carries a percentage
// D3 format, `count` carries a currency format. The series labels already
// honor each metric's format; the tooltip must do the same.
const chartProps = createTestChartProps({
formData: {
metrics: ['count', 'pct_change'],
richTooltip: true,
},
queriesData: [
createTestQueryData(
[{ count: 1000, pct_change: 0.1234, __timestamp: BASE_TIMESTAMP }],
{ label_map: { count: ['count'], pct_change: ['pct_change'] } },
),
],
datasource: {
verboseMap: {},
columnFormats: { pct_change: '.2%' },
currencyFormats: { count: { symbol: 'USD', symbolPosition: 'prefix' } },
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{ seriesId: 'count', seriesName: 'count', value: [BASE_TIMESTAMP, 1000] },
{
seriesId: 'pct_change',
seriesName: 'pct_change',
value: [BASE_TIMESTAMP, 0.1234],
},
]);
expect(result).toContain('12.34%');
expect(result).toContain('$');
});
test('tooltip resolves per-metric formats for series renamed by verbose_name', () => {
// With a verbose_name configured, the rendered series name (and so the
// tooltip key) is the verbose label, while `label_map` stays keyed by the
// raw metric label. The formatter lookup has to bridge that gap.
const chartProps = createTestChartProps({
formData: {
metrics: ['count', 'pct_change'],
richTooltip: true,
},
queriesData: [
createTestQueryData(
[{ count: 1000, pct_change: 0.1234, __timestamp: BASE_TIMESTAMP }],
{ label_map: { count: ['count'], pct_change: ['pct_change'] } },
),
],
datasource: {
verboseMap: { count: 'Total Count', pct_change: 'Percent Change' },
columnFormats: { pct_change: '.2%' },
currencyFormats: { count: { symbol: 'USD', symbolPosition: 'prefix' } },
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'Total Count',
seriesName: 'Total Count',
value: [BASE_TIMESTAMP, 1000],
},
{
seriesId: 'Percent Change',
seriesName: 'Percent Change',
value: [BASE_TIMESTAMP, 0.1234],
},
]);
expect(result).toContain('12.34%');
expect(result).toContain('$');
});
test('tooltip keeps per-metric formats on time-comparison (time-shifted) series', () => {
// A time-shifted series renders under a name carrying the offset, and its
// `label_map` entry leads with that offset rather than the metric. The
// formatter lookup has to land on the underlying metric so the shifted row is
// formatted like the series it is compared against.
const chartProps = createTestChartProps({
formData: {
metrics: ['count', 'pct_change'],
richTooltip: true,
timeCompare: ['1 year ago'],
},
queriesData: [
createTestQueryData(
[
{
count: 1000,
pct_change: 0.1234,
'count, 1 year ago': 900,
__timestamp: BASE_TIMESTAMP,
},
],
{
label_map: {
count: ['count'],
pct_change: ['pct_change'],
'count, 1 year ago': ['1 year ago', 'count'],
},
},
),
],
datasource: {
verboseMap: {},
columnFormats: { pct_change: '.2%' },
currencyFormats: { count: { symbol: 'USD', symbolPosition: 'prefix' } },
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{ seriesId: 'count', seriesName: 'count', value: [BASE_TIMESTAMP, 1000] },
{
seriesId: 'count, 1 year ago',
seriesName: 'count, 1 year ago',
value: [BASE_TIMESTAMP, 900],
},
]);
// The base series and its time-shifted counterpart keep the currency format.
expect(result).toContain('$ 1k');
expect(result).toContain('$ 900');
});
test('tooltip does not apply a metric currency format to a Percentage time comparison', () => {
// Reported on #33757: a Time Comparison set to Percentage change on a
// currency metric kept rendering the derived row in dollars. That row holds a
// ratio rather than a value in the metric's units, so it must not inherit the
// metric's saved CurrencyFormatter.
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
richTooltip: true,
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Percentage,
},
queriesData: [
createTestQueryData(
[{ sum__num: 100, '1 week ago': 0.25, __timestamp: BASE_TIMESTAMP }],
{ label_map: { sum__num: ['sum__num'], '1 week ago': ['1 week ago'] } },
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num',
seriesName: 'sum__num',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago',
seriesName: '1 week ago',
value: [BASE_TIMESTAMP, 0.25],
},
]);
// The source metric keeps its currency; the percentage-change row does not.
expect(result).toContain('$ 100');
expect(result).toContain('25.00%');
expect(result).not.toContain('$ 0.25');
});
test('tooltip does not apply a metric currency format to a grouped Percentage time comparison', () => {
// A groupby appends the dimension values to the derived series name
// ("1 week ago, East"), so matching the dimensionless names alone left the
// grouped rows resolving back to the source metric's CurrencyFormatter.
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['region'],
richTooltip: true,
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Percentage,
},
queriesData: [
createTestQueryData(
[
{
'sum__num, East': 100,
'1 week ago, East': 0.25,
__timestamp: BASE_TIMESTAMP,
},
],
{
label_map: {
'sum__num, East': ['sum__num', 'East'],
'1 week ago, East': ['1 week ago', 'East'],
},
},
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num, East',
seriesName: 'sum__num, East',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago, East',
seriesName: '1 week ago, East',
value: [BASE_TIMESTAMP, 0.25],
},
]);
expect(result).toContain('$ 100');
expect(result).toContain('25.00%');
expect(result).not.toContain('$ 0.25');
});
test('tooltip does not apply a metric currency format to a Ratio time comparison', () => {
// A Ratio comparison is `source / compare`, a plain multiplier, so the derived row is
// no more in the metric's currency than a Percentage one is — but it is not a
// percentage either, so it takes a unitless number format rather than the percent one.
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
richTooltip: true,
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Ratio,
},
queriesData: [
createTestQueryData(
[{ sum__num: 100, '1 week ago': 1.25, __timestamp: BASE_TIMESTAMP }],
{ label_map: { sum__num: ['sum__num'], '1 week ago': ['1 week ago'] } },
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num',
seriesName: 'sum__num',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago',
seriesName: '1 week ago',
value: [BASE_TIMESTAMP, 1.25],
},
]);
// The source metric keeps its currency; the ratio row renders as a plain number.
expect(result).toContain('$ 100');
expect(result).toContain('1.25');
expect(result).not.toContain('$ 1.25');
});
test('tooltip does not apply a metric currency format to a grouped Ratio time comparison', () => {
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['region'],
richTooltip: true,
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Ratio,
},
queriesData: [
createTestQueryData(
[
{
'sum__num, East': 100,
'1 week ago, East': 1.25,
__timestamp: BASE_TIMESTAMP,
},
],
{
label_map: {
'sum__num, East': ['sum__num', 'East'],
'1 week ago, East': ['1 week ago', 'East'],
},
},
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num, East',
seriesName: 'sum__num, East',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago, East',
seriesName: '1 week ago, East',
value: [BASE_TIMESTAMP, 1.25],
},
]);
expect(result).toContain('$ 100');
expect(result).toContain('1.25');
expect(result).not.toContain('$ 1.25');
});
test('tooltip formats derived rows when timeCompare normalization strips the offset', () => {
// With `timeCompare` populated, `labelMap` has its leading offset shifted off before
// the formatters run, so the derived identity has to be captured during that pass —
// reading `labelMap[key][0]` afterwards sees the dimension value instead.
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['region'],
richTooltip: true,
timeCompare: ['1 week ago'],
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Percentage,
},
queriesData: [
createTestQueryData(
[
{
'sum__num, East': 100,
'1 week ago, East': 0.25,
__timestamp: BASE_TIMESTAMP,
},
],
{
label_map: {
'sum__num, East': ['sum__num', 'East'],
'1 week ago, East': ['1 week ago', 'East'],
},
},
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num, East',
seriesName: 'sum__num, East',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago, East',
seriesName: '1 week ago, East',
value: [BASE_TIMESTAMP, 0.25],
},
]);
expect(result).toContain('$ 100');
expect(result).toContain('25.00%');
expect(result).not.toContain('$ 0.25');
});
test('tooltip gives a Ratio row a unitless format when timeCompare is set', () => {
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['region'],
richTooltip: true,
timeCompare: ['1 week ago'],
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Ratio,
},
queriesData: [
createTestQueryData(
[
{
'sum__num, East': 100,
'1 week ago, East': 1.25,
__timestamp: BASE_TIMESTAMP,
},
],
{
label_map: {
'sum__num, East': ['sum__num', 'East'],
'1 week ago, East': ['1 week ago', 'East'],
},
},
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num, East',
seriesName: 'sum__num, East',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago, East',
seriesName: '1 week ago, East',
value: [BASE_TIMESTAMP, 1.25],
},
]);
expect(result).toContain('$ 100');
expect(result).toContain('1.25');
expect(result).not.toContain('$ 1.25');
});
test('tooltip keeps the metric format when a dimension value equals the offset', () => {
// A groupby value can legitimately read like the configured offset, giving a *base*
// series called `sum__num, 1 week ago`. Matching the rendered name would classify it
// as derived and strip its currency; `label_map` leads with the metric, not the
// offset, so it stays a base row.
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
groupby: ['region'],
richTooltip: true,
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Percentage,
},
queriesData: [
createTestQueryData(
[
{
'sum__num, 1 week ago': 100,
'1 week ago, 1 week ago': 0.25,
__timestamp: BASE_TIMESTAMP,
},
],
{
label_map: {
// The region is named "1 week ago"; the metric still leads the base entry.
'sum__num, 1 week ago': ['sum__num', '1 week ago'],
// Its derived counterpart leads with the offset.
'1 week ago, 1 week ago': ['1 week ago', '1 week ago'],
},
},
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num, 1 week ago',
seriesName: 'sum__num, 1 week ago',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago, 1 week ago',
seriesName: '1 week ago, 1 week ago',
value: [BASE_TIMESTAMP, 0.25],
},
]);
// The base row keeps its currency even though its name ends in the offset, and the
// genuinely derived row is still formatted as a percentage.
expect(result).toContain('$ 100');
expect(result).toContain('25.00%');
});
test('tooltip keeps the metric format on a Difference time comparison', () => {
// Difference is `source - compare`, which stays in the metric's units, so unlike
// Percentage and Ratio it must keep the currency format.
const chartProps = createTestChartProps({
formData: {
metric: 'sum__num',
metrics: ['sum__num'],
richTooltip: true,
time_compare: ['1 week ago'],
comparison_type: ComparisonType.Difference,
},
queriesData: [
createTestQueryData(
[{ sum__num: 100, '1 week ago': 25, __timestamp: BASE_TIMESTAMP }],
{ label_map: { sum__num: ['sum__num'], '1 week ago': ['1 week ago'] } },
),
],
datasource: {
verboseMap: {},
columnFormats: {},
currencyFormats: {
sum__num: { symbol: 'USD', symbolPosition: 'prefix' },
},
},
});
const { echartOptions } = transformProps(chartProps);
const { tooltip } = echartOptions as unknown as TooltipFormatterOptions;
const result = tooltip.formatter([
{
seriesId: 'sum__num',
seriesName: 'sum__num',
value: [BASE_TIMESTAMP, 100],
},
{
seriesId: '1 week ago',
seriesName: '1 week ago',
value: [BASE_TIMESTAMP, 25],
},
]);
expect(result).toContain('$ 100');
expect(result).toContain('$ 25');
});
@@ -16,8 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
/** @jsxImportSource @emotion/react */
import {
Children,
cloneElement,
@@ -288,28 +286,9 @@ function StickyWrap({
</colgroup>
);
// Below, `width: maxWidth` is applied unconditionally (never reduced by
// subtracting a separately-measured scrollbar width, unlike this file's
// previous `maxWidth - scrollBarSize`). That's the load-bearing part of
// this fix: the shared colgroup (computed from the sizer below, whose
// own clientWidth can only ever be <= maxWidth) can never need more
// width than that, so a header/footer wrapper that's never narrowed
// below maxWidth can never clip it, regardless of whether any
// JS-measured scrollbar size agrees with what the sizer/body actually
// reserve in a given browser.
//
// `scrollbarGutter`/`scrollBarStyles` below are a separate, secondary
// measure -- matching an actual clip boundary is not what they're for
// (an `overflow: hidden` box's clip boundary sits at its real
// border-box edge regardless of `scrollbar-gutter`, which only affects
// what `clientWidth` reports). They keep header/footer's reported
// `clientWidth` consistent with body's so that, when both a vertical
// and a horizontal scrollbar are present, the horizontal `scrollLeft`
// synced from body (see `onScroll` below) reveals the same slice of the
// row in header/footer as is actually visible in body.
const headerFooterGutter: CSSProperties = {
scrollbarGutter: hasVerticalScroll ? 'stable' : undefined,
};
const headerContainerWidth = hasVerticalScroll
? maxWidth - scrollBarSize
: maxWidth;
headerTable = (
<div
@@ -317,11 +296,9 @@ function StickyWrap({
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
width: maxWidth,
width: headerContainerWidth,
boxSizing: 'border-box',
...headerFooterGutter,
}}
css={scrollBarStyles}
role="presentation"
>
{cloneElement(
@@ -340,11 +317,9 @@ function StickyWrap({
ref={scrollFooterRef}
style={{
overflow: 'hidden',
width: maxWidth,
width: headerContainerWidth,
boxSizing: 'border-box',
...headerFooterGutter,
}}
css={scrollBarStyles}
role="presentation"
>
{cloneElement(
@@ -1,205 +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 { useCallback } from 'react';
import { useTable, Column } from 'react-table';
import { render } from '@superset-ui/core/spec';
import useSticky from '../../../src/DataTable/hooks/useSticky';
// A value distinguishable from any real scrollbar width, so the width
// assertions below can detect whether header/footer's wrapper width was
// computed by subtracting this JS-measured probe from `maxWidth` (the old,
// removed `maxWidth - scrollBarSize` behavior) rather than always being the
// unconditional `maxWidth` the fix uses. If that subtraction is ever
// reintroduced, header/footer's `style.width` would read
// `${MAX_WIDTH - MOCKED_SCROLLBAR_PROBE_SIZE}px`, an unmistakably wrong
// value given how large this mock is.
const MOCKED_SCROLLBAR_PROBE_SIZE = 42;
jest.mock('../../../src/DataTable/utils/getScrollBarSize', () => ({
__esModule: true,
CUSTOM_SCROLLBAR_SIZE: 8,
default: () => 0,
getCustomScrollBarSize: () => MOCKED_SCROLLBAR_PROBE_SIZE,
}));
const MAX_WIDTH = 300;
const MAX_HEIGHT = 120; // small enough that the mocked content forces a vertical scroll
const TOTAL_HEADER_HEIGHT = 30;
const TOTAL_FOOTER_HEIGHT = 30;
// Larger than `MAX_HEIGHT - TOTAL_HEADER_HEIGHT - TOTAL_FOOTER_HEIGHT`, so the
// sticky layout effect computes `hasVerticalScroll: true`.
const FULL_TABLE_HEIGHT = 400;
function mockMeasurements() {
jest
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
.mockImplementation(function mockClientHeight(this: HTMLElement) {
if (this.tagName === 'THEAD') return TOTAL_HEADER_HEIGHT;
if (this.tagName === 'TFOOT') return TOTAL_FOOTER_HEIGHT;
if (this.tagName === 'TABLE') return FULL_TABLE_HEIGHT;
return 0;
});
jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockRect(this: HTMLElement) {
const width = this.tagName === 'TH' ? 60 : 0;
return {
width,
height: 0,
top: 0,
left: 0,
right: width,
bottom: 0,
x: 0,
y: 0,
toJSON: () => {},
} as DOMRect;
});
}
type Row = { category: string; amount: string };
const columns: Column<Row>[] = [
{ Header: 'Category', accessor: 'category' },
{ Header: 'SUM(amount)', accessor: 'amount' },
];
const data: Row[] = Array.from({ length: 8 }, (_, i) => ({
category: `Category ${i}`,
amount: `${1234567.891234 + i}`,
}));
function StickyTableHarness() {
const getTableSize = useCallback(
() => ({ width: MAX_WIDTH, height: MAX_HEIGHT }),
[],
);
const { getTableProps, headerGroups, rows, prepareRow, wrapStickyTable } =
useTable<Row>(
{
columns,
data,
getTableSize,
},
useSticky,
);
const renderTable = () => (
<table {...getTableProps()}>
<thead>
{headerGroups.map(hg => (
<tr {...hg.getHeaderGroupProps()} key={hg.id}>
{hg.headers.map(col => (
<th {...col.getHeaderProps()} key={col.id}>
{col.render('Header')}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map(row => {
prepareRow(row);
return (
<tr {...row.getRowProps()} key={row.id}>
{row.cells.map(cell => (
<td {...cell.getCellProps()} key={cell.column.id}>
{cell.render('Cell')}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr key="footer">
<th>Summary</th>
<td>
<strong>14814904.694808</strong>
</td>
</tr>
</tfoot>
</table>
);
return <div data-test="sticky-root">{wrapStickyTable(renderTable)}</div>;
}
test('sticky header/footer width matches the body, independent of the scrollbar-size probe', () => {
mockMeasurements();
const { container } = render(<StickyTableHarness />);
const root = container.querySelector('[data-test="sticky-root"] > div');
expect(root).not.toBeNull();
const [headerDiv, bodyDiv, footerDiv] = Array.from(
root!.children,
) as HTMLDivElement[];
expect(bodyDiv.style.width).toBe(`${MAX_WIDTH}px`);
// This is the load-bearing assertion for the reported bug. Before the fix
// these read `${MAX_WIDTH - MOCKED_SCROLLBAR_PROBE_SIZE}px` (258px) --
// genuinely narrower than the body, from a real CSS `width` subtraction
// (`maxWidth - scrollBarSize`), not just a smaller reported `clientWidth`.
// A wrapper that's actually narrower than the shared, fixed-layout
// colgroup it has to display gets genuinely clipped by its own
// `overflow: hidden` (verified with real hit-testing in a real browser --
// this is not true of the `scrollbarGutter` assertions below). The fix
// makes header/footer always exactly `maxWidth`, which the colgroup
// (bounded by the sizer's `clientWidth`, itself bounded by `maxWidth`)
// can never exceed.
expect(headerDiv.style.width).toBe(`${MAX_WIDTH}px`);
expect(footerDiv.style.width).toBe(`${MAX_WIDTH}px`);
// Secondary, not itself load-bearing for preventing clipping: real
// hit-testing shows `scrollbar-gutter` on an `overflow: hidden` box
// changes what `clientWidth` reports without moving where it actually
// clips, so this doesn't guard against the reported bug by itself. It's
// asserted because header/footer's reported `clientWidth` still needs to
// match body's `clientWidth` for their programmatically
// synced `scrollLeft` (see `onScroll` in `useSticky.tsx`) to reveal the
// same slice of the row body actually shows, when a horizontal scrollbar
// is present alongside a vertical one.
expect(headerDiv.style.scrollbarGutter).toBe(bodyDiv.style.scrollbarGutter);
expect(footerDiv.style.scrollbarGutter).toBe(bodyDiv.style.scrollbarGutter);
expect(bodyDiv.style.scrollbarGutter).toBe('stable');
// Pin the `css={scrollBarStyles}` addition to header/footer directly (part
// of the same secondary consistency measure as the `scrollbarGutter`
// assertions above, not the clipping fix). This component carries
// `/** @jsxImportSource @emotion/react */`, which makes
// Babel route its `css` prop through Emotion's jsx runtime instead of
// passing `css` straight through as an inert DOM attribute (the default in
// this repo's Jest/Babel setup, which -- unlike the webpack/SWC build --
// doesn't set `importSource: '@emotion/react'` globally). With the pragma
// in place, an applied `css` prop is observable as a real, non-empty
// className, so this assertion actually fails without the fix instead of
// passing regardless of whether `scrollBarStyles` is wired up.
//
// Before `css={scrollBarStyles}` was added to header/footer, they had no
// emotion-generated class at all (`className === ''`) while the body kept
// its own -- so this fails pre-fix and passes post-fix.
expect(headerDiv.className).not.toBe('');
expect(headerDiv.className).toBe(bodyDiv.className);
expect(footerDiv.className).toBe(bodyDiv.className);
jest.restoreAllMocks();
});
@@ -45,8 +45,8 @@ test('getCustomScrollBarSize measures the probe using the shared custom scrollba
});
test('CUSTOM_SCROLLBAR_SIZE matches the custom scrollbar width rendered in the sticky table', () => {
// useSticky.tsx's scrollBarStyles sets `::-webkit-scrollbar { width: ... }`
// from this constant, so it must stay in sync with it or the real
// scrollbar body/sizer render won't match what this constant claims.
// useSticky.tsx's scrollBarStyles must stay in sync with this constant so
// the sticky header's shrink amount always matches the body's real
// scrollbar width.
expect(CUSTOM_SCROLLBAR_SIZE).toBe(8);
});
@@ -35,14 +35,12 @@ jest.mock('src/utils/cachedSupersetGet');
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
// config, so we can assert ChartContextMenu wires it into the modal.
jest.mock('../DrillBy/DrillBySubmenu', () => ({
DrillBySubmenu: ({ onDrillBy, onCloseMenu, dataset }: any) => (
DrillBySubmenu: ({ onDrillBy, dataset }: any) => (
<>
<button
type="button"
data-test="fake-drill-by-submenu"
onClick={() => {
// Mirrors DrillBySubmenu's real handleSelection, which calls
// onDrillBy and onCloseMenu together once a column is picked.
onClick={() =>
onDrillBy(
{ column_name: 'city', groupby: true },
{ id: 1, columns: [], metrics: [] },
@@ -50,9 +48,8 @@ jest.mock('../DrillBy/DrillBySubmenu', () => ({
filters: [{ col: 'selected_scope' }],
groupbyFieldName: 'groupby',
},
);
onCloseMenu?.();
}}
)
}
>
Fake Drill By
</button>
@@ -224,39 +221,6 @@ test('drill by modal uses the scope selected in the submenu over the raw context
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
});
test('context menu can be reopened after Drill By closes it via onCloseMenu', async () => {
// Ant Design's Dropdown keeps its overlay mounted and toggles an
// `ant-dropdown-hidden` class rather than unmounting, so open/closed is
// asserted on that class instead of the overlay's presence in the DOM.
const isMenuOpen = () =>
!screen
.getByTestId('chart-context-menu')
.closest('.ant-dropdown')
?.classList.contains('ant-dropdown-hidden');
setup();
const openButton = screen.getByTestId('open-context-menu');
userEvent.click(openButton);
await waitFor(() => {
expect(isMenuOpen()).toBe(true);
});
const submenuButton = await screen.findByTestId('fake-drill-by-submenu');
userEvent.click(submenuButton);
await waitFor(() => {
expect(isMenuOpen()).toBe(false);
});
userEvent.click(openButton);
await waitFor(() => {
expect(isMenuOpen()).toBe(true);
});
});
test('drill by only offers dimension columns', async () => {
// drill_info returns every column so the results grid can label non-dimension
// ones; narrowing to dimensions is this component's job, not the API's.
@@ -24,7 +24,6 @@ import {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import ReactDOM from 'react-dom';
@@ -111,11 +110,6 @@ const ChartContextMenu = (
);
const [visible, setVisible] = useState(false);
// `visible` state updates aren't synchronous, so a second open() call that
// runs before React re-renders would still see the stale `false` closure.
// This ref is updated synchronously (both here and in onOpenChange) so the
// guard below always reflects the latest known open state.
const visibleRef = useRef(false);
const isDisplayed = (item: ContextMenuItem) =>
displayedItems === ContextMenuItem.All ||
@@ -168,7 +162,6 @@ const ChartContextMenu = (
const [showDrillByModal, setShowDrillByModal] = useState(false);
const closeContextMenu = useCallback(() => {
visibleRef.current = false;
setVisible(false);
onClose();
}, [onClose]);
@@ -407,26 +400,11 @@ const ChartContextMenu = (
filters,
});
// Some chart libraries (e.g. AG Grid) can dispatch a single logical
// right-click as two contextmenu events in quick succession, calling
// `open()` twice. Since Ant Design's Dropdown treats a click on an
// already-open trigger as a toggle-to-close, re-clicking the hidden
// span here on the second call would immediately close the menu we
// just opened. Only click it when the menu isn't already visible; the
// position/filters update above still applies on every call.
//
// visibleRef (not the `visible` state) drives this guard: the state
// update from the first call's click hasn't been committed by the time
// the second call runs, so a state-based check would still read the
// stale `false` from this render's closure and click twice anyway.
if (!visibleRef.current) {
visibleRef.current = true;
// Ant Design's Dropdown does not offer an imperative API and we
// can't attach event triggers to charts' SVG elements, so we use a
// hidden span that gets clicked on when receiving click events from
// the charts.
document.getElementById(`hidden-span-${id}`)?.click();
}
// Since Ant Design's Dropdown does not offer an imperative API
// and we can't attach event triggers to charts SVG elements, we
// use a hidden span that gets clicked on when receiving click events
// from the charts.
document.getElementById(`hidden-span-${id}`)?.click();
},
[id, itemsCount],
);
@@ -448,7 +426,6 @@ const ChartContextMenu = (
? menuItems
: [{ key: 'no-actions', label: t('No actions'), disabled: true }],
onClick: () => {
visibleRef.current = false;
setVisible(false);
onClose();
},
@@ -458,7 +435,6 @@ const ChartContextMenu = (
)}
trigger={['click']}
onOpenChange={value => {
visibleRef.current = value;
setVisible(value);
if (!value) {
onClose();
@@ -44,18 +44,16 @@ const setup = ({
displayedItems = ContextMenuItem.All,
additionalConfig = {},
roles = undefined,
formData = { datasource: '1__table', viz_type: VizType.Pie },
}: {
onSelection?: () => void;
displayedItems?: ContextMenuItem | ContextMenuItem[];
additionalConfig?: Record<string, any>;
roles?: Record<string, string[][]>;
formData?: Record<string, any>;
} = {}) => {
const { result } = renderHook(() =>
useContextMenu(
sliceId,
formData as { datasource: string; viz_type: string },
{ datasource: '1__table', viz_type: VizType.Pie },
onSelection,
displayedItems,
additionalConfig,
@@ -367,24 +365,3 @@ test('Dataset drill info API call is not made when user lacks drill permissions'
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Dataset drill info API call is not made when formData.datasource is not yet hydrated', async () => {
// Regression test: right after a client-side navigation back to a
// dashboard from Explore, the chart's formData can transiently be missing
// `datasource` before the dashboard rehydrates. Firing a request for
// dataset "NaN" (Number(undefined)) must not happen - see
// useDatasetDrillInfo's Number.isNaN guard.
const result = setup({ formData: { viz_type: VizType.Pie } });
act(() => {
result.current.onContextMenu(0, 0, {});
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockCachedSupersetGet).not.toHaveBeenCalledWith(
expect.objectContaining({
endpoint: expect.stringContaining('/api/v1/dataset/NaN/drill_info/'),
}),
);
});
@@ -603,9 +603,7 @@ const Chart = (props: ChartProps) => {
const exportOwnState = state
? {
...baseOwnState,
...convertChartStateToOwnState(sliceVizType, state, {
forExport: true,
}),
...convertChartStateToOwnState(sliceVizType, state),
}
: baseOwnState;
@@ -261,66 +261,6 @@ test('should return equal results when only clientView changes', () => {
});
});
test('should strip chartState from ownState to prevent spurious re-queries', () => {
// Chart.tsx (dashboard) folds the AG Grid chartState (persisted separately
// in dashboardState.chartStates) into ownState so the chart plugin can read
// it on mount. That fold-in is not a query-affecting change and must not
// trigger a re-query when it churns, e.g. right after a user interaction.
const mockDataMaskWithChartState: DataMaskStateWithId = {
chart1: {
id: 'chart1',
ownState: {
pageSize: 10,
currentPage: 0,
chartState: {
columnState: [{ colId: 'name', width: 200 }],
filterModel: {},
},
},
},
};
const result = getRelevantDataMask(mockDataMaskWithChartState, 'ownState');
expect(result).toEqual({
chart1: {
pageSize: 10,
currentPage: 0,
},
});
});
test('should return equal results when only chartState changes', () => {
// chartState is refreshed on every AG Grid column/sort/filter change; if it
// isn't stripped, its churn is read as a chart-state change by
// getAffectedOwnDataCharts and re-triggers the chart's query.
const dataMaskBefore: DataMaskStateWithId = {
chart1: {
id: 'chart1',
ownState: {
pageSize: 10,
chartState: { columnState: [{ colId: 'name', width: 200 }] },
},
},
};
const dataMaskAfter: DataMaskStateWithId = {
chart1: {
id: 'chart1',
ownState: {
pageSize: 10,
chartState: { columnState: [{ colId: 'name', width: 350 }] },
},
},
};
const resultBefore = getRelevantDataMask(dataMaskBefore, 'ownState');
const resultAfter = getRelevantDataMask(dataMaskAfter, 'ownState');
expect(resultBefore).toEqual(resultAfter);
expect(resultBefore).toEqual({ chart1: { pageSize: 10 } });
});
test('should return extraFormData unchanged (clientView stripping only applies to ownState)', () => {
// Verify extraFormData is passed through without modification
const mockDataMask: DataMaskStateWithId = {
@@ -34,19 +34,16 @@ export const getRelevantDataMask = (
.filter(item => item[prop])
.map(item => {
const value = item[prop];
// TableChart writes clientView to ownState on every filtered-row change for export,
// and Chart.tsx (dashboard) folds the AG Grid chartState (column/sort/filter state,
// persisted separately in dashboardState.chartStates) into the same ownState object
// for the chart plugin to read on mount. Neither is query-affecting, so both must be
// stripped here or their churn is read as a chart-state change and triggers re-queries.
// Only clone when one of them exists to avoid unnecessary allocations.
// TableChart writes clientView to ownState on every filtered-row change for export
// but clientView changes should NOT trigger chart re-queries
// Only clone when clientView exists to avoid unnecessary allocations
if (
prop === 'ownState' &&
value &&
typeof value === 'object' &&
('clientView' in value || 'chartState' in value)
'clientView' in value
) {
return [item.id, omit(value, ['clientView', 'chartState'])];
return [item.id, omit(value, ['clientView'])];
}
return [item.id, value];
}),
@@ -19,7 +19,6 @@
import type {
ChartStateConverter,
ChartStateConverterOptions,
BackendOwnState,
JsonObject,
} from '@superset-ui/core';
@@ -45,18 +44,14 @@ class ChartStateConverterRegistry {
* Convert chart-specific state to backend-compatible ownState format.
* Returns an empty object if no converter is registered for the viz type.
*/
convert(
vizType: string,
chartState: JsonObject,
options?: ChartStateConverterOptions,
): Partial<BackendOwnState> {
convert(vizType: string, chartState: JsonObject): Partial<BackendOwnState> {
const converter = this.converters.get(vizType);
if (!converter) {
return {};
}
try {
return converter(chartState, options);
return converter(chartState);
} catch (error) {
// Log error but don't throw - graceful degradation
console.warn(
@@ -120,9 +115,8 @@ export function registerChartStateConverter(
export function convertChartStateToOwnState(
vizType: string,
chartState: JsonObject,
options?: ChartStateConverterOptions,
): Partial<BackendOwnState> {
return registry.convert(vizType, chartState, options);
return registry.convert(vizType, chartState);
}
/**
@@ -874,12 +874,8 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
const previousOwnState = usePrevious(props.ownState);
useEffect(() => {
// clientView (export snapshot) and chartState (AG Grid column/sort/filter
// state read on mount) are folded into ownState but aren't query-affecting;
// excluding them here is what the dashboard-side getRelevantDataMask does
// for the same reason - see src/dashboard/util/activeAllDashboardFilters.ts.
const strip = (s: JsonObject | undefined) =>
omit(s && typeof s === 'object' ? s : {}, ['clientView', 'chartState']);
s && typeof s === 'object' ? omit(s, ['clientView']) : s;
if (!isEqual(strip(previousOwnState), strip(props.ownState))) {
onQuery();
reRenderChart();
@@ -512,15 +512,13 @@ export const useExploreAdditionalActionsMenu = (
permalinkChartState,
]);
// Minimal client-side CSV builder used for "Current View" when pagination is disabled.
// `rows` may legitimately be empty (a filter that matches nothing) -- only
// `columns` is required to produce a valid header-only export.
// Minimal client-side CSV builder used for "Current View" when pagination is disabled
const downloadClientCSV = (
rows: ClientViewRow[],
columns: ClientViewColumn[],
filename: string,
) => {
if (!columns?.length) return;
if (!rows?.length || !columns?.length) return;
const header = columns
.map(c => escapeCsvValue(c.label ?? c.key ?? ''))
.join(',');
@@ -538,15 +536,13 @@ export const useExploreAdditionalActionsMenu = (
URL.revokeObjectURL(link.href);
};
// Robust client-side JSON for "Current View". `rows` may legitimately be
// empty (a filter that matches nothing) -- only `columns` is required to
// produce a valid header-only export.
// Robust client-side JSON for "Current View"
const downloadClientJSON = (
rows: ClientViewRow[],
columns: ClientViewColumn[],
filename: string,
) => {
if (!columns?.length) return;
if (!rows?.length || !columns?.length) return;
const norm = (v: unknown): unknown => {
if (v instanceof Date) return v.toISOString();
@@ -591,15 +587,13 @@ export const useExploreAdditionalActionsMenu = (
URL.revokeObjectURL(link.href);
};
// Client-side XLSX for "Current View" (uses 'xlsx' already in deps).
// `rows` may legitimately be empty (a filter that matches nothing) -- only
// `columns` is required to produce a valid header-only export.
// Client-side XLSX for "Current View" (uses 'xlsx' already in deps)
const downloadClientXLSX = async (
rows: ClientViewRow[],
columns: ClientViewColumn[],
filename: string,
) => {
if (!columns?.length) return;
if (!rows?.length || !columns?.length) return;
try {
const XLSX = (await import(/* webpackChunkName: "xlsx" */ 'xlsx'))
.default;
@@ -624,20 +618,12 @@ export const useExploreAdditionalActionsMenu = (
return o;
});
// json_to_sheet infers headers from the first data object's keys, so
// with zero rows it would emit a completely blank sheet -- pass the
// column labels explicitly so an empty filtered view still exports a
// header-only sheet instead of nothing.
const headers = columns.map(c => c.label ?? c.key);
const ws = XLSX.utils.json_to_sheet(data, {
header: headers,
skipHeader: false,
});
const ws = XLSX.utils.json_to_sheet(data, { skipHeader: false });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Current View');
// Autosize columns (roughly) by header length
const colWidths = headers.map(h => ({
const colWidths = Object.keys(data[0] || {}).map(h => ({
wch: Math.max(10, String(h).length + 2),
}));
ws['!cols'] = colWidths;
@@ -870,10 +856,10 @@ export const useExploreAdditionalActionsMenu = (
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
if (
!latestQueryFormData?.server_pagination &&
ownState?.clientView &&
ownState.clientView.columns?.length
ownState?.clientView?.rows?.length &&
ownState?.clientView?.columns?.length
) {
const { rows = [], columns = [] } = ownState.clientView;
const { rows, columns } = ownState.clientView;
downloadClientCSV(
rows,
columns,
@@ -908,10 +894,10 @@ export const useExploreAdditionalActionsMenu = (
onClick: () => {
if (
!latestQueryFormData?.server_pagination &&
ownState?.clientView &&
ownState.clientView.columns?.length
ownState?.clientView?.rows?.length &&
ownState?.clientView?.columns?.length
) {
const { rows = [], columns = [] } = ownState.clientView;
const { rows, columns } = ownState.clientView;
downloadClientJSON(
rows,
columns,
@@ -970,11 +956,11 @@ export const useExploreAdditionalActionsMenu = (
onClick: async () => {
if (
!latestQueryFormData?.server_pagination &&
ownState?.clientView &&
ownState.clientView.columns?.length
ownState?.clientView?.rows?.length &&
ownState?.clientView?.columns?.length
) {
// Client-side filtered view → XLSX
const { rows = [], columns = [] } = ownState.clientView;
const { rows, columns } = ownState.clientView;
await downloadClientXLSX(
rows,
columns,
@@ -278,39 +278,6 @@ test('shows 413 error toast when Export Current View CSV server path fails with
});
});
test('Export Current View CSV takes the client path for a filter that matches zero rows, rather than falling back to an unfiltered backend export', async () => {
global.URL.revokeObjectURL = jest.fn();
render(
<TestComponent
{...defaultProps}
latestQueryFormData={{
datasource: '1__table',
viz_type: 'table',
}}
ownState={{
clientView: {
rows: [],
columns: [{ key: 'name', label: 'Name' }],
},
}}
/>,
{ useRedux: true },
);
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
userEvent.click(await screen.findByText('Export to .CSV'));
// The client path builds and clicks a download link directly rather than
// calling exportChart; asserting exportChart was never called is what
// distinguishes it from the backend fallback path (which doesn't know
// about the empty client-side filter and would export every row).
await waitFor(() => {
expect(mockExportChart).not.toHaveBeenCalled();
});
});
const CHART_SELECTOR = '.panel-body .chart-container';
const SLICE_NAME = 'My chart';
const CHART_ID = 42;
@@ -303,61 +303,24 @@ test('useDatasetDrillInfo creates new verbose_map from columns and metrics', asy
expect(result.current.result?.verbose_map).not.toHaveProperty('old_key');
});
test('useDatasetDrillInfo does not fetch when datasource ID resolves to NaN', async () => {
// Regression test: a chart's slice entity can still be unhydrated right
// after a client-side navigation back to a dashboard from Explore, so
// datasetId may transiently resolve to NaN. The hook must not fire a
// request for dataset "NaN" and should stay in loading, retrying once a
// real datasetId arrives (see the SliceHeaderControls -> Chart.tsx
// `state.sliceEntities.slices[id] || EMPTY_OBJECT` fallback).
const { result, rerender } = renderHook(
({ id }: { id: string | number }) => useDatasetDrillInfo(id, 456),
{ initialProps: { id: 'abc' } },
);
expect(result.current.status).toBe('loading');
expect(mockedCachedSupersetGet).not.toHaveBeenCalled();
const mockDataset = { id: 123, columns: [], metrics: [] };
test('useDatasetDrillInfo handles NaN datasource ID from malformed string', async () => {
mockedCachedSupersetGet.mockResolvedValue({
json: { result: mockDataset },
json: {
result: { id: NaN, columns: [], metrics: [] },
},
} as any);
rerender({ id: '123__table' });
const { result } = renderHook(() => useDatasetDrillInfo('abc', 456));
await waitFor(() => {
expect(result.current.status).toBe('complete');
});
// Verify hook calls endpoint with NaN (API will handle validation)
expect(mockedCachedSupersetGet).toHaveBeenCalledWith({
endpoint: '/api/v1/dataset/123/drill_info/?q=(dashboard_id:456)',
endpoint: '/api/v1/dataset/NaN/drill_info/?q=(dashboard_id:456)',
});
});
test('useDatasetDrillInfo resets to loading when datasetId regresses to NaN after resolving another dataset', async () => {
// Regression test: if the hook already completed for one dataset and then
// receives a transient malformed id (e.g. a fresh navigation clears the
// resolved datasetId before the new one hydrates), it must not keep
// exposing the previous dataset's Complete result -- the context menu
// would otherwise offer drill metadata for the wrong dataset.
const mockDataset = { id: 123, columns: [], metrics: [] };
mockedCachedSupersetGet.mockResolvedValue({
json: { result: mockDataset },
} as any);
const { result, rerender } = renderHook(
({ id }: { id: string | number }) => useDatasetDrillInfo(id, 456),
{ initialProps: { id: 123 } },
);
await waitFor(() => {
expect(result.current.status).toBe('complete');
});
expect(result.current.result).toMatchObject({ id: 123 });
rerender({ id: 'abc' });
expect(result.current.status).toBe('loading');
expect(result.current.result).toBeNull();
expect(result.current.status).toBe('complete');
});
test('useDatasetDrillInfo fetches dataset via extension when extension and formData provided', async () => {
@@ -86,28 +86,11 @@ export const useDatasetDrillInfo = (
});
return;
}
const numericDatasetId = getDatasetId(datasetId);
if (Number.isNaN(numericDatasetId)) {
// datasetId isn't resolved yet (e.g. the dashboard's slice entity hasn't
// hydrated after a client-side navigation back from Explore). Reset to
// Loading rather than firing a request for dataset "NaN" -- and rather
// than leaving a previous id's Complete/Error result in place, which
// would let the context menu expose drill metadata for the wrong
// dataset until this one resolves. The effect reruns once datasetId
// settles to a real value.
setResource({
status: ResourceStatus.Loading,
result: null,
error: null,
});
return;
}
// `bestEffort` callers recover from a failure themselves, so it is not worth
// logging: a deployment that registers the drill-by extension because this
// endpoint is unreachable would otherwise log on every dashboard load.
const fetchDrillInfo = async ({ bestEffort = false } = {}) => {
const endpoint = `/api/v1/dataset/${numericDatasetId}/drill_info/?q=(dashboard_id:${dashboardId})`;
const endpoint = `/api/v1/dataset/${getDatasetId(datasetId)}/drill_info/?q=(dashboard_id:${dashboardId})`;
try {
const { json } = await cachedSupersetGet({ endpoint });
return json.result;
@@ -122,6 +105,7 @@ export const useDatasetDrillInfo = (
const fetchDataset = async () => {
try {
const numericDatasetId = getDatasetId(datasetId);
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
'load.drillby.options',
);
+81 -81
View File
@@ -24,7 +24,7 @@
"@types/ws": "^8.18.1",
"esbuild": "^0.28.2",
"globals": "^17.11.0",
"oxfmt": "^0.66.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.81.0",
"oxlint-tsgolint": "^7.0.2001",
"tscw-config": "^1.1.2",
@@ -522,9 +522,9 @@
}
},
"node_modules/@oxfmt/binding-android-arm-eabi": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.66.0.tgz",
"integrity": "sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.65.0.tgz",
"integrity": "sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==",
"cpu": [
"arm"
],
@@ -539,9 +539,9 @@
}
},
"node_modules/@oxfmt/binding-android-arm64": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.66.0.tgz",
"integrity": "sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.65.0.tgz",
"integrity": "sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==",
"cpu": [
"arm64"
],
@@ -556,9 +556,9 @@
}
},
"node_modules/@oxfmt/binding-darwin-arm64": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.66.0.tgz",
"integrity": "sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.65.0.tgz",
"integrity": "sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==",
"cpu": [
"arm64"
],
@@ -573,9 +573,9 @@
}
},
"node_modules/@oxfmt/binding-darwin-x64": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.66.0.tgz",
"integrity": "sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.65.0.tgz",
"integrity": "sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==",
"cpu": [
"x64"
],
@@ -590,9 +590,9 @@
}
},
"node_modules/@oxfmt/binding-freebsd-x64": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.66.0.tgz",
"integrity": "sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.65.0.tgz",
"integrity": "sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==",
"cpu": [
"x64"
],
@@ -607,9 +607,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm-gnueabihf": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.66.0.tgz",
"integrity": "sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.65.0.tgz",
"integrity": "sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==",
"cpu": [
"arm"
],
@@ -624,9 +624,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm-musleabihf": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.66.0.tgz",
"integrity": "sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.65.0.tgz",
"integrity": "sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==",
"cpu": [
"arm"
],
@@ -641,9 +641,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm64-gnu": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.66.0.tgz",
"integrity": "sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.65.0.tgz",
"integrity": "sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==",
"cpu": [
"arm64"
],
@@ -661,9 +661,9 @@
}
},
"node_modules/@oxfmt/binding-linux-arm64-musl": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.66.0.tgz",
"integrity": "sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.65.0.tgz",
"integrity": "sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==",
"cpu": [
"arm64"
],
@@ -681,9 +681,9 @@
}
},
"node_modules/@oxfmt/binding-linux-ppc64-gnu": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.66.0.tgz",
"integrity": "sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.65.0.tgz",
"integrity": "sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==",
"cpu": [
"ppc64"
],
@@ -701,9 +701,9 @@
}
},
"node_modules/@oxfmt/binding-linux-riscv64-gnu": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.66.0.tgz",
"integrity": "sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.65.0.tgz",
"integrity": "sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==",
"cpu": [
"riscv64"
],
@@ -721,9 +721,9 @@
}
},
"node_modules/@oxfmt/binding-linux-riscv64-musl": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.66.0.tgz",
"integrity": "sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.65.0.tgz",
"integrity": "sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==",
"cpu": [
"riscv64"
],
@@ -741,9 +741,9 @@
}
},
"node_modules/@oxfmt/binding-linux-s390x-gnu": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.66.0.tgz",
"integrity": "sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.65.0.tgz",
"integrity": "sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==",
"cpu": [
"s390x"
],
@@ -761,9 +761,9 @@
}
},
"node_modules/@oxfmt/binding-linux-x64-gnu": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.66.0.tgz",
"integrity": "sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.65.0.tgz",
"integrity": "sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==",
"cpu": [
"x64"
],
@@ -781,9 +781,9 @@
}
},
"node_modules/@oxfmt/binding-linux-x64-musl": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.66.0.tgz",
"integrity": "sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.65.0.tgz",
"integrity": "sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==",
"cpu": [
"x64"
],
@@ -801,9 +801,9 @@
}
},
"node_modules/@oxfmt/binding-openharmony-arm64": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.66.0.tgz",
"integrity": "sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.65.0.tgz",
"integrity": "sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==",
"cpu": [
"arm64"
],
@@ -818,9 +818,9 @@
}
},
"node_modules/@oxfmt/binding-win32-arm64-msvc": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.66.0.tgz",
"integrity": "sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.65.0.tgz",
"integrity": "sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==",
"cpu": [
"arm64"
],
@@ -835,9 +835,9 @@
}
},
"node_modules/@oxfmt/binding-win32-ia32-msvc": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.66.0.tgz",
"integrity": "sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.65.0.tgz",
"integrity": "sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==",
"cpu": [
"ia32"
],
@@ -852,9 +852,9 @@
}
},
"node_modules/@oxfmt/binding-win32-x64-msvc": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.66.0.tgz",
"integrity": "sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.65.0.tgz",
"integrity": "sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==",
"cpu": [
"x64"
],
@@ -2923,9 +2923,9 @@
}
},
"node_modules/oxfmt": {
"version": "0.66.0",
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.66.0.tgz",
"integrity": "sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==",
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.65.0.tgz",
"integrity": "sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2938,28 +2938,28 @@
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/oxc-project"
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxfmt/binding-android-arm-eabi": "0.66.0",
"@oxfmt/binding-android-arm64": "0.66.0",
"@oxfmt/binding-darwin-arm64": "0.66.0",
"@oxfmt/binding-darwin-x64": "0.66.0",
"@oxfmt/binding-freebsd-x64": "0.66.0",
"@oxfmt/binding-linux-arm-gnueabihf": "0.66.0",
"@oxfmt/binding-linux-arm-musleabihf": "0.66.0",
"@oxfmt/binding-linux-arm64-gnu": "0.66.0",
"@oxfmt/binding-linux-arm64-musl": "0.66.0",
"@oxfmt/binding-linux-ppc64-gnu": "0.66.0",
"@oxfmt/binding-linux-riscv64-gnu": "0.66.0",
"@oxfmt/binding-linux-riscv64-musl": "0.66.0",
"@oxfmt/binding-linux-s390x-gnu": "0.66.0",
"@oxfmt/binding-linux-x64-gnu": "0.66.0",
"@oxfmt/binding-linux-x64-musl": "0.66.0",
"@oxfmt/binding-openharmony-arm64": "0.66.0",
"@oxfmt/binding-win32-arm64-msvc": "0.66.0",
"@oxfmt/binding-win32-ia32-msvc": "0.66.0",
"@oxfmt/binding-win32-x64-msvc": "0.66.0"
"@oxfmt/binding-android-arm-eabi": "0.65.0",
"@oxfmt/binding-android-arm64": "0.65.0",
"@oxfmt/binding-darwin-arm64": "0.65.0",
"@oxfmt/binding-darwin-x64": "0.65.0",
"@oxfmt/binding-freebsd-x64": "0.65.0",
"@oxfmt/binding-linux-arm-gnueabihf": "0.65.0",
"@oxfmt/binding-linux-arm-musleabihf": "0.65.0",
"@oxfmt/binding-linux-arm64-gnu": "0.65.0",
"@oxfmt/binding-linux-arm64-musl": "0.65.0",
"@oxfmt/binding-linux-ppc64-gnu": "0.65.0",
"@oxfmt/binding-linux-riscv64-gnu": "0.65.0",
"@oxfmt/binding-linux-riscv64-musl": "0.65.0",
"@oxfmt/binding-linux-s390x-gnu": "0.65.0",
"@oxfmt/binding-linux-x64-gnu": "0.65.0",
"@oxfmt/binding-linux-x64-musl": "0.65.0",
"@oxfmt/binding-openharmony-arm64": "0.65.0",
"@oxfmt/binding-win32-arm64-msvc": "0.65.0",
"@oxfmt/binding-win32-ia32-msvc": "0.65.0",
"@oxfmt/binding-win32-x64-msvc": "0.65.0"
},
"peerDependencies": {
"svelte": "^5.0.0",
+1 -1
View File
@@ -32,7 +32,7 @@
"@types/ws": "^8.18.1",
"esbuild": "^0.28.2",
"globals": "^17.11.0",
"oxfmt": "^0.66.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.81.0",
"oxlint-tsgolint": "^7.0.2001",
"tscw-config": "^1.1.2",
+1 -18
View File
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
import logging
import time
from typing import Any, Callable
import click
@@ -59,18 +58,10 @@ def _load_dataset(
if "force" in sig.parameters:
params["force"] = force
start = time.perf_counter()
try:
loader(**params)
except Exception as e:
logger.warning(
"Failed to load %s after %.2fs: %s",
dataset_name,
time.perf_counter() - start,
e,
)
else:
logger.info("Finished [%s] in %.2fs", dataset_name, time.perf_counter() - start)
logger.warning("Failed to load %s: %s", dataset_name, e)
def load_examples_run(
@@ -79,7 +70,6 @@ def load_examples_run(
only_metadata: bool = False,
force: bool = False,
) -> None:
run_start = time.perf_counter()
if only_metadata:
logger.info("Loading examples metadata")
else:
@@ -104,14 +94,7 @@ def load_examples_run(
_load_dataset(loader, loader_name, only_metadata, force)
# Load examples that are stored as YAML config files
configs_start = time.perf_counter()
examples.load_examples_from_configs(force, load_test_data)
logger.info(
"Finished [Examples From Configs] in %.2fs",
time.perf_counter() - configs_start,
)
logger.info("load_examples finished in %.2fs", time.perf_counter() - run_start)
@click.command()
-3
View File
@@ -43,7 +43,6 @@ from superset.migrations.shared.migrate_viz.processors import (
MigratePivotTable,
MigrateSankey,
MigrateSunburst,
MigrateTableChart,
MigrateTreeMap,
)
from superset.migrations.shared.utils import paginated_update, try_load_json
@@ -62,7 +61,6 @@ class VizType(str, Enum):
PIVOT_TABLE = "pivot_table"
SANKEY = "sankey"
SUNBURST = "sunburst"
TABLE = "table"
TREEMAP = "treemap"
@@ -79,7 +77,6 @@ MIGRATIONS: dict[VizType, Type[MigrateViz]] = {
VizType.PIVOT_TABLE: MigratePivotTable,
VizType.SANKEY: MigrateSankey,
VizType.SUNBURST: MigrateSunburst,
VizType.TABLE: MigrateTableChart,
VizType.TREEMAP: MigrateTreeMap,
}
+7 -17
View File
@@ -22,7 +22,6 @@ from typing import Any, Optional, TypedDict
import pandas as pd
from flask import current_app
from flask_babel import lazy_gettext as _
from sqlalchemy import or_
from werkzeug.datastructures import FileStorage
from superset import db
@@ -169,20 +168,12 @@ class UploadCommand(BaseCommand):
)
)
catalog = self._model.get_default_catalog()
catalog_filter = (
or_(SqlaTable.catalog == catalog, SqlaTable.catalog.is_(None))
if catalog is not None
else SqlaTable.catalog.is_(None)
)
sqla_table = (
db.session.query(SqlaTable)
.filter(
SqlaTable.table_name == self._table_name,
SqlaTable.schema == self._schema,
SqlaTable.database_id == self._model_id,
catalog_filter,
.filter_by(
table_name=self._table_name,
schema=self._schema,
database_id=self._model_id,
)
.one_or_none()
)
@@ -215,7 +206,7 @@ class UploadCommand(BaseCommand):
)
if soft_twin := DatasetDAO.find_soft_deleted_logical_duplicate(
self._model, Table(self._table_name, self._schema, catalog)
self._model, Table(self._table_name, self._schema)
):
raise DatabaseUploadSoftDeletedDatasetExistsError(str(soft_twin.uuid))
@@ -226,13 +217,12 @@ class UploadCommand(BaseCommand):
table_name=self._table_name,
database=self._model,
database_id=self._model_id,
catalog=catalog,
editors=editors,
schema=self._schema,
# Ensure catalog is set
catalog=self._model.get_default_catalog(),
)
db.session.add(sqla_table)
elif sqla_table.catalog is None and catalog is not None:
sqla_table.catalog = catalog
sqla_table.fetch_metadata()
@@ -163,14 +163,6 @@ class ImportExamplesCommand(ImportModelsCommand):
dataset_info: dict[str, dict[str, Any]] = {}
for file_name, config in configs.items():
if file_name.startswith("datasets/"):
# Some examples ship a dataset config for a table that another
# example already defines (same uuid, re-exported under a
# different folder). Import each uuid once per run --
# reimporting it just repeats the same column/metric sync
# against an identical config.
if config["uuid"] in dataset_info:
continue
# find the ID of the corresponding database
if config["database_uuid"] not in database_ids:
raise Exception( # pylint: disable=broad-exception-raised
-6
View File
@@ -42,12 +42,6 @@ NO_TIME_RANGE = "No filter"
QUERY_CANCEL_KEY = "cancel_query"
QUERY_EARLY_CANCEL_KEY = "early_cancel_query"
# Set once execute_sql_statements() has opened a DB connection and asked the
# engine spec for a cancel handle, regardless of whether one came back. Lets
# cancel_query() tell "hasn't been dispatched to the engine yet" (safe to
# fabricate a stop) apart from "this engine just has no cancel support"
# (must fail honestly) when no cancel ID is on record.
QUERY_DISPATCHED_KEY = "query_dispatched"
LRU_CACHE_MAX_SIZE = 256
-7
View File
@@ -28,7 +28,6 @@ from superset.queries.filters import QueryFilter
from superset.queries.saved_queries.filters import SavedQueryFilter
from superset.utils.core import get_user_id
from superset.utils.dates import now_as_float
from superset.utils.decorators import transaction
logger = logging.getLogger(__name__)
@@ -60,7 +59,6 @@ class QueryDAO(BaseDAO[Query]):
)
@staticmethod
@transaction()
def stop_query(client_id: str) -> None:
query = (
db.session.query(Query)
@@ -83,11 +81,6 @@ class QueryDAO(BaseDAO[Query]):
if not sql_lab.cancel_query(query):
raise SupersetCancelQueryException("Could not cancel query")
# cancel_query() may have staged an early-cancel flag on query.extra
# without committing it (see its docstring/comments); the
# @transaction decorator commits it together with status=STOPPED
# below in one transaction, closing the window where another
# request could observe the flag set but the status still RUNNING.
query.status = QueryStatus.STOPPED
query.end_time = now_as_float()
+2
View File
@@ -175,6 +175,7 @@ class CouchbaseEngineSpec(BasicParametersMixin, BaseEngineSpec):
port=parameters.get("port"),
query=query_params,
)
print(uri)
# SQLAlchemy 2.0 made URL.__str__() hide the password by default
# (it rendered in full under 1.4); render_as_string(hide_password=
# False) is required here since this URI is stored/used to actually
@@ -185,6 +186,7 @@ class CouchbaseEngineSpec(BasicParametersMixin, BaseEngineSpec):
def get_parameters_from_uri(
cls, uri: str, encrypted_extra: Optional[dict[str, Any]] = None
) -> BaseBasicParametersType:
print("get_parameters is called : ", uri)
url = make_url_safe(uri)
query = {
key: value
@@ -0,0 +1,348 @@
# 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.
always_filter_main_dttm: false
cache_timeout: null
catalog: null
columns:
- advanced_data_type: null
column_name: order_date
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: true
python_date_format: null
type: TIMESTAMP WITHOUT TIME ZONE
verbose_name: null
- advanced_data_type: null
column_name: price_each
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: DOUBLE PRECISION
verbose_name: null
- advanced_data_type: null
column_name: sales
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: DOUBLE PRECISION
verbose_name: null
- advanced_data_type: null
column_name: address_line1
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: address_line2
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: order_line_number
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: quantity_ordered
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: order_number
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: quarter
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: year
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: month
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: msrp
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: BIGINT
verbose_name: null
- advanced_data_type: null
column_name: contact_last_name
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: contact_first_name
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: postal_code
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: customer_name
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: deal_size
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: product_code
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: product_line
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: state
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: status
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: city
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: country
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: phone
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
- advanced_data_type: null
column_name: territory
description: null
expression: null
extra: null
filterable: true
groupby: true
is_active: true
is_dttm: false
python_date_format: null
type: TEXT
verbose_name: null
data_file: cleaned_sales_data.parquet
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
default_endpoint: null
description: null
extra: null
fetch_values_predicate: null
filter_select_enabled: true
folders: null
main_dttm_col: order_date
metrics:
- currency: null
d3format: null
description: null
expression: COUNT(*)
extra: null
metric_name: count
metric_type: count
verbose_name: COUNT(*)
warning_text: null
normalize_columns: false
offset: 0
params: null
schema: null
sql: null
table_name: cleaned_sales_data
template_params: null
uuid: e8623bb9-5e00-f531-506a-19607f5f8005
version: 1.0.0
+2 -11
View File
@@ -150,23 +150,14 @@ def load_parquet_table( # noqa: C901
except Exception as e:
logger.warning("Could not process column %s: %s", col, e)
# Write to target database. Scale the row chunksize down for wide
# tables so a single batch's bound-parameter count (rows * columns)
# stays under stock SQLite's default SQLITE_MAX_VARIABLE_NUMBER of
# 32766 -- a flat chunksize=500 on a 328-column table generates
# ~164k params per batch, which only some builds raise the limit
# for (e.g. Debian's SQLite package). A right-sized batch also
# plans faster than an oversized one.
num_cols = max(len(pdf.columns), 1)
chunksize = max(50, min(500, 30_000 // num_cols))
# Write to target database
with database.get_sqla_engine() as engine:
pdf.to_sql(
table_name,
engine,
schema=schema,
if_exists="replace",
chunksize=chunksize,
chunksize=500,
method="multi",
index=False,
)
File diff suppressed because it is too large Load Diff
+114 -71
View File
@@ -22,7 +22,7 @@ MCP tool: get_chart_data
import logging
import math
import time
from typing import Any, Dict, List, TYPE_CHECKING
from typing import Any, Dict, List, NamedTuple
from fastmcp import Context
from flask import current_app
@@ -30,9 +30,6 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import subqueryload
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.slice import Slice
from superset.charts.data.form_data import set_query_context_form_data
from superset.commands.exceptions import CommandException
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
@@ -68,6 +65,24 @@ from superset.utils.core import GenericDataType
logger = logging.getLogger(__name__)
class _ChartFacts(NamedTuple):
"""Chart values copied off the Slice while it is still session-attached.
Downstream helpers run after several commits and await points, so they are
handed these plain values rather than the ORM instance, whose attributes
may be expired or detached by then. Field names match the Slice columns
they come from, so helpers reading ``chart.viz_type`` or
``getattr(chart, "datasource_id", None)`` behave identically.
"""
id: int
slice_name: str | None
viz_type: str | None
# Unset for an unsaved chart, which carries only export metadata.
datasource_id: Any = None
datasource_type: str | None = None
_GENERIC_TYPE_MAP: dict[int, str] = {
GenericDataType.NUMERIC: "numeric",
GenericDataType.STRING: "string",
@@ -416,38 +431,66 @@ async def get_chart_data( # noqa: C901
chart = find_chart_by_identifier(
request.identifier, query_options=chart_query_options
)
if chart is not None:
guest_dashboard_id = guest_scope.guest_dashboard_id(chart)
if not chart:
await ctx.warning(
"Chart not found: identifier=%s" % (request.identifier,)
)
logger.warning(
"get_chart_data: chart not found: identifier=%s", request.identifier
)
display_id = str(request.identifier)[:200]
return ChartError(
error=(
f"No chart found with identifier: {display_id}."
" Use list_charts to get valid chart IDs."
),
error_type="NotFound",
)
if not chart:
await ctx.warning("Chart not found: identifier=%s" % (request.identifier,))
logger.warning(
"get_chart_data: chart not found: identifier=%s", request.identifier
)
display_id = str(request.identifier)[:200]
return ChartError(
error=(
f"No chart found with identifier: {display_id}."
" Use list_charts to get valid chart IDs."
),
error_type="NotFound",
# Copy the values this function needs into plain locals while the
# instance is freshly loaded and still attached.
#
# Reading them off the ORM object later is not safe: exiting an
# event_logger.log_context() commits the session
# (DBEventLogger.log -> db.session.commit), and a commit expires
# every loaded attribute. If the instance is also detached before
# the next read -- this tool is async and crosses many await
# points -- that read raises DetachedInstanceError, which the
# broad SQLAlchemyError handler below turns into a confusing
# internal-session error instead of chart data. Plain locals are
# immune to both expiry and detachment.
chart_id = chart.id
chart_name = chart.slice_name
chart_viz_type = chart.viz_type
chart_datasource_id = chart.datasource_id
chart_datasource_type = chart.datasource_type
chart_params = chart.params
chart_query_context = chart.query_context
chart_facts = _ChartFacts(
chart_id,
chart_name,
chart_viz_type,
chart_datasource_id,
chart_datasource_type,
)
guest_dashboard_id = guest_scope.guest_dashboard_id(chart)
await ctx.info(
"Chart found successfully: chart_id=%s, chart_name=%s, viz_type=%s"
% (
chart.id,
chart.slice_name,
chart.viz_type,
chart_id,
chart_name,
chart_viz_type,
)
)
logger.info("Getting data for chart %s: %s", chart.id, chart.slice_name)
logger.info("Getting data for chart %s: %s", chart_id, chart_name)
# Guests skip the RBAC check (authorize_query covers it) but keep the
# existence check, so a deleted dataset still returns
# DatasetNotAccessible.
validation_result = validate_chart_dataset(
chart.datasource_id, check_access=not guest_scope.is_guest_read()
chart_datasource_id, check_access=not guest_scope.is_guest_read()
)
if not validation_result.is_valid:
await ctx.warning(
@@ -456,7 +499,7 @@ async def get_chart_data( # noqa: C901
)
logger.warning(
"get_chart_data: dataset not accessible for chart_id=%s: %s",
chart.id,
chart_id,
validation_result.error,
)
return ChartError(
@@ -527,7 +570,7 @@ async def get_chart_data( # noqa: C901
else:
try:
parsed_saved_form_data = (
utils_json.loads(chart.params) if chart.params else {}
utils_json.loads(chart_params) if chart_params else {}
)
form_data = (
parsed_saved_form_data
@@ -538,7 +581,7 @@ async def get_chart_data( # noqa: C901
form_data = {}
if not using_unsaved_state:
form_data["viz_type"] = chart.viz_type or form_data.get("viz_type")
form_data["viz_type"] = chart_viz_type or form_data.get("viz_type")
# If using cached form_data, we need to build query_context from it
if using_unsaved_state and cached_form_data_dict is not None:
@@ -553,7 +596,7 @@ async def get_chart_data( # noqa: C901
query_context = build_query_context_from_form_data(
cached_form_data_dict,
chart=chart,
chart=chart_facts,
extra_form_data=request.extra_form_data,
row_limit=row_limit,
order_desc=cached_form_data_dict.get("order_desc", True),
@@ -563,9 +606,9 @@ async def get_chart_data( # noqa: C901
await ctx.debug(
"Built query_context from cached form_data (unsaved state)"
)
elif chart.query_context:
elif chart_query_context:
try:
query_context_json = utils_json.loads(chart.query_context)
query_context_json = utils_json.loads(chart_query_context)
await ctx.debug(
"Using chart's saved query_context for data retrieval"
)
@@ -586,7 +629,7 @@ async def get_chart_data( # noqa: C901
from superset.common.query_context_factory import QueryContextFactory
factory = QueryContextFactory()
# row_limit from chart.params may be a str; coerce for
# row_limit from chart_params may be a str; coerce for
# apply_max_row_limit's int comparison.
row_limit = _coerce_row_limit(
request.limit or form_data.get("row_limit"),
@@ -603,13 +646,13 @@ async def get_chart_data( # noqa: C901
# Bubble charts use x/y/size as separate metric fields.
# Deck.gl charts (deck_arc, deck_scatter, etc.) use spatial
# column configs (lat/lon, geohash, etc.) instead.
viz_type = chart.viz_type or ""
viz_type = chart_viz_type or ""
fallback_queries = build_query_dicts_from_form_data(
form_data,
chart.datasource_id,
chart.datasource_type,
chart=chart,
chart_datasource_id,
chart_datasource_type,
chart=chart_facts,
extra_form_data=request.extra_form_data,
row_limit=row_limit,
order_desc=True,
@@ -627,17 +670,17 @@ async def get_chart_data( # noqa: C901
"(viz_type=%s): no metrics, columns, or groupby "
"could be extracted from form_data. "
"Re-save the chart to populate query_context."
% (chart.id, viz_type)
% (chart_id, viz_type)
)
logger.warning(
"get_chart_data: cannot construct fallback query for "
"chart_id=%s (viz_type=%s): no metrics/columns found",
chart.id,
chart_id,
viz_type,
)
return ChartError(
error=(
f"Chart {chart.id} (type: {viz_type}) has no "
f"Chart {chart_id} (type: {viz_type}) has no "
f"saved query_context and its form_data does "
f"not contain recognizable metrics or columns. "
f"Please open this chart in Superset and "
@@ -648,8 +691,8 @@ async def get_chart_data( # noqa: C901
query_context = factory.create(
datasource={
"id": chart.datasource_id,
"type": chart.datasource_type,
"id": chart_datasource_id,
"type": chart_datasource_type,
},
queries=fallback_queries,
form_data=form_data,
@@ -685,8 +728,8 @@ async def get_chart_data( # noqa: C901
"Query execution parameters: datasource_id=%s, datasource_type=%s, "
"row_limit=%s, force_refresh=%s"
% (
chart.datasource_id,
chart.datasource_type,
chart_datasource_id,
chart_datasource_type,
request.limit or 100,
request.force_refresh,
)
@@ -699,8 +742,8 @@ async def get_chart_data( # noqa: C901
set_query_context_form_data(
query_context,
chart.datasource_id,
chart.datasource_type,
chart_datasource_id,
chart_datasource_type,
)
# Execute the query
@@ -729,16 +772,16 @@ async def get_chart_data( # noqa: C901
if not result or ("queries" not in result) or len(result["queries"]) == 0:
await ctx.warning(
"Empty query results: chart_id=%s, chart_type=%s"
% (chart.id, chart.viz_type)
% (chart_id, chart_viz_type)
)
logger.warning(
"get_chart_data: empty query results for chart_id=%s, "
"chart_type=%s",
chart.id,
chart.viz_type,
chart_id,
chart_viz_type,
)
return ChartError(
error=f"No query results returned for chart {chart.id}. "
error=f"No query results returned for chart {chart_id}. "
f"This may occur with chart types like big_number.",
error_type="EmptyQuery",
)
@@ -760,13 +803,13 @@ async def get_chart_data( # noqa: C901
# Check if we have data to work with
if not any(query.get("data") for query in result["queries"]):
await ctx.warning("No data in query results: chart_id=%s" % (chart.id,))
await ctx.warning("No data in query results: chart_id=%s" % (chart_id,))
logger.warning(
"get_chart_data: no data in query results for chart_id=%s",
chart.id,
chart_id,
)
return ChartError(
error=f"No data available for chart {chart.id}", error_type="NoData"
error=f"No data available for chart {chart_id}", error_type="NoData"
)
# Create rich column metadata
@@ -833,7 +876,7 @@ async def get_chart_data( # noqa: C901
insights.append("Fresh data retrieved from database")
recommended_visualizations = _recommend_visualizations(
viz_type=chart.viz_type or "unknown",
viz_type=chart_viz_type or "unknown",
columns=columns,
row_count=len(data),
)
@@ -873,7 +916,7 @@ async def get_chart_data( # noqa: C901
cache_info = age_info
summary_parts = [
f"Chart '{chart.slice_name}' ({chart.viz_type})",
f"Chart '{chart_name}' ({chart_viz_type})",
f"Contains {len(data)} rows across {len(raw_columns)} columns"
f"{cache_info}",
]
@@ -891,7 +934,7 @@ async def get_chart_data( # noqa: C901
action="mcp.get_chart_data.format_conversion"
):
return _export_data_as_csv(
chart,
chart_facts,
data[: request.limit] if request.limit else data,
raw_columns,
cache_status,
@@ -902,7 +945,7 @@ async def get_chart_data( # noqa: C901
action="mcp.get_chart_data.format_conversion"
):
return _export_data_as_excel(
chart,
chart_facts,
data[: request.limit] if request.limit else data,
raw_columns,
cache_status,
@@ -922,7 +965,7 @@ async def get_chart_data( # noqa: C901
"rows_returned=%s, columns_returned=%s, execution_time_ms=%s, "
"cache_hit=%s, data_completeness=%s"
% (
chart.id,
chart_id,
len(data),
len(raw_columns),
execution_time,
@@ -933,9 +976,9 @@ async def get_chart_data( # noqa: C901
# Default JSON format
return ChartData(
chart_id=chart.id,
chart_name=chart.slice_name or f"Chart {chart.id}",
chart_type=chart.viz_type or "unknown",
chart_id=chart_id,
chart_name=chart_name or f"Chart {chart_id}",
chart_type=chart_viz_type or "unknown",
columns=columns,
data=data[: request.limit] if request.limit else data,
query_results=_build_query_results(result["queries"], request.limit),
@@ -960,12 +1003,12 @@ async def get_chart_data( # noqa: C901
await ctx.error(
"Data retrieval failed: chart_id=%s, error=%s, error_type=%s"
% (
chart.id,
chart_id,
str(data_error),
type(data_error).__name__,
)
)
logger.error("Data retrieval error for chart %s: %s", chart.id, data_error)
logger.error("Data retrieval error for chart %s: %s", chart_id, data_error)
return ChartError(
error=f"Error retrieving chart data: {str(data_error)}",
error_type="DataError",
@@ -1142,10 +1185,10 @@ async def _query_from_form_data( # noqa: C901
chart_name = form_data.get("slice_name", "Unsaved chart")
if request.format in {"csv", "excel"}:
from superset.models.slice import Slice
# A transient chart supplies export metadata without saving anything.
chart = Slice(id=0, slice_name=chart_name, viz_type=viz_type)
# Export metadata for an unsaved chart. A plain record rather than
# a transient Slice: nothing here is persisted, and an unattached
# mapped instance risks being picked up by a later autoflush.
chart = _ChartFacts(0, chart_name, viz_type)
export = (
_export_data_as_csv
if request.format == "csv"
@@ -1205,7 +1248,7 @@ async def _query_from_form_data( # noqa: C901
def _export_data_as_csv(
chart: "Slice",
chart: "_ChartFacts",
data: List[Dict[str, Any]],
columns: List[str],
cache_status: Any,
@@ -1264,7 +1307,7 @@ def _export_data_as_csv(
def _export_data_as_excel(
chart: "Slice",
chart: "_ChartFacts",
data: List[Dict[str, Any]],
columns: List[str],
cache_status: Any,
@@ -1281,7 +1324,7 @@ def _export_data_as_excel(
def _create_excel_with_openpyxl(
chart: "Slice", data: List[Dict[str, Any]], columns: List[str]
chart: "_ChartFacts", data: List[Dict[str, Any]], columns: List[str]
) -> str:
"""Create Excel file using openpyxl."""
import base64
@@ -1325,7 +1368,7 @@ def _write_excel_data(ws: Any, data: List[Dict[str, Any]], columns: List[str]) -
def _try_xlsxwriter_fallback(
chart: "Slice",
chart: "_ChartFacts",
data: List[Dict[str, Any]],
columns: List[str],
cache_status: Any,
@@ -1352,7 +1395,7 @@ def _try_xlsxwriter_fallback(
def _create_excel_with_xlsxwriter(
chart: "Slice", data: List[Dict[str, Any]], columns: List[str]
chart: "_ChartFacts", data: List[Dict[str, Any]], columns: List[str]
) -> str:
"""Create Excel file using xlsxwriter."""
import base64
@@ -1395,7 +1438,7 @@ def _write_xlsxwriter_data(
def _create_excel_chart_data(
chart: "Slice",
chart: "_ChartFacts",
data: List[Dict[str, Any]],
excel_b64: str,
performance: Any,
@@ -1428,7 +1471,7 @@ def _create_excel_chart_data(
def _create_excel_chart_data_xlsxwriter(
chart: "Slice",
chart: "_ChartFacts",
data: List[Dict[str, Any]],
excel_b64: str,
performance: Any,
+47 -73
View File
@@ -40,8 +40,6 @@ class Slice(Base): # type: ignore
viz_type = Column(String(250))
params = Column(Text)
query_context = Column(Text)
datasource_id: int = Column(Integer)
datasource_type: str = Column(String(200))
FORM_DATA_BAK_FIELD_NAME = "form_data_bak"
@@ -158,24 +156,16 @@ class MigrateViz:
def upgrade_slice(cls, slc: Slice) -> None:
try:
clz = cls(slc.params)
# Back up params exactly as they were stored, before synthesizing
# "datasource" below, so downgrade_slice() restores the original
# chart verbatim rather than a copy carrying an injected key it
# never had.
form_data_bak = copy.deepcopy(clz.data)
# Some charts don't carry a "datasource" key in params — outside
# of migrations, callers always read it via Slice.form_data,
# which injects "datasource" from the datasource_id/
# datasource_type columns on every access. _build_query() (and
# anything else touching self.data) needs that same key, so
# synthesize it here the same way for the charts missing it.
if "datasource" not in clz.data and slc.datasource_id is not None:
clz.data["datasource"] = f"{slc.datasource_id}__{slc.datasource_type}"
clz._pre_action()
clz._migrate()
clz._post_action()
# viz_type depends on the migration and should be set after its execution
# because a source viz can be mapped to different target viz types
slc.viz_type = clz.target_viz_type
backup: Any | dict[str, Any] = {FORM_DATA_BAK_FIELD_NAME: form_data_bak}
query_context = try_load_json(slc.query_context)
@@ -185,14 +175,17 @@ class MigrateViz:
# A stored query_context is expected to be an object carrying
# a non-null "queries" list, but an atypical/malformed one
# (e.g. hand-edited via the API) missing that key, or with
# "queries": null, is still backed up wholesale here rather
# than raising on membership-testing it below, so downgrade
# can restore it verbatim instead of losing it (see
# FULL_CONTEXT_BAK_KEY). Both cases must share this sentinel
# path rather than backing up a bare `None` -- that value is
# indistinguishable from "no context was ever stored", which
# would make downgrade discard the slice's original
# datasource/form_data instead of restoring this context.
# "queries": null, must not raise here: viz_type was already
# flipped above, so an uncaught exception at this point
# would leave the slice half-migrated (new viz_type, but
# stale params/query_context in the old shape). Back up the
# whole context in that case so downgrade can restore it
# verbatim instead of losing it (see FULL_CONTEXT_BAK_KEY).
# Both cases must share this sentinel path rather than
# backing up a bare `None` -- that value is indistinguishable
# from "no context was ever stored", which would make
# downgrade discard the slice's original datasource/form_data
# instead of restoring this context.
if "queries" in query_context and query_context["queries"] is not None:
queries_bak = copy.deepcopy(query_context["queries"])
else:
@@ -208,23 +201,16 @@ class MigrateViz:
# number or a JSON list -- both accepted by the schema
# validator) can't carry "queries"/"form_data" keys; back it
# up wholesale like the cases above and rebuild a fresh one,
# rather than raising on membership-testing a non-dict.
# rather than raising on membership-testing a non-dict (which
# would leave the slice half-migrated, per the note above).
queries_bak = {FULL_CONTEXT_BAK_KEY: copy.deepcopy(query_context)}
query_context = clz._build_query()
else:
query_context = clz._build_query()
slc.query_context = json.dumps(query_context)
backup[QUERIES_BAK_FIELD_NAME] = queries_bak
new_params = json.dumps({**clz.data, **backup})
new_query_context = json.dumps(query_context)
# Only mutate the slice once every step above has succeeded, so a
# failure never leaves viz_type out of sync with params/query_context.
# viz_type depends on the migration and should be set after its execution
# because a source viz can be mapped to different target viz types
slc.viz_type = clz.target_viz_type
slc.query_context = new_query_context
slc.params = new_params
slc.params = json.dumps({**clz.data, **backup})
except Exception as e:
logger.warning("Failed to migrate slice %s: %s", slc.id, e)
@@ -233,48 +219,36 @@ class MigrateViz:
def downgrade_slice(cls, slc: Slice) -> None:
try:
form_data = try_load_json(slc.params)
form_data_bak = form_data.get(FORM_DATA_BAK_FIELD_NAME, {})
if (
"viz_type"
not in (form_data_bak := form_data.get(FORM_DATA_BAK_FIELD_NAME, {}))
or form_data_bak["viz_type"] != cls.source_viz_type
"viz_type" in form_data_bak
and form_data_bak["viz_type"] == cls.source_viz_type
):
return
new_params = json.dumps(form_data_bak)
new_viz_type = form_data_bak.get("viz_type")
# Sentinel so a "leave query_context untouched" branch below is
# distinguishable from "explicitly set it to None".
unchanged = object()
new_query_context: Any = unchanged
query_context = try_load_json(slc.query_context)
queries_bak = form_data.get(QUERIES_BAK_FIELD_NAME)
if isinstance(queries_bak, dict) and FULL_CONTEXT_BAK_KEY in queries_bak:
# The original context had no "queries" key (or wasn't an
# object at all), so it was backed up wholesale on upgrade --
# restore it verbatim rather than patching "queries" onto the
# upgraded context.
new_query_context = json.dumps(queries_bak[FULL_CONTEXT_BAK_KEY])
elif queries_bak is not None:
# A falsy-but-present backup (e.g. an original
# "queries": []) is still a real context to restore, not
# the "there was nothing to restore" case below -- treating
# it as None would discard the slice's original datasource
# and form_data.
query_context["queries"] = queries_bak
if "form_data" in query_context:
query_context["form_data"] = form_data_bak
new_query_context = json.dumps(query_context)
else:
new_query_context = None
# Only mutate the slice once every step above has succeeded, so a
# failure never leaves viz_type out of sync with params/query_context.
slc.params = new_params
slc.viz_type = new_viz_type
if new_query_context is not unchanged:
slc.query_context = new_query_context
slc.params = json.dumps(form_data_bak)
slc.viz_type = form_data_bak.get("viz_type")
query_context = try_load_json(slc.query_context)
queries_bak = form_data.get(QUERIES_BAK_FIELD_NAME)
if (
isinstance(queries_bak, dict)
and FULL_CONTEXT_BAK_KEY in queries_bak
):
# The original context had no "queries" key, so it was
# backed up wholesale on upgrade -- restore it verbatim
# rather than patching "queries" onto the upgraded
# context.
slc.query_context = json.dumps(queries_bak[FULL_CONTEXT_BAK_KEY])
elif queries_bak is not None:
# A falsy-but-present backup (e.g. an original
# "queries": []) is still a real context to restore, not
# the "there was nothing to restore" case below --
# treating it as None would discard the slice's original
# datasource and form_data.
query_context["queries"] = queries_bak
if "form_data" in query_context:
query_context["form_data"] = form_data_bak
slc.query_context = json.dumps(query_context)
else:
slc.query_context = None
except Exception as e:
logger.warning("Failed to downgrade slice %s: %s", slc.id, e)
@@ -26,16 +26,13 @@ from superset.migrations.shared.migrate_viz.query_functions import (
get_metric_label,
get_x_axis_column,
histogram_operator,
is_adhoc_metric_simple,
is_physical_column,
is_time_comparison,
is_x_axis_set,
normalize_order_by,
omit,
pivot_operator,
prophet_operator,
rank_operator,
remove_duplicates,
remove_form_data_suffix,
rename_operator,
resample_operator,
@@ -657,315 +654,3 @@ class MigrateSankey(MigrateViz):
return [result]
return build_query_context(self.data, process)
def _get_table_chart_time_offsets(
form_data: dict[str, Any], base_query_object: dict[str, Any]
) -> list[Any]:
"""
Resolve time_compare into the list of shifts buildQuery.ts sends as
time_offsets. table charts use a single-select time_compare control
whose choices include the special 'custom'/'inherit' shifts, which
resolve to start_date_offset/'inherit' rather than being used verbatim.
Chart-level shifts only apply when is_time_comparison(...) holds,
mirroring buildQuery.ts; the dashboard-level extra_form_data override
below is applied regardless, since it can force a comparison the chart
itself isn't configured for.
"""
time_compare_shifts = ensure_is_array(form_data.get("time_compare"))
non_custom_or_inherit_shifts = [
shift for shift in time_compare_shifts if shift not in ("custom", "inherit")
]
custom_or_inherit_shifts = [
shift for shift in time_compare_shifts if shift in ("custom", "inherit")
]
time_offsets: list[Any] = []
if is_time_comparison(form_data, base_query_object):
time_offsets = list(non_custom_or_inherit_shifts)
if "custom" in custom_or_inherit_shifts:
time_offsets.append(form_data.get("start_date_offset"))
if "inherit" in custom_or_inherit_shifts:
time_offsets.append("inherit")
# Dashboard filter override - allows dashboard-level time shifts to
# OVERRIDE chart-level time shift settings, mirroring buildQuery.ts.
extra_form_data_time_compare = (form_data.get("extra_form_data") or {}).get(
"time_compare"
)
if extra_form_data_time_compare:
# extra_form_data.time_compare is typed as a single string on the
# frontend, but self.data comes from deserialized JSON with no
# runtime type guarantee — normalize defensively so an already-list
# value doesn't get double-nested into [[...]].
time_offsets = list(ensure_is_array(extra_form_data_time_compare))
return time_offsets
def _reorder_table_chart_temporal_column(
columns: list[Any],
time_grain_sqla: Any,
temporal_columns_lookup: dict[str, Any],
) -> list[Any]:
"""
Move the first physical column with a temporal_columns_lookup entry to
the front of the columns list as a BASE_AXIS adhoc column, mirroring
buildQuery.ts's temporal-column handling in aggregate mode.
"""
temporal_column = None
filtered_columns = []
for col in columns:
should_be_temporal = (
is_physical_column(col)
and time_grain_sqla
and temporal_columns_lookup.get(col)
)
if should_be_temporal and temporal_column is None:
temporal_column = {
"timeGrain": time_grain_sqla,
"columnType": "BASE_AXIS",
"sqlExpression": col,
"label": col,
"expressionType": "SQL",
}
else:
filtered_columns.append(col)
return [temporal_column] + filtered_columns if temporal_column else filtered_columns
def _to_totals_aggregate(value: Any) -> str:
"""
Narrow a raw totals_aggregate form-data value, mirroring
toTotalsAggregate() in @superset-ui/chart-controls. Anything other than
an explicit SUM/AVG -- including charts saved before the control
existed -- keeps each metric's own aggregation.
"""
return value if value in ("SUM", "AVG") else "ORIGINAL"
def _get_table_chart_totals_metrics(
metrics: list[Any], totals_aggregate: str
) -> list[Any]:
"""
Build the metrics for the totals query, mirroring getTotalsMetrics() in
@superset-ui/chart-controls: with SUM or AVG, each SIMPLE (adhoc) metric
is cloned with its aggregate replaced, since the totals query has no
GROUP BY. Custom-SQL and saved (string) metrics have no safe way to
rewrite an arbitrary aggregate, so they pass through unchanged.
"""
if totals_aggregate == "ORIGINAL":
return metrics
return [
{**metric, "aggregate": totals_aggregate}
if is_adhoc_metric_simple(metric)
else metric
for metric in metrics
]
class MigrateTableChart(MigrateViz):
source_viz_type = "table"
target_viz_type = "ag-grid-table"
# allow_rearrange_columns/allow_render_html are kept as-is: v2 reads them
# under the same names (see rename_keys below), so nothing to remove.
# (allow_rearrange_columns still gets a value materialized in
# _pre_action below when the source chart omits the key.)
remove_keys: set[str] = set()
rename_keys: dict[str, str] = {} # no renames needed; names match 1:1
def _pre_action(self) -> None:
# page_length: 0 means "All rows" (no pagination) in both v1 and v2.
# v2's control panel doesn't offer 0 as a page_length dropdown
# choice, but it's still a working runtime value there -- e.g.
# getPageSize() in transformProps.ts picks 0 automatically for any
# chart under 5000 cells when page_length isn't set at all -- so
# keep it as-is rather than rewriting it to a paginated value.
# Table charts are explicitly excluded from Matrixify
# (MATRIXIFY_INCOMPATIBLE_CHARTS), so drop any matrixify_* keys
# rather than migrating them.
for key in [k for k in self.data if k.startswith("matrixify_")]:
self.data.pop(key)
# v1's control (and TableChart) default allow_rearrange_columns to
# False, and older saved charts may omit the key entirely. v2's
# transformProps.ts instead defaults a missing key to True, since
# for v2-native charts that predate the control it means "keep the
# always-on behavior v2 originally shipped with". Materialize v1's
# default explicitly here so a migrated chart keeps its original
# (non-draggable) behavior instead of picking up v2's unrelated
# default for its own pre-existing charts.
if "allow_rearrange_columns" not in self.data:
self.data["allow_rearrange_columns"] = False
def _build_aggregate_mode_query(
self, base_query_object: dict[str, Any], time_offsets: list[Any]
) -> tuple[list[Any], list[Any], Any, list[Any]]:
"""
Returns (metrics, columns, orderby, post_processing) for aggregate
mode, mirroring buildQuery.ts's QueryMode.Aggregate branch: sort-by
metric/default ordering, percent-metric contribution, time
comparison, and moving the temporal column to the front.
"""
metrics = base_query_object.get("metrics") or []
orderby = base_query_object.get("orderby") or []
columns = list(base_query_object.get("columns") or [])
post_processing: list[Any] = []
sort_by_metric_options = ensure_is_array(
self.data.get("timeseries_limit_metric")
)
sort_by_metric = sort_by_metric_options[0] if sort_by_metric_options else None
if sort_by_metric:
orderby = [[sort_by_metric, not self.data.get("order_desc", False)]]
elif metrics:
orderby = [[metrics[0], False]]
if percent_metrics := ensure_is_array(self.data.get("percent_metrics")):
percent_metric_base_labels = [get_metric_label(m) for m in percent_metrics]
if is_time_comparison(self.data, base_query_object):
# Mirror buildQuery.ts's addComparisonPercentMetrics: expand
# each percent metric with its time-offset suffixes so
# shifted percent columns are computed/renamed too.
percent_metric_labels_with_time_comparison = [
label
for metric_label in percent_metric_base_labels
for label in [
metric_label,
*[f"{metric_label}__{shift}" for shift in time_offsets],
]
]
else:
percent_metric_labels_with_time_comparison = percent_metric_base_labels
percent_metric_labels = remove_duplicates(
percent_metric_labels_with_time_comparison, get_metric_label
)
metrics = remove_duplicates(metrics + percent_metrics, get_metric_label)
post_processing.append(
{
"operation": "contribution",
"options": {
"columns": percent_metric_labels,
"rename_columns": [f"%{m}" for m in percent_metric_labels],
},
}
)
if time_offsets:
time_compare = time_compare_operator(self.data, base_query_object)
if time_compare:
post_processing.append(time_compare)
# Dashboard-level grain override takes precedence over the
# chart-level time_grain_sqla, mirroring buildQuery.ts.
extra_form_data_time_grain = (self.data.get("extra_form_data") or {}).get(
"time_grain_sqla"
)
time_grain_sqla = extra_form_data_time_grain or self.data.get("time_grain_sqla")
columns = _reorder_table_chart_temporal_column(
columns,
time_grain_sqla,
self.data.get("temporal_columns_lookup") or {},
)
return metrics, columns, orderby, post_processing
def _build_table_chart_extra_queries(
self, query_object: dict[str, Any]
) -> list[dict[str, Any]]:
"""
Extra queries appended after the main query: an unlimited
percent-metrics-only query for percent_metric_calculation ==
'all_records', and a totals query when show_totals is on.
"""
percent_metrics = ensure_is_array(self.data.get("percent_metrics"))
calculation_mode = self.data.get("percent_metric_calculation") or "row_limit"
metrics = query_object.get("metrics")
contribution_post_processing = next(
(
pp
for pp in query_object.get("post_processing") or []
if pp.get("operation") == "contribution"
),
None,
)
extra_queries = []
if calculation_mode == "all_records" and percent_metrics:
extra_queries.append(
{
**query_object,
"columns": [],
"metrics": percent_metrics,
"post_processing": [],
"row_limit": 0,
"row_offset": 0,
"orderby": [],
"is_timeseries": False,
}
)
if metrics and self.data.get("show_totals"):
totals_aggregate = _to_totals_aggregate(self.data.get("totals_aggregate"))
extra_queries.append(
{
**omit(query_object, ["order_desc", "orderby"]),
"columns": [],
"metrics": _get_table_chart_totals_metrics(
metrics, totals_aggregate
),
"row_limit": 0,
"row_offset": 0,
"post_processing": (
[contribution_post_processing]
if contribution_post_processing
else []
),
}
)
return extra_queries
def _build_query(self) -> dict[str, Any]:
# Table v1 and v2 share the same buildQuery shape (groupby/metrics/
# percent_metrics/row_limit/order_by_cols/percent_metric_calculation),
# so this mirrors plugin-chart-table/src/buildQuery.ts and
# plugin-chart-ag-grid-table/src/buildQuery.ts, minus the
# request-time-only branches (server pagination paging/search state,
# download row-limit overrides) that don't apply to a persisted
# query_context.
query_mode = self.data.get("query_mode")
all_columns = ensure_is_array(self.data.get("all_columns"))
raw_mode = query_mode == "raw" or (query_mode is None and len(all_columns) > 0)
def process(base_query_object: dict[str, Any]) -> list[dict[str, Any]]:
time_offsets = _get_table_chart_time_offsets(self.data, base_query_object)
if raw_mode:
metrics = base_query_object.get("metrics")
columns = base_query_object.get("columns") or []
orderby = base_query_object.get("orderby") or []
post_processing: list[Any] = []
else:
metrics, columns, orderby, post_processing = (
self._build_aggregate_mode_query(base_query_object, time_offsets)
)
query_object = {
**base_query_object,
"columns": columns,
"orderby": orderby,
"metrics": metrics,
"post_processing": post_processing,
"time_offsets": time_offsets,
}
extra_queries = (
[] if raw_mode else self._build_table_chart_extra_queries(query_object)
)
return [query_object, *extra_queries]
return build_query_context(self.data, process)
@@ -40,12 +40,6 @@ class DatasourceType(Enum):
Dataset = "dataset"
SlTable = "sl_table"
SavedQuery = "saved_query"
SemanticView = "semantic_view"
DATASOURCE_TYPE_MAP = {
datasource_type.value: datasource_type for datasource_type in DatasourceType
}
UNARY_OPERATORS = ["IS NOT NULL", "IS NULL"]
@@ -73,7 +67,10 @@ class DatasourceKey:
def __init__(self, key: str):
id_str, type_str = key.split("__", 1)
self.id = int(id_str)
self.type = DATASOURCE_TYPE_MAP.get(type_str, DatasourceType.Table)
# Default to Table; if type_str is 'query', then use Query.
self.type = DatasourceType.Table
if type_str == "query":
self.type = DatasourceType.Query
def __str__(self) -> str:
return f"{self.id}__{self.type.value}"
+5 -61
View File
@@ -735,66 +735,6 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
return self.format()
_SELECT_TRAILING_CLAUSES: tuple[str, ...] = (
"options",
"settings",
"format",
"locks",
"offset",
"limit",
"sort",
"cluster",
"distribute",
"order",
"windows",
"qualify",
"having",
"group",
"where",
"joins",
"laterals",
"from",
"into",
"expressions",
)
def _get_select_trailing_child(node: exp.Select) -> exp.Expression | None:
for clause_name in _SELECT_TRAILING_CLAUSES:
val = node.args.get(clause_name)
if isinstance(val, list) and val:
return _find_last_token_node(val[-1])
if isinstance(val, exp.Expression):
return _find_last_token_node(val)
return None
def _find_last_token_node(node: exp.Expression) -> exp.Expression:
"""
Find the last token/leaf node in SQL generation order to attach trailing comments.
Avoids optimizer hints (exp.Hint) and non-trailing subtrees to prevent injecting
trailing comments inside optimizer hint blocks (e.g. /*+ SET_VAR(...) */).
"""
if isinstance(node, exp.Select):
if trailing := _get_select_trailing_child(node):
return trailing
children: list[exp.Expression] = []
for k, v in node.args.items():
if k in ("hint", "comments"):
continue
if isinstance(v, exp.Expression):
children.append(v)
elif isinstance(v, list):
children.extend(item for item in v if isinstance(item, exp.Expression))
if children:
return _find_last_token_node(children[-1])
return node
class SQLStatement(BaseSQLStatement[exp.Expression]):
"""
A SQL statement.
@@ -992,7 +932,11 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
# statement; move them back to the last token in the last real statement
if len(statements) > 1 and isinstance(statements[-1], exp.Semicolon):
last_statement = statements.pop()
target = _find_last_token_node(statements[-1])
target = statements[-1]
for node in statements[-1].walk():
if hasattr(node, "comments"): # pragma: no cover
target = node
target.comments = target.comments or []
target.comments.extend(last_statement.comments)
+5 -177
View File
@@ -39,11 +39,7 @@ from superset import (
security_manager,
)
from superset.common.db_query_status import QueryStatus
from superset.constants import (
QUERY_CANCEL_KEY,
QUERY_DISPATCHED_KEY,
QUERY_EARLY_CANCEL_KEY,
)
from superset.constants import QUERY_CANCEL_KEY, QUERY_EARLY_CANCEL_KEY
from superset.dataframe import df_to_records
from superset.db_engine_specs import BaseEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -103,39 +99,6 @@ def handle_query_error(
) -> dict[str, Any]:
"""Local method handling error while processing the SQL"""
payload = payload or {}
# A stop request may have already committed STOPPED status while this
# exception was being raised/propagated -- this function is the general
# catch-all for failures anywhere in execute_sql_statements (connection
# setup, cancel-ID acquisition, parsing, or a per-block failure), not
# just ones caused by the stop itself. A terminal stop must stay
# terminal, so don't let an unrelated error overwrite it with FAILED.
#
# Deliberately NOT a flush()-then-refresh(query) here, unlike the other
# STOPPED-preservation checks in this module: the exception that got us
# here may itself have already set query.status (or other attributes)
# locally (e.g. SoftTimeLimitExceeded's own handler sets TIMED_OUT
# without committing). Flushing first would push that stale local state
# to the DB, clobbering a concurrently-committed STOPPED before this
# check ever gets to observe it.
#
# A targeted refresh(attribute_names=["status"]) alone isn't enough:
# verified empirically that even though it expires and reloads only the
# named attribute (so a dirty `status` itself is correctly discarded
# rather than written), the reload's own SELECT still triggers a normal
# autoflush of any OTHER dirty attribute on the session first -- e.g. a
# pending query.tmp_table_name or query.executed_sql set earlier would
# still get written before the status read. no_autoflush suppresses
# that: verified it emits only the targeted SELECT, with no UPDATE
# beforehand, and leaves other pending attributes exactly as dirty as
# they were (to be flushed normally by this function's own commit()
# below, once we're past the STOPPED check).
with db.session.no_autoflush:
db.session.refresh(query, attribute_names=["status"])
if query.status == QueryStatus.STOPPED:
payload.update({"status": query.status})
return payload
msg = f"{prefix_message} {str(ex)}".strip()
query.error_message = msg
query.tmp_table_name = None
@@ -449,21 +412,6 @@ def execute_sql_statements( # noqa: C901
query = get_query(query_id=query_id)
payload: dict[str, Any] = {"query_id": query_id}
# A stop request may have landed before this worker even started (e.g.
# the request was queued and the user clicked Stop before a worker
# picked it up). Honor it here, mirroring the per-block stopped-check
# further down, instead of unconditionally overwriting it back to
# RUNNING and dispatching the statement anyway.
#
# Same disclosed, unfixed TOCTOU residual as the other status checks in
# this function (see the longer comment above the pre-payload check
# further down): a stop committed strictly between this check and the
# `query.status = RUNNING` commit a few lines below is still missed.
if query.status == QueryStatus.STOPPED:
payload.update({"status": query.status})
return payload
database = query.database
db_engine_spec = database.db_engine_spec
db_engine_spec.patch()
@@ -561,14 +509,9 @@ def execute_sql_statements( # noqa: C901
cursor = conn.cursor()
cancel_query_id = db_engine_spec.get_cancel_query_id(cursor, query)
# Recorded unconditionally -- even when no cancel ID comes back --
# so cancel_query() can tell "hasn't reached the engine yet" (still
# safe to fabricate a stop) apart from "this engine has no cancel
# support" (must fail honestly) once we get here.
query.set_extra_json_key(QUERY_DISPATCHED_KEY, True)
if cancel_query_id is not None:
query.set_extra_json_key(QUERY_CANCEL_KEY, cancel_query_id)
db.session.commit()
db.session.commit()
block_count = len(blocks)
for i, block in enumerate(blocks):
@@ -621,41 +564,6 @@ def execute_sql_statements( # noqa: C901
if parsed_script.has_mutation() or query.select_as_cta:
conn.commit()
# A stop request may have landed after the last per-block check but
# before the final statement finished (there's no next iteration to
# catch it on for the last block). Check again before building a SUCCESS
# payload or writing results to the backend -- both would otherwise
# disagree with the row. The results-backend-write-failure branch below
# has its own second check for the same reason (a stop landing while
# that specific write is in flight).
#
# KNOWN, DELIBERATELY UNFIXED RESIDUAL: this codebase has no DB-level
# locking, so every "check status, then later commit something based on
# what was read" pattern in this function -- this one, the
# results-backend-write-failure check below, the startup check before
# `query.status = RUNNING` is committed a few lines later, and
# cancel_query()'s own QUERY_DISPATCHED_KEY read/commit gap (see the
# disclosure comment there) -- has the same fundamental TOCTOU window: a
# stop committed strictly between the check and the later commit is
# still missed. Each check narrows its window as much as reasonably
# possible without locking; none of them claim to close it. Closing any
# of them for real needs real DB-level row locking (e.g.
# SELECT ... FOR UPDATE) or optimistic-concurrency versioning on the
# query row, neither of which is meaningfully verifiable against the
# sqlite backend this codebase tests against, and is deliberately not
# attempted here.
#
# flush() first: refresh() does NOT autoflush -- without this, any
# pending, uncommitted attribute set earlier in this iteration (e.g.
# query.executed_sql, set just before execute_query() ran) would be
# silently discarded and reloaded back to its previous committed value
# instead of surviving to the function's own later commits.
db.session.flush()
db.session.refresh(query)
if query.status == QueryStatus.STOPPED:
payload.update({"status": query.status})
return payload
# Success, updating the query entry in database
query.rows = result_set.size
query.progress = 100
@@ -744,36 +652,6 @@ def execute_sql_statements( # noqa: C901
# For async queries (not returning results inline), mark as FAILED
# because results are inaccessible to the user
if not return_results:
# A stop request may have landed and committed STOPPED
# while this (potentially slow) results-backend write was
# in flight. Refresh before marking FAILED -- a terminal
# STOPPED must stay terminal, not be overwritten just
# because the backend write also failed to complete
# around the same time.
#
# flush() first: refresh() does NOT autoflush -- without
# this, the result metadata already set earlier in this
# function (rows, progress, extra "columns", select_sql,
# end_time) plus the results_key = None set just above
# would be silently discarded and reloaded back to their
# previous (pre-execution) values instead of surviving to
# this branch's own commit below.
db.session.flush()
db.session.refresh(query)
if query.status == QueryStatus.STOPPED:
# A fresh, minimal payload -- not `payload.update()`.
# By this point `payload` already has the full
# SUCCESS shape baked in from earlier (result data, a
# nested query["state"] == SUCCESS, and a resultsKey
# for a write that just failed), so patching only the
# top-level "status" key would return a payload that
# simultaneously claims STOPPED while still carrying
# SUCCESS data and a resultsKey pointing at nothing
# actually stored. Matches the shape the other
# STOPPED-preservation return sites in this function
# use (a plain {"query_id", "status"} pair).
return {"query_id": query_id, "status": query.status}
query.status = QueryStatus.FAILED
query.error_message = (
"Failed to store query results in the results backend. "
@@ -798,24 +676,8 @@ def execute_sql_statements( # noqa: C901
key,
)
# Only set SUCCESS if we didn't already set FAILED above, and don't
# clobber a STOPPED status a concurrent stop request may have committed
# since the check above -- a terminal stop must stay terminal. This is a
# backstop for the DB row specifically (the payload/results-write
# consistency check already happened above); it doesn't reopen or
# re-narrow the same disclosed race window from that check.
#
# flush() first: refresh() does NOT autoflush -- without this, every
# result field set on the success path above (rows, progress, extra
# "columns", select_sql, end_time, results_key) would be silently
# discarded and reloaded back to their pre-execution (typically None)
# values on EVERY successful query, since nothing before this point
# commits them. This was a real regression caught by CI integration
# tests across all three DB backends (sqlite/mysql/postgres) that the
# unit-test suite driving this fix never exercised.
db.session.flush()
db.session.refresh(query)
if query.status not in (QueryStatus.FAILED, QueryStatus.STOPPED):
# Only set SUCCESS if we didn't already set FAILED above
if query.status != QueryStatus.FAILED:
query.status = QueryStatus.SUCCESS
db.session.commit()
@@ -885,41 +747,7 @@ def cancel_query(query: Query) -> bool:
cancel_query_id = query.extra.get(QUERY_CANCEL_KEY)
if cancel_query_id is None:
# KNOWN LIMITATION (deliberately not fixed here): this read of
# QUERY_DISPATCHED_KEY and execute_sql_statements()'s own commit of
# that same flag (see the "Recorded unconditionally" comment where
# it's set) are two independent transactions with no lock between
# them. A stop request can still land in the narrow window where
# this read has already happened -- deciding "not dispatched yet,
# safe to fabricate a stop" -- but the worker's dispatch commit
# lands immediately after, so the statement still gets sent to the
# engine even though the row was just marked STOPPED. Closing this
# for real needs DB-level row locking (e.g. SELECT ... FOR UPDATE)
# or optimistic-concurrency versioning on the query row; neither is
# meaningfully verifiable against the sqlite backend this codebase's
# tests run against, so it's out of scope here rather than a
# false claim of safety.
if query.extra.get(QUERY_DISPATCHED_KEY):
# execute_sql_statements() already opened a connection and asked
# this engine spec for a cancel handle, and still got nothing --
# this engine genuinely has no way to cancel a query once it's
# running. That's a real failure, not a race window; report it
# honestly rather than fabricating a stop the engine can't back.
return False
# No cancel handle has been recorded and execution hasn't reached the
# engine yet, so "no ID" here can only mean "too early to have one" --
# record the same early-cancel intent Trino's own
# prepare_cancel_query() records for its harder case (ID only
# obtainable after execution starts), so the stopped check at the top
# of the statement-block loop honors the request instead of leaving
# the query stuck at RUNNING with no avenue to ever stop it.
#
# Not committed here: the caller (QueryDAO.stop_query) commits this
# together with status=STOPPED in one transaction, so another
# request can never observe the flag set but the status still
# RUNNING.
query.set_extra_json_key(QUERY_EARLY_CANCEL_KEY, True)
return True
return False
with query.database.get_sqla_engine(
catalog=query.catalog,
@@ -296,14 +296,7 @@ def _stub_run_environment(mocker: MockerFixture) -> MagicMock:
)
db_mock = mocker.patch("superset.commands.database.uploaders.base.db")
# No visible dataset over the target table.
db_mock.session.query.return_value.filter.return_value.one_or_none.return_value = (
None
)
db_mock.session.query.return_value.filter_by.return_value.one_or_none.return_value = None # noqa: E501
mocker.patch(
"superset.commands.database.uploaders.base.or_",
side_effect=lambda *args: mocker.MagicMock(),
)
return model
@@ -375,57 +368,3 @@ def test_run_proceeds_when_no_soft_deleted_twin(
)
command.run()
reader.read.assert_called_once()
def test_run_sets_default_catalog_on_dataset_creation(
app_context: None, mocker: MockerFixture
) -> None:
"""UploadCommand sets default catalog on newly created dataset."""
model = _stub_run_environment(mocker)
model.get_default_catalog.return_value = "default_catalog"
mocker.patch(
"superset.daos.dataset.DatasetDAO.find_soft_deleted_logical_duplicate",
return_value=None,
)
sqla_table_mock = mocker.patch(
"superset.commands.database.uploaders.base.SqlaTable",
return_value=MagicMock(),
)
mocker.patch(
"superset.commands.database.uploaders.base.get_user",
return_value=None,
)
reader = MagicMock()
command = UploadCommand(
model_id=1, table_name="t", file=_file(b"x"), schema="public", reader=reader
)
command.run()
sqla_table_mock.assert_called_once()
assert sqla_table_mock.call_args.kwargs.get("catalog") == "default_catalog"
def test_run_updates_catalog_on_existing_dataset_with_none_catalog(
app_context: None, mocker: MockerFixture
) -> None:
"""UploadCommand updates catalog on an existing dataset if catalog was None."""
model = _stub_run_environment(mocker)
model.get_default_catalog.return_value = "default_catalog"
existing_table = MagicMock()
existing_table.catalog = None
db_mock = mocker.patch("superset.commands.database.uploaders.base.db")
db_mock.session.query.return_value.filter.return_value.one_or_none.return_value = (
existing_table
)
reader = MagicMock()
command = UploadCommand(
model_id=1, table_name="t", file=_file(b"x"), schema="public", reader=reader
)
command.run()
assert existing_table.catalog == "default_catalog"
existing_table.fetch_metadata.assert_called_once()
@@ -348,71 +348,6 @@ def test_import_passes_ignore_permissions_to_all_importers(
assert mock_import_dashboard.call_args[1].get("ignore_permissions") is True
@patch(
"superset.commands.importers.v1.examples.safe_insert_dashboard_chart_relationships"
)
@patch("superset.commands.importers.v1.examples.import_dataset")
@patch("superset.commands.importers.v1.examples.import_database")
def test_import_dedupes_datasets_with_same_uuid(
mock_import_db,
mock_import_dataset,
mock_safe_insert,
):
"""_import() must import a given dataset uuid at most once per run.
Two example folders can ship a dataset config for the same
underlying table with an identical uuid (e.g. "world_health" and
"misc_charts" both shipping a config for "wb_health_population").
Importing it twice repeats the same column/metric sync for no
benefit.
"""
from superset.commands.importers.v1.examples import ImportExamplesCommand
db_uuid = "a2dc77af-e654-49bb-b321-40f6b559a1ee"
dataset_uuid = "69e9de42-fe7f-4948-946a-f7913227aee8"
mock_db_obj = MagicMock()
mock_db_obj.uuid = db_uuid
mock_db_obj.id = 1
mock_import_db.return_value = mock_db_obj
mock_dataset_obj = MagicMock()
mock_dataset_obj.uuid = dataset_uuid
mock_dataset_obj.id = 10
mock_dataset_obj.table_name = "wb_health_population"
mock_import_dataset.return_value = mock_dataset_obj
configs = {
"databases/examples.yaml": {
"uuid": db_uuid,
"database_name": "examples",
"sqlalchemy_uri": "sqlite:///test.db",
},
"datasets/examples/world_health.yaml": {
"uuid": dataset_uuid,
"table_name": "wb_health_population",
"database_uuid": db_uuid,
"schema": None,
"sql": None,
},
"datasets/examples/wb_health_population.yaml": {
"uuid": dataset_uuid,
"table_name": "wb_health_population",
"database_uuid": db_uuid,
"schema": None,
"sql": None,
},
}
with patch(
"superset.commands.importers.v1.examples.get_example_default_schema",
return_value=None,
):
ImportExamplesCommand._import(configs)
mock_import_dataset.assert_called_once()
def test_normalize_dataset_schema_converts_main_to_null():
"""SQLite 'main' schema must be normalized to null in YAML content.
-15
View File
@@ -146,11 +146,6 @@ def test_query_dao_stop_query_not_found(
db.session.add(database)
db.session.add(query_obj)
# Committed (not just autoflushed) since QueryDAO.stop_query() is now
# wrapped in @transaction, which rolls back the session on the
# QueryNotFoundException raised below -- an uncommitted insert would be
# discarded along with it.
db.session.commit()
mocker.patch("superset.sql_lab.cancel_query", return_value=False)
@@ -233,11 +228,6 @@ def test_query_dao_stop_query_failed(
db.session.add(database)
db.session.add(query_obj)
# Committed (not just autoflushed) since QueryDAO.stop_query() is now
# wrapped in @transaction, which rolls back the session on the
# SupersetCancelQueryException raised below -- an uncommitted insert
# would be discarded along with it.
db.session.commit()
mocker.patch("superset.sql_lab.cancel_query", return_value=False)
@@ -324,11 +314,6 @@ def test_query_dao_stop_query_wrong_user(
db.session.add(database)
db.session.add(query_obj)
# Committed (not just autoflushed) since QueryDAO.stop_query() is now
# wrapped in @transaction, which rolls back the session on the
# QueryNotFoundException raised below -- an uncommitted insert would be
# discarded along with it.
db.session.commit()
# Simulate a different user (user 2) attempting to stop user 1's query
mocker.patch("superset.daos.query.get_user_id", return_value=2)
File diff suppressed because it is too large Load Diff
@@ -2897,3 +2897,125 @@ def test_xlsxwriter_preserves_nonfinite_group_rows() -> None:
assert [row[0] for row in list(workbook.active.values)[1:]] == [
row["team"] for row in rows
]
class _DetachAfterLookupChart:
"""Slice stand-in that starts attached and detaches on demand.
After ``detach()`` every attribute read raises ``DetachedInstanceError``,
which is what a real Slice does once the session has committed (expiring
its attributes) and then been torn down.
"""
_COLUMNS = {
"id": 9,
"slice_name": "Sales",
"viz_type": "table",
"datasource_id": 1,
"datasource_type": "table",
"params": None,
"query_context": (
'{"datasource": {"id": 1, "type": "table"},'
' "queries": [{"columns": ["country"], "metrics": ["count"],'
' "filters": [], "row_limit": 100}],'
' "result_format": "json", "result_type": "full"}'
),
}
def __init__(self) -> None:
object.__setattr__(self, "_detached", False)
def detach(self) -> None:
object.__setattr__(self, "_detached", True)
def __getattr__(self, name: str) -> Any:
from sqlalchemy.orm.exc import DetachedInstanceError
if object.__getattribute__(self, "_detached"):
raise DetachedInstanceError(
"Instance <Slice at 0x0> is not bound to a Session; "
f"attribute refresh operation cannot proceed (attribute: {name})"
)
try:
return self._COLUMNS[name]
except KeyError:
raise AttributeError(name) from None
@pytest.mark.parametrize("export_format", ["json", "csv", "excel"])
@pytest.mark.asyncio
async def test_chart_data_survives_chart_detached_after_lookup(
export_format: str, mcp_server: Any, mock_auth: Any
) -> None:
"""The tool must still return data when the Slice detaches after lookup.
Reproduces the reported failure: the session commits and is torn down
partway through the request, so every later read on the chart instance
raises DetachedInstanceError and the broad SQLAlchemyError handler returns
an internal-session error instead of chart data. The chart is detached at
the end of the lookup block, right after its last legitimate ORM use.
"""
from unittest.mock import patch
from fastmcp import Client
module = importlib.import_module("superset.mcp_service.chart.tool.get_chart_data")
chart = _DetachAfterLookupChart()
def _detach_at_end_of_lookup(instance: Any) -> None:
instance.detach()
return None
def fake_load(self: Any, data: dict[str, Any]) -> Any:
queries = [
SimpleNamespace(
filter=query.get("filters", []),
time_range=query.get("time_range"),
to_dict=lambda query=query: dict(query),
)
for query in data.get("queries", [])
]
return SimpleNamespace(queries=queries, form_data=data.get("form_data", {}))
class _Command:
def __init__(self, query_context: Any) -> None: ...
def validate(self) -> None: ...
def run(self) -> dict[str, Any]:
return {
"queries": [
{
"data": [{"country": "USA"}],
"colnames": ["country"],
"rowcount": 1,
}
]
}
with (
patch.object(module, "find_chart_by_identifier", return_value=chart),
patch.object(
module,
"validate_chart_dataset",
return_value=SimpleNamespace(is_valid=True, warnings=[], error=None),
),
patch.object(
module.guest_scope, "guest_dashboard_id", _detach_at_end_of_lookup
),
patch(
"superset.commands.chart.data.get_data_command.ChartDataCommand", _Command
),
patch("superset.charts.schemas.ChartDataQueryContextSchema.load", fake_load),
):
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_chart_data",
{"request": {"identifier": 9, "format": export_format}},
)
data = json.loads(result.content[0].text)
assert "error_type" not in data, (
f"format={export_format}: chart detached after lookup produced "
f"{data.get('error_type')}: {data.get('error')}"
)
assert data["chart_id"] == 9
assert data["chart_name"] == "Sales"
@@ -1,427 +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.
from typing import Any
import pytest
from superset.migrations.shared.migrate_viz import MigrateTableChart
from superset.utils import json
from tests.unit_tests.migrations.viz.utils import migrate_and_assert
SOURCE_FORM_DATA: dict[str, Any] = {
"datasource": "1__table",
"any_other_key": "untouched",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": ["count"],
"percent_metrics": [],
"all_columns": [],
"row_limit": 1000,
"order_desc": True,
"table_timestamp_format": "smart_date",
"page_length": 20,
"include_search": False,
"show_cell_bars": True,
"align_pn": False,
"color_pn": True,
"allow_rearrange_columns": True,
"allow_render_html": True,
}
TARGET_FORM_DATA: dict[str, Any] = {
"datasource": "1__table",
"any_other_key": "untouched",
"viz_type": "ag-grid-table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": ["count"],
"percent_metrics": [],
"all_columns": [],
"row_limit": 1000,
"order_desc": True,
"table_timestamp_format": "smart_date",
"page_length": 20,
"include_search": False,
"show_cell_bars": True,
"align_pn": False,
"color_pn": True,
"allow_rearrange_columns": True,
"allow_render_html": True,
"form_data_bak": SOURCE_FORM_DATA,
}
def test_migration() -> None:
migrate_and_assert(MigrateTableChart, SOURCE_FORM_DATA, TARGET_FORM_DATA)
def test_migration_without_datasource_key_in_params() -> None:
"""Some slices don't have a "datasource" key inside params, relying
instead on the datasource_id/datasource_type columns that key is
normally injected on the fly by Slice.form_data, which the migration
framework bypasses by reading params directly. upgrade_slice must
synthesize the same "id__type" string from those columns, or
_build_query() raises KeyError('datasource') for charts missing it."""
from superset.models.slice import Slice
from superset.utils import json
source: dict[str, Any] = {
k: v for k, v in SOURCE_FORM_DATA.items() if k != "datasource"
}
dumped_form_data: str = json.dumps(source)
slc: Slice = Slice(
viz_type=MigrateTableChart.source_viz_type,
datasource_id=1,
datasource_type="table",
params=dumped_form_data,
query_context=f'{{"form_data": {dumped_form_data}, "queries": []}}',
)
MigrateTableChart.upgrade_slice(slc)
assert slc.viz_type == MigrateTableChart.target_viz_type
new_form_data: dict[str, Any] = json.loads(slc.params)
assert new_form_data["datasource"] == "1__table"
def test_migration_preserves_semantic_view_datasource_type() -> None:
"""DatasourceKey used to only recognize "query" as a non-table type,
defaulting everything else (e.g. "semantic_view") to "table". Slices
with no pre-existing query_context get one built from scratch via
DatasourceKey, which must keep the original type rather than silently
repointing the chart at a table with the same id."""
from superset.models.slice import Slice
source: dict[str, Any] = {**SOURCE_FORM_DATA, "datasource": "7__semantic_view"}
dumped_form_data: str = json.dumps(source)
slc: Slice = Slice(
viz_type=MigrateTableChart.source_viz_type,
datasource_type="table",
params=dumped_form_data,
query_context=None,
)
MigrateTableChart.upgrade_slice(slc)
new_query_context: dict[str, Any] = json.loads(slc.query_context)
assert new_query_context["datasource"] == {"id": 7, "type": "semantic_view"}
def test_migration_raw_mode() -> None:
source: dict[str, Any] = {
**SOURCE_FORM_DATA,
"query_mode": "raw",
"groupby": [],
"metrics": [],
"all_columns": ["name", "sales"],
}
target: dict[str, Any] = {
**TARGET_FORM_DATA,
"query_mode": "raw",
"groupby": [],
"metrics": [],
"all_columns": ["name", "sales"],
"form_data_bak": source,
}
migrate_and_assert(MigrateTableChart, source, target)
def test_migration_page_length_all_is_preserved() -> None:
"""page_length: 0 means 'All rows' (no pagination) in both v1 and v2,
so it should carry over unchanged rather than being rewritten to a
paginated value."""
source: dict[str, Any] = {**SOURCE_FORM_DATA, "page_length": 0}
target: dict[str, Any] = {
**TARGET_FORM_DATA,
"page_length": 0,
"form_data_bak": source,
}
migrate_and_assert(MigrateTableChart, source, target)
def test_migration_page_length_all_as_string_is_preserved() -> None:
source: dict[str, Any] = {**SOURCE_FORM_DATA, "page_length": "0"}
target: dict[str, Any] = {
**TARGET_FORM_DATA,
"page_length": "0",
"form_data_bak": source,
}
migrate_and_assert(MigrateTableChart, source, target)
def test_migration_percent_metric_calculation_all_records_carries_over() -> None:
"""percent_metric_calculation now has a v2 equivalent (control panel +
buildQuery all_records branch), so it should carry over unchanged."""
source: dict[str, Any] = {
**SOURCE_FORM_DATA,
"percent_metrics": ["sum__sales"],
"percent_metric_calculation": "all_records",
}
target: dict[str, Any] = {
**TARGET_FORM_DATA,
"percent_metrics": ["sum__sales"],
"percent_metric_calculation": "all_records",
"form_data_bak": source,
}
migrate_and_assert(MigrateTableChart, source, target)
def test_migration_entire_row_conditional_formatting_carries_over() -> None:
"""'entire row' conditional formatting now has a v2 equivalent (control
panel + getCellStyle.ts), so it should carry over unchanged."""
conditional_formatting: list[dict[str, Any]] = [
{
"operator": ">",
"targetValue": 0,
"colorScheme": "#ACE1C4",
"column": "sales",
"columnFormatting": "ENTIRE_ROW",
}
]
source: dict[str, Any] = {
**SOURCE_FORM_DATA,
"conditional_formatting": conditional_formatting,
}
target: dict[str, Any] = {
**TARGET_FORM_DATA,
"conditional_formatting": conditional_formatting,
"form_data_bak": source,
}
migrate_and_assert(MigrateTableChart, source, target)
def test_migration_strips_matrixify_keys() -> None:
"""Matrixify has no v2 control panel surface at all. Table charts can't
reach the Matrixify tab through today's Explore UI, but a chart saved
during the ~5 months before that exclusion was added (or edited directly
via the API) can still carry matrixify_* keys. Those keys should be
dropped and the migration should proceed normally rather than skipping
the slice."""
source: dict[str, Any] = {
**SOURCE_FORM_DATA,
"matrixify_enable": True,
"matrixify_mode_rows": "dimensions",
"matrixify_dimension_rows": "category",
}
target: dict[str, Any] = {**TARGET_FORM_DATA, "form_data_bak": source}
migrate_and_assert(MigrateTableChart, source, target)
def test_migration_defaults_omitted_allow_rearrange_columns_to_false() -> None:
"""v1's control (and TableChart) default allow_rearrange_columns to
False, and charts saved before that control existed may omit the key
entirely. v2's transformProps.ts instead defaults a missing key to
True (its own pre-existing behavior for v2-native charts), so the
migration must materialize v1's False default explicitly rather than
letting a migrated chart pick up v2's unrelated default."""
source: dict[str, Any] = {
k: v for k, v in SOURCE_FORM_DATA.items() if k != "allow_rearrange_columns"
}
target: dict[str, Any] = {
**{k: v for k, v in TARGET_FORM_DATA.items() if k != "form_data_bak"},
"allow_rearrange_columns": False,
"form_data_bak": source,
}
migrate_and_assert(MigrateTableChart, source, target)
@pytest.mark.parametrize(
"auto_currency_form_data",
[
{
**SOURCE_FORM_DATA,
"column_config": {
"sales": {
"currencyFormat": {"symbol": "AUTO", "symbolPosition": "prefix"}
}
},
},
],
)
def test_migration_auto_currency_carries_over(
auto_currency_form_data: dict[str, Any],
) -> None:
"""AUTO currency resolution now has a v2 equivalent (transformProps.ts +
formatValue.ts), so column_config carries over unchanged."""
target: dict[str, Any] = {
**TARGET_FORM_DATA,
"column_config": auto_currency_form_data["column_config"],
"form_data_bak": auto_currency_form_data,
}
migrate_and_assert(MigrateTableChart, auto_currency_form_data, target)
def test_build_query_extra_form_data_time_compare_overrides_chart_level() -> None:
"""A dashboard-level time_compare override (extra_form_data.time_compare)
must replace the chart-level time_compare shifts in the migrated
query_context, mirroring buildQuery.ts's override precedence."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": ["count"],
"time_compare": ["1 year ago"],
"extra_form_data": {"time_compare": "4 weeks ago"},
}
main_query = MigrateTableChart(json.dumps(form_data))._build_query()["queries"][0]
assert main_query["time_offsets"] == ["4 weeks ago"]
def test_build_query_extra_form_data_time_grain_sqla_overrides_chart_level() -> None:
"""A dashboard-level time_grain_sqla override (extra_form_data) must be
used to decide temporal-column promotion, mirroring buildQuery.ts's
`extra_form_data?.time_grain_sqla || formData.time_grain_sqla`
precedence, even when the chart has no top-level time_grain_sqla set."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["ds", "name"],
"metrics": ["count"],
"temporal_columns_lookup": {"ds": True},
"extra_form_data": {"time_grain_sqla": "P1D"},
}
main_query = MigrateTableChart(json.dumps(form_data))._build_query()["queries"][0]
assert main_query["columns"][0] == {
"timeGrain": "P1D",
"columnType": "BASE_AXIS",
"sqlExpression": "ds",
"label": "ds",
"expressionType": "SQL",
}
def test_build_query_extra_form_data_time_compare_override_not_double_nested() -> None:
"""extra_form_data.time_compare is typed as a single string on the
frontend, but self.data comes from deserialized JSON with no runtime
type guarantee. If a malformed/legacy record already carries it as a
list, the override must not double-nest it into [[...]]."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": ["count"],
"extra_form_data": {"time_compare": ["4 weeks ago"]},
}
main_query = MigrateTableChart(json.dumps(form_data))._build_query()["queries"][0]
assert main_query["time_offsets"] == ["4 weeks ago"]
def test_build_query_percent_metric_expands_with_time_comparison() -> None:
"""When time comparison is enabled, percent-metric contribution columns
must include the time-offset-suffixed labels (e.g. "metric__1 year
ago"), mirroring buildQuery.ts's addComparisonPercentMetrics, so shifted
percent columns are computed/renamed rather than only the base metric."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": ["sum__sales"],
"percent_metrics": ["sum__sales"],
"time_compare": ["1 year ago"],
"comparison_type": "values",
}
main_query = MigrateTableChart(json.dumps(form_data))._build_query()["queries"][0]
contribution = next(
pp for pp in main_query["post_processing"] if pp["operation"] == "contribution"
)
assert contribution["options"]["columns"] == [
"sum__sales",
"sum__sales__1 year ago",
]
assert contribution["options"]["rename_columns"] == [
"%sum__sales",
"%sum__sales__1 year ago",
]
def test_build_query_stale_time_compare_without_comparison_type_is_ignored() -> None:
"""time_compare shifts require a valid comparison_type, mirroring
isTimeComparison() in both Table buildQuery implementations. A chart
with a stale time_compare left over from a prior configuration but no
(or an invalid) comparison_type should not request offset queries the
runtime chart itself would never send."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": ["count"],
"time_compare": ["1 year ago"],
}
main_query = MigrateTableChart(json.dumps(form_data))._build_query()["queries"][0]
assert main_query["time_offsets"] == []
def test_build_query_totals_query_applies_totals_aggregate() -> None:
"""Both runtime buildQuery.ts implementations call
getTotalsMetrics(metrics, toTotalsAggregate(formData.totals_aggregate))
when building the totals query. A chart saved with show_totals=True,
totals_aggregate='AVG', and a SIMPLE SUM(...) metric must therefore get
a persisted totals query that computes AVG, not the main query's SUM."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "aggregate",
"groupby": ["name"],
"metrics": [
{
"expressionType": "SIMPLE",
"column": {"column_name": "sales"},
"aggregate": "SUM",
"label": "sum__sales",
}
],
"show_totals": True,
"totals_aggregate": "AVG",
}
queries = MigrateTableChart(json.dumps(form_data))._build_query()["queries"]
totals_query = next(q for q in queries if q["columns"] == [])
assert totals_query["metrics"] == [
{
"expressionType": "SIMPLE",
"column": {"column_name": "sales"},
"aggregate": "AVG",
"label": "sum__sales",
}
]
def test_build_query_raw_mode_stale_time_compare_is_ignored() -> None:
"""A chart switched to raw mode retains its old time_compare/
comparison_type controls (hidden rather than cleared) while metrics is
cleared to []. With no metrics to offset, isTimeComparison()'s
get_metric_offsets_map() is empty, so time_offsets should stay empty
rather than requesting shifts a raw-mode chart never sends."""
form_data: dict[str, Any] = {
"datasource": "1__table",
"viz_type": "table",
"query_mode": "raw",
"groupby": [],
"metrics": [],
"all_columns": ["name", "sales"],
"time_compare": ["1 year ago"],
"comparison_type": "values",
}
main_query = MigrateTableChart(json.dumps(form_data))._build_query()["queries"][0]
assert main_query["time_offsets"] == []
+11 -60
View File
@@ -29,8 +29,6 @@ from superset.jinja_context import JinjaTemplateProcessor
from superset.sql.parse import (
_check_script_length,
_count_weighted_table_references,
_find_last_token_node,
_get_select_trailing_child,
BaseSQLStatement,
count_referenced_tables,
CTASMethod,
@@ -1360,12 +1358,20 @@ LIMIT 100
assert "increase timeout for large scans" in formatted[hint_end:]
@pytest.mark.xfail(
reason=(
"#38189 is not fully fixed: a `;`-terminated statement still hits "
"the comment-relocation branch and corrupts the hint block. Only "
"the no-semicolon form from the original repro was fixed."
),
strict=True,
)
def test_sqlscript_format_preserves_optimizer_hint_block_with_semicolon() -> None:
"""
Same as `test_sqlscript_format_preserves_optimizer_hint_block`, but with
a terminating `;` on the statement -- verifies #38189 fix so that trailing
`--` comments land after the statement rather than injected into the
`/*+ SET_VAR(...) */` hint block for StarRocks/MySQL-style engines.
a terminating `;` on the statement -- this still reproduces #38189: the
trailing `--` comment gets injected inside the `/*+ SET_VAR(...) */`
hint block, corrupting it for StarRocks/MySQL-style engines.
"""
sql = """SELECT /*+ SET_VAR(query_timeout = 3000) */ col1, col2
FROM my_table
@@ -1382,61 +1388,6 @@ LIMIT 100;
assert "increase timeout for large scans" in formatted[hint_end:]
def test_sqlscript_format_preserves_optimizer_hint_with_cte_and_semicolon() -> None:
"""
Ensure optimizer hints with CTEs and trailing comments survive formatting intact.
"""
sql = """WITH cte AS (SELECT 1 AS id)
SELECT /*+ SET_VAR(query_timeout = 3000) */ id
FROM cte
WHERE id = 1;
-- trailing explanation comment"""
statement = SQLScript(sql, "mysql").statements[0]
formatted = statement.format()
hint = "/*+ SET_VAR(query_timeout = 3000) */"
assert hint in formatted
assert "SET_VAR(query_timeout /*" not in formatted
hint_end = formatted.index(hint) + len(hint)
assert "trailing explanation comment" in formatted[hint_end:]
def test_find_last_token_node_branches() -> None:
"""
Directly test all branches of _find_last_token_node and _get_select_trailing_child.
"""
# 1. Empty select returns None from _get_select_trailing_child
# and falls back to node
empty_select = exp.Select()
assert _get_select_trailing_child(empty_select) is None
assert _find_last_token_node(empty_select) is empty_select
# 2. Select with list clause vs single Expression clause
select_with_exprs = exp.Select(expressions=[exp.Literal.number(1)])
assert _get_select_trailing_child(select_with_exprs) == exp.Literal.number(1)
select_with_where = exp.Select(where=exp.Where(this=exp.Literal.number(2)))
assert _get_select_trailing_child(select_with_where) == exp.Literal.number(2)
# 3. Node with hint or comments in args is skipped during child traversal
col_with_comment = exp.Column(this="foo", comments=["my comment"])
assert _find_last_token_node(col_with_comment) is not None
table_with_hint = exp.Table(
this="bar", hint=exp.Hint(expressions=[exp.var("HINT")])
)
assert _find_last_token_node(table_with_hint) is not None
# 4. Non-select node with list of expressions
tup = exp.Tuple(expressions=[exp.Literal.number(1), exp.Literal.number(2)])
assert _find_last_token_node(tup) == exp.Literal.number(2)
# 5. Leaf node with no children returns itself
lit = exp.Literal.number(42)
assert _find_last_token_node(lit) is lit
@pytest.mark.parametrize(
"sql, engine, expected",
[
-5
View File
@@ -483,11 +483,6 @@ def test_get_sql_results_oauth2(mocker: MockerFixture, app) -> None:
mocker.patch("superset.daos.key_value.KeyValueDAO.delete_expired_entries")
mocker.patch("superset.daos.key_value.KeyValueDAO.create_entry")
mocker.patch("superset.db_engine_specs.base.db.session.commit")
# handle_query_error() refreshes `query` from the DB to check for a
# concurrently-committed STOPPED status before overwriting it with
# FAILED; `query` here is a MagicMock, not a real persistent ORM
# instance, so the real refresh() would error introspecting it.
mocker.patch("superset.sql_lab.db.session.refresh", return_value=None)
g = mocker.patch("superset.db_engine_specs.base.g")
g.user = mocker.MagicMock()