Compare commits

..
Author SHA1 Message Date
sadpandajoeandClaude Sonnet 5 7f362a84b8 fix(explore): stop dashboard permalink key from leaking into chart URL
RESERVED_CHART_URL_PARAMS was missing permalink_key while the analogous
RESERVED_DASHBOARD_URL_PARAMS already excluded it. This asymmetry let a
dashboard permalink's permalink_key, merged into a chart's form_data via
a shared cache keyed only by sliceId, get copied into the chart's own
Explore URL when opened from a dashboard. On refresh, Explore forwarded
that dashboard-salted key to the explore permalink resolver, which fails
key decoding against the wrong salt and falls back to a stub datasource,
producing the "missing datasource" error.

Add permalink_key to RESERVED_CHART_URL_PARAMS, mirroring the pattern
already used correctly on the dashboard side and in FilterBar's
EXCLUDED_URL_PARAMS. As an accepted side effect, this also makes
Explore's own permalink key (/explore/p/<key>/) drop out of the URL
after refresh instead of staying sticky, matching FilterBar's existing
behavior for dashboards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 05:06:22 +00:00
24 changed files with 85 additions and 735 deletions
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -93,7 +93,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.11.0",
"globals": "^17.10.0",
"oxfmt": "^0.63.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
+4 -4
View File
@@ -9174,10 +9174,10 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
globals@^17.10.0:
version "17.10.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.10.0.tgz#f9dbd847ae99e236f98b13095e2426ac3b25a45c"
integrity sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==
globalthis@^1.0.4:
version "1.0.4"
+15 -6
View File
@@ -99,7 +99,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"google-auth-library": "^11.0.1",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -20612,7 +20612,7 @@
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/expect-playwright/-/expect-playwright-0.8.0.tgz",
"integrity": "sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg==",
"deprecated": "⚠️ The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.",
"deprecated": "\u26a0\ufe0f The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.",
"dev": true,
"license": "MIT"
},
@@ -22783,9 +22783,9 @@
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.1.tgz",
"integrity": "sha512-ZqfaYduu9ASUaFuUk5dF9g9QvufdhhSj7jFiEnCrTQcH57sFPKYetM0iU4dcKkQk6CqC1xpSrVr5uQ9NhqjNOg==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -26023,7 +26023,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/jest-process-manager/-/jest-process-manager-0.4.0.tgz",
"integrity": "sha512-80Y6snDyb0p8GG83pDxGI/kQzwVTkCxc7ep5FPe/F6JYdvRDhwr6RzRmPSP7SEwuLhxo80lBS/NqOdUIbHIfhw==",
"deprecated": "⚠️ The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.",
"deprecated": "\u26a0\ufe0f The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -43073,6 +43073,15 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
+1 -1
View File
@@ -176,7 +176,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.2",
"google-auth-library": "^11.0.1",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
+31
View File
@@ -0,0 +1,31 @@
/**
* 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 {
URL_PARAMS,
RESERVED_CHART_URL_PARAMS,
RESERVED_DASHBOARD_URL_PARAMS,
} from 'src/constants';
test('permalinkKey is reserved on both the chart and dashboard URL param lists', () => {
// Dashboard and explore permalinks resolve against different backend
// KV resources/salts, so a key from one must never leak into the other's
// URL via the reserved-params passthrough logic.
expect(RESERVED_DASHBOARD_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
expect(RESERVED_CHART_URL_PARAMS).toContain(URL_PARAMS.permalinkKey.name);
});
+1
View File
@@ -123,6 +123,7 @@ export const RESERVED_CHART_URL_PARAMS: string[] = [
URL_PARAMS.datasourceId.name,
URL_PARAMS.datasourceType.name,
URL_PARAMS.datasetId.name,
URL_PARAMS.permalinkKey.name,
URL_PARAMS.versionHistory.name,
];
export const RESERVED_DASHBOARD_URL_PARAMS: string[] = [
+4 -4
View File
@@ -28,7 +28,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.11.0",
"globals": "^17.10.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
@@ -2053,9 +2053,9 @@
}
},
"node_modules/globals": {
"version": "17.11.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
"version": "17.10.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.10.0.tgz",
"integrity": "sha512-V0kztuWST2k8A/VbxAY8+L+7+Rgo3fyA24IHRLrZp7HOzJjV0gHSaZUjK9lpP/IrBSNite2tZ1prhRkinRu1CA==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -36,7 +36,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.11.0",
"globals": "^17.10.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
+2 -81
View File
@@ -17,7 +17,6 @@
# pylint: disable=invalid-name
from __future__ import annotations
import inspect
import logging
from datetime import datetime
from pprint import pformat
@@ -206,86 +205,8 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def _set_post_processing(
self, post_processing: list[dict[str, Any] | None] | None
) -> None:
self.post_processing = [
self._drop_unsupported_options(post_proc)
for post_proc in post_processing or []
if post_proc
]
@staticmethod
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
"""
Drop options that the post-processing operation no longer accepts.
A chart's ``query_context`` is written when the chart is saved and is
never rewritten afterwards, while Explore rebuilds the query from
``form_data`` at every render. A chart saved by an older version of
Superset can therefore reference an option that has since been removed
from the operation. ``exec_post_processing`` passes the stored options
as keyword arguments, so that option raises a bare ``TypeError`` on
every path that replays the stored ``query_context`` -- the chart data
endpoint, alerts and reports, thumbnails, CSV export -- while the same
chart still renders correctly in Explore.
Comparing against the signature avoids a hard-coded list of removed
option names, which would need extending at each release.
"""
operation = post_proc.get("operation")
function = (
getattr(pandas_postprocessing, operation, None)
if isinstance(operation, str)
else None
)
if function is None:
# A missing or unknown operation is left untouched, so that
# exec_post_processing reports it as InvalidPostProcessingError.
return post_proc
parameters = inspect.signature(function).parameters
if any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
):
return post_proc
# `exec_post_processing` calls the operation as `operation(df, **options)`,
# so an option can only reach a parameter that a caller may fill by
# keyword. That excludes the first parameter, which receives the
# DataFrame positionally, and any positional-only or `*args` parameter.
keyword_parameters = {
name
for position, (name, parameter) in enumerate(parameters.items())
if position > 0
and parameter.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
}
options = post_proc.get("options") or {}
unsupported = {key for key in options if key not in keyword_parameters}
if not unsupported:
return post_proc
# Logged at info: a chart saved before the option was removed hits this
# on every render, so a warning would repeat for as long as the chart
# is not resaved, without anything new to report.
logger.info(
"Dropping unsupported option(s) %s of post-processing operation "
"`%s`. The chart's stored query_context predates the current "
"signature of that operation.",
sorted(unsupported),
operation,
)
return {
**post_proc,
"options": {
key: value
for key, value in options.items()
if key in keyword_parameters
},
}
post_processing = post_processing or []
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
def _init_series_columns(
self,
+7 -18
View File
@@ -1234,11 +1234,7 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
expression = self._validate_stored_expression(expression)
col = literal_column(expression, type_=type_)
else:
identifier = db_engine_spec.prepare_identifier(
cast(str, self.column_name),
normalize_columns=bool(getattr(self.table, "normalize_columns", False)),
)
col = column(identifier, type_=type_)
col = column(self.column_name, type_=type_)
col = self.database.make_sqla_column_compatible(col, label)
return col
@@ -1264,15 +1260,12 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
pdf = self.python_date_format
is_epoch = pdf in ("epoch_s", "epoch_ms")
db_engine_spec = self.db_engine_spec
column_spec = db_engine_spec.get_column_spec(self.type, db_extra=self.db_extra)
column_spec = self.db_engine_spec.get_column_spec(
self.type, db_extra=self.db_extra
)
type_ = column_spec.sqla_type if column_spec else DateTime
if not self.expression and not time_grain and not is_epoch:
identifier = db_engine_spec.prepare_identifier(
cast(str, self.column_name),
normalize_columns=bool(getattr(self.table, "normalize_columns", False)),
)
sqla_col = column(identifier, type_=type_)
sqla_col = column(self.column_name, type_=type_)
return self.database.make_sqla_column_compatible(sqla_col, label)
if expression := self.expression:
if template_processor:
@@ -1297,12 +1290,8 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
expression = self._validate_stored_expression(expression)
col = literal_column(expression, type_=type_)
else:
identifier = db_engine_spec.prepare_identifier(
cast(str, self.column_name),
normalize_columns=bool(getattr(self.table, "normalize_columns", False)),
)
col = column(identifier, type_=type_)
time_expr = db_engine_spec.get_timestamp_expr(col, pdf, time_grain)
col = column(self.column_name, type_=type_)
time_expr = self.db_engine_spec.get_timestamp_expr(col, pdf, time_grain)
return self.database.make_sqla_column_compatible(time_expr, label)
@property
-13
View File
@@ -2918,19 +2918,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return name
@classmethod
def prepare_identifier(
cls,
name: str,
normalize_columns: bool = False,
) -> str:
"""
Prepare a physical identifier for SQLAlchemy column construction.
The default preserves SQLAlchemy's automatic identifier-quoting behavior.
"""
return name
@classmethod
def quote_table(cls, table: Table, dialect: Dialect) -> str:
"""
-12
View File
@@ -33,7 +33,6 @@ from marshmallow import fields, Schema
from sqlalchemy import text, types
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.sql import quoted_name
from superset.constants import TimeGrain
from superset.databases.utils import make_url_safe
@@ -99,17 +98,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
supports_catalog = supports_dynamic_catalog = supports_cross_catalog_queries = True
supports_grouping_sets = True
@classmethod
def prepare_identifier(
cls,
name: str,
normalize_columns: bool = False,
) -> str:
"""Preserve exact-case physical identifiers when columns are not normalized."""
if normalize_columns:
return name
return quoted_name(name, quote=True)
metadata = {
"description": "Snowflake is a cloud-native data warehouse.",
"logo": "snowflake.svg",
+1 -5
View File
@@ -3959,11 +3959,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
expression = self._validate_stored_expression(expression)
col = literal_column(expression, type_=type_)
else:
identifier = db_engine_spec.prepare_identifier(
cast(str, tbl_column.column_name),
normalize_columns=bool(self.normalize_columns),
)
col = sa.column(identifier, type_=type_)
col = sa.column(tbl_column.column_name, type_=type_)
col = self.make_sqla_column_compatible(col, label)
return col
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from collections.abc import Sequence
from functools import partial, wraps
from functools import partial
from typing import Any, Callable
import numpy as np
@@ -122,10 +122,6 @@ def scalar_to_sequence(val: Any) -> Sequence[str]:
def validate_column_args(*argnames: str) -> Callable[..., Any]:
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
# `wraps` keeps `func` reachable through `__wrapped__`, so that
# `inspect.signature` reports the parameters of the decorated operation
# rather than the `(df, **options)` of this wrapper.
@wraps(func)
def wrapped(df: DataFrame, **options: Any) -> Any:
if _is_multi_index_on_columns(df):
# MultiIndex column validate first level
+8 -60
View File
@@ -159,49 +159,16 @@ CHART_ERROR_OR_EMPTY_SELECTOR = (
f"{ALERT_SELECTOR}, {EMPTY_SELECTOR}, {MISSING_CHART_SELECTOR}"
)
# Runtime contract with the dashboard frontend. Dispatching this window event
# forces every DashboardVirtualization row to render regardless of whether it
# intersects the headless viewport, mirroring the client-side "Download as
# Image/PDF" path (see FORCE_IN_VIEW_EVENT in
# superset-frontend/src/dashboard/constants.ts and forceLoadAllCharts in
# superset-frontend/src/utils/downloadUtils.ts). The non-tiled report capture
# takes a single full-page screenshot that includes below-the-fold holders, so
# those holders must be forced to render before the readiness wait -- otherwise
# a virtualized (or still-loading) off-screen holder is captured blank. A plain
# Event with no `detail.rowIds` means "force every row", matching the frontend's
# single-pass branch.
FORCE_ALL_CHART_HOLDERS_IN_VIEW_EVENT = "superset-force-all-in-view"
FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS = (
f"() => window.dispatchEvent(new Event('{FORCE_ALL_CHART_HOLDERS_IN_VIEW_EVENT}'))"
)
def _unready_chart_holders_js_body(*, viewport_only: bool) -> str:
"""Return the shared holder-readiness scan body.
A holder is ready only after a terminal marker appears and its loading
marker disappears. When ``viewport_only`` is True the scan skips holders
that do not intersect the current viewport: correct for the tiled path,
which scrolls every region into view before capturing it, and for
thumbnails, which only ever capture the viewport. The non-tiled *report*
capture takes a single full-page screenshot that includes below-the-fold
holders, so it must scan every mounted holder (``viewport_only=False``) --
otherwise an off-screen holder that never rendered is captured blank and
silently delivered as a Success.
"""
viewport_skip = (
"""
const r = holder.getBoundingClientRect();
if (!(r.top < window.innerHeight && r.bottom > 0)) {
continue;
}"""
if viewport_only
else ""
)
return f"""
# Shared body for holder readiness and timeout diagnostics. A holder is ready
# only after a terminal marker appears and its loading marker disappears.
UNREADY_CHART_HOLDERS_JS_BODY = f"""
const holders = document.querySelectorAll('{CHART_HOLDER_SELECTOR}');
const unready = [];
for (const holder of holders) {{{viewport_skip}
for (const holder of holders) {{
const r = holder.getBoundingClientRect();
if (!(r.top < window.innerHeight && r.bottom > 0)) {{
continue;
}}
const hasSliceContainer = holder.querySelector(
'{SLICE_CONTAINER_SELECTOR}'
) !== null;
@@ -239,13 +206,6 @@ def _unready_chart_holders_js_body(*, viewport_only: bool) -> str:
}}
"""
# Viewport-scoped scan (tiled path + thumbnails).
UNREADY_CHART_HOLDERS_JS_BODY = _unready_chart_holders_js_body(viewport_only=True)
# Full-dashboard scan (non-tiled report capture, which screenshots the whole
# element in one shot and therefore cannot ignore below-the-fold holders).
UNREADY_ALL_CHART_HOLDERS_JS_BODY = _unready_chart_holders_js_body(viewport_only=False)
# Diagnostic query for every chart holder, including terminal and virtualized
# states. It interpolates the same selector constants as the predicates.
FIND_CHART_HOLDER_STATES_JS = f"""
@@ -296,24 +256,12 @@ REPORT_CHART_HOLDERS_READY_JS = (
f"() => {{ {UNREADY_CHART_HOLDERS_JS_BODY} "
"return holders.length > 0 && unready.length === 0; }"
)
# Report readiness for the non-tiled full-page capture: every mounted holder --
# including below-the-fold ones -- must be terminally rendered. Off-screen
# holders are forced to render first (FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS); if any
# still fails to render within budget the wait times out and the report fails
# loudly rather than shipping a blank/partial screenshot as a Success.
REPORT_ALL_CHART_HOLDERS_READY_JS = (
f"() => {{ {UNREADY_ALL_CHART_HOLDERS_JS_BODY} "
"return holders.length > 0 && unready.length === 0; }"
)
CHART_HOLDERS_MOUNTED_JS = (
f"() => document.querySelectorAll('{CHART_HOLDER_SELECTOR}').length > 0"
)
FIND_UNREADY_CHART_HOLDERS_JS = (
f"() => {{ {UNREADY_CHART_HOLDERS_JS_BODY} return unready; }}"
)
FIND_ALL_UNREADY_CHART_HOLDERS_JS = (
f"() => {{ {UNREADY_ALL_CHART_HOLDERS_JS_BODY} return unready; }}"
)
# A chart capture has one target rather than dashboard holders, but needs the
# same positive terminal-state guarantee and loading exclusion.
+5 -43
View File
@@ -34,10 +34,8 @@ from superset.utils.screenshot_utils import (
CHART_CONTAINER_READY_JS,
CHART_CONTAINER_STATE_JS,
CHART_HOLDERS_READY_JS,
FIND_ALL_UNREADY_CHART_HOLDERS_JS,
FIND_CHART_HOLDER_STATES_JS,
FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS,
REPORT_ALL_CHART_HOLDERS_READY_JS,
REPORT_CHART_HOLDERS_READY_JS,
resolve_screenshot_task_budget_seconds,
ScreenshotTaskBudgetExceededError,
take_tiled_screenshot,
@@ -384,19 +382,7 @@ class WebDriverPlaywright(WebDriverProxy):
if element_name == "chart-container":
readiness_predicate = CHART_CONTAINER_READY_JS
elif report_execution_context:
# This non-tiled path captures the whole element in one shot
# (`_get_screenshot` uses `full_page=True` / `element.screenshot()`),
# so below-the-fold holders end up in the image. Force every
# virtualized row to render up front -- mirroring the client-side
# "Download as Image/PDF" path -- and then require *all* mounted
# holders (not just the viewport-visible ones) to reach a terminal
# state. If an off-screen holder never renders, the wait times out
# and the report fails loudly instead of silently delivering a
# blank/partial screenshot as a Success. The tiled path keeps the
# viewport-scoped predicate because it scrolls each region into view
# before capturing it.
page.evaluate(FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS)
readiness_predicate = REPORT_ALL_CHART_HOLDERS_READY_JS
readiness_predicate = REPORT_CHART_HOLDERS_READY_JS
else:
# Preserve the thumbnail behavior introduced by #42253. The
# stricter zero-holder gate is report-specific because an empty
@@ -444,16 +430,6 @@ class WebDriverPlaywright(WebDriverProxy):
ready_holders = sum(
holder.get("state") in ready_states for holder in chart_holder_states
)
# `FIND_CHART_HOLDER_STATES_JS` short-circuits off-screen holders to
# "virtualized" (counted as ready above), so on the report path -- a
# full-page capture that includes below-the-fold holders -- the real
# culprits (off-screen holders that never rendered) would be hidden.
# Surface them explicitly using the non-viewport-scoped scan.
below_fold_unready = (
page.evaluate(FIND_ALL_UNREADY_CHART_HOLDERS_JS)
if report_execution_context
else unready_chart_holders
)
deadline_elapsed = deadline.elapsed_seconds if deadline else elapsed
deadline_remaining = (
deadline.remaining_seconds if deadline else remaining_budget
@@ -462,8 +438,7 @@ class WebDriverPlaywright(WebDriverProxy):
"report_readiness_terminal url=%s expected_holders=%s "
"mounted_holders=%s ready_holders=%s elapsed_seconds=%.2f "
"remaining_seconds=%s effective_wait_seconds=%.2f%s "
"terminal_reason=readiness_timeout unready_holders=%s "
"all_unready_holders=%s states=%s; "
"terminal_reason=readiness_timeout unready_holders=%s states=%s; "
"aborting before capture or delivery",
url,
expected_holders,
@@ -478,7 +453,6 @@ class WebDriverPlaywright(WebDriverProxy):
effective_load_wait,
context_suffix,
unready_chart_holders,
below_fold_unready,
chart_holder_states,
)
raise
@@ -773,21 +747,9 @@ class WebDriverPlaywright(WebDriverProxy):
context_suffix,
)
# Use tiled screenshots for large dashboards. For scheduled
# reports a likely-large dashboard whose measured height is
# at or below a single tile is almost always mid-layout
# (charts still virtualized/collapsed at measurement time),
# not genuinely short -- a 52-chart dashboard is never really
# <one viewport tall. Routing it to the single-shot,
# full-page non-tiled capture risks shipping a windowed
# partial render. Prefer the tiled path, which scrolls every
# region into view and waits per tile; worst case it is a
# single tile. The tiled decision for thumbnails is
# unchanged.
# Use tiled screenshots for large dashboards
use_tiled = likely_large_dashboard and (
height_unknown
or dashboard_height > tile_height
or report_execution_context is not None
height_unknown or dashboard_height > tile_height
)
if use_tiled:
@@ -21,7 +21,6 @@ import pandas as pd
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import create_engine
from sqlalchemy.dialects import sqlite
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.session import Session
@@ -50,70 +49,6 @@ from superset.superset_typing import QueryObjectDict
from superset.utils import json
def test_get_sqla_col_quotes_snowflake_case_sensitive_identifier(
mocker: MockerFixture,
) -> None:
"""Snowflake physical columns retain their exact reflected case in generated SQL."""
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
mocker.patch.object(
Database,
"get_db_engine_spec",
return_value=SnowflakeEngineSpec,
)
table = SqlaTable(
table_name="bug_test",
database=database,
normalize_columns=False,
)
tbl_column = TableColumn(column_name="id", type="INTEGER", table=table)
rendered = str(
tbl_column.get_sqla_col().compile(
dialect=sqlite.dialect(),
compile_kwargs={"literal_binds": True},
)
)
assert rendered == '"id"'
@pytest.mark.parametrize("time_grain", [None, "P1D"])
def test_get_timestamp_expression_quotes_snowflake_case_sensitive_identifier(
mocker: MockerFixture,
time_grain: str | None,
) -> None:
"""Snowflake timestamp paths quote exact-case physical columns."""
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
mocker.patch.object(
Database,
"get_db_engine_spec",
return_value=SnowflakeEngineSpec,
)
table = SqlaTable(
table_name="bug_test",
database=database,
normalize_columns=False,
)
tbl_column = TableColumn(
column_name="created_at",
type="TIMESTAMP",
table=table,
)
rendered = str(
tbl_column.get_timestamp_expression(time_grain=time_grain).compile(
dialect=sqlite.dialect(),
compile_kwargs={"literal_binds": True},
)
)
assert '"created_at"' in rendered
def test_query_bubbles_errors(mocker: MockerFixture) -> None:
"""
Test that the `query` method bubbles exceptions correctly.
@@ -291,13 +291,6 @@ def test_get_default_catalog(mocker: MockerFixture) -> None:
assert BaseEngineSpec.get_default_catalog(database) is None
def test_prepare_identifier_returns_name_unchanged() -> None:
name = "physical_column"
assert BaseEngineSpec.prepare_identifier(name, normalize_columns=False) is name
assert BaseEngineSpec.prepare_identifier(name, normalize_columns=True) is name
def test_quote_table() -> None:
"""
Test the `quote_table` function.
@@ -24,7 +24,6 @@ from unittest import mock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine.url import make_url
from sqlalchemy.sql import quoted_name
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.utils import json
@@ -32,32 +31,6 @@ from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
from tests.unit_tests.fixtures.common import dttm # noqa: F401
@pytest.mark.parametrize("name", ["lowercase", "UPPERCASE"])
def test_prepare_identifier_quotes_exact_case_names(name: str) -> None:
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
identifier = SnowflakeEngineSpec.prepare_identifier(
name,
normalize_columns=False,
)
assert isinstance(identifier, quoted_name)
assert str(identifier) == name
assert identifier.quote is True
def test_prepare_identifier_preserves_normalized_name() -> None:
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
name = "lowercase"
identifier = SnowflakeEngineSpec.prepare_identifier(
name,
normalize_columns=True,
)
assert identifier is name
@pytest.mark.parametrize(
"target_type,expected_result",
[
-34
View File
@@ -4552,40 +4552,6 @@ def test_simple_metric_quotes_column_requiring_quoting(database: Database) -> No
)
def test_convert_tbl_column_quotes_snowflake_case_sensitive_identifier(
database: Database,
mocker: MockerFixture,
) -> None:
"""The chart query-object path quotes exact-case Snowflake physical columns."""
from superset.connectors.sqla.models import SqlaTable, TableColumn
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
from superset.models.core import Database
mocker.patch.object(
Database,
"get_db_engine_spec",
return_value=SnowflakeEngineSpec,
)
table = SqlaTable(
database=database,
table_name="bug_test",
normalize_columns=False,
)
tbl_column = TableColumn(column_name="name", type="VARCHAR", table=table)
with database.get_sqla_engine() as engine:
dialect = engine.dialect
rendered = str(
table.convert_tbl_column_to_sqla_col(tbl_column).compile(
dialect=dialect,
compile_kwargs={"literal_binds": True},
)
)
assert rendered == '"name"'
@pytest.mark.parametrize(
"native_type",
[
@@ -14,13 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import inspect
from superset.utils.pandas_postprocessing import (
escape_separator,
pivot,
unescape_separator,
)
from superset.utils.pandas_postprocessing import escape_separator, unescape_separator
def test_escape_separator():
@@ -34,19 +28,3 @@ def test_escape_separator():
escape_string = escape_separator("hello,world")
assert escape_string == r"hello\,world"
assert unescape_separator(escape_string) == "hello,world"
def test_validate_column_args_preserves_signature():
"""
The decorator must not hide the signature of the operation it wraps.
`inspect.signature` follows `__wrapped__`, which `functools.wraps` sets.
Without it every decorated operation reports `(df, **options)`, and code
that inspects the signature -- see `QueryObject._drop_unsupported_options`
-- cannot tell a supported option from an unsupported one.
"""
parameters = inspect.signature(pivot).parameters
assert pivot.__name__ == "pivot"
assert "options" not in parameters
assert {"index", "aggregates", "columns"} <= set(parameters)
@@ -22,7 +22,6 @@ from superset.common.query_object import QueryObject
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.superset_typing import Metric
from superset.utils import pandas_postprocessing
from superset.utils.core import override_user
@@ -439,143 +438,3 @@ def test_cache_key_cache_impersonation_on_with_different_user_and_db_impersonati
],
any_order=True,
)
def test_post_processing_drops_unsupported_options():
"""
An option that the operation no longer accepts is dropped, not passed on.
A chart saved by an older version of Superset stores `flatten_columns` in
the options of its `pivot` operation. `pivot` lost that parameter when
flattening became its own operation, so replaying the stored query_context
raised `TypeError: pivot() got an unexpected keyword argument
'flatten_columns'`.
"""
query_object = QueryObject(
row_limit=1,
post_processing=[
{
"operation": "pivot",
"options": {
"index": ["__timestamp"],
"columns": ["genre"],
"aggregates": {"count": {"operator": "mean"}},
"drop_missing_columns": False,
"flatten_columns": True,
"reset_index": True,
},
}
],
)
options = query_object.post_processing[0]["options"]
assert "flatten_columns" not in options
assert "reset_index" not in options
assert options["drop_missing_columns"] is False
assert options["index"] == ["__timestamp"]
def test_post_processing_keeps_supported_options():
"""Options the operation accepts are left alone."""
post_processing = [
{
"operation": "pivot",
"options": {"index": ["__timestamp"], "aggregates": {}},
}
]
query_object = QueryObject(row_limit=1, post_processing=post_processing)
assert query_object.post_processing == post_processing
def test_post_processing_keeps_unknown_operation():
"""
An unknown operation is kept, so that `exec_post_processing` can report it
as an `InvalidPostProcessingError` rather than being silently dropped here.
"""
query_object = QueryObject(
row_limit=1,
post_processing=[{"operation": "does_not_exist", "options": {"a": 1}}, None],
)
assert query_object.post_processing == [
{"operation": "does_not_exist", "options": {"a": 1}}
]
def test_post_processing_drops_the_dataframe_parameter():
"""
The DataFrame parameter is not an option.
`exec_post_processing` calls `operation(df, **options)`, so an option named
after the first parameter would raise `TypeError: pivot() got multiple
values for argument 'df'`.
"""
query_object = QueryObject(
row_limit=1,
post_processing=[
{
"operation": "pivot",
"options": {"df": "malformed", "index": ["a"], "aggregates": {}},
}
],
)
options = query_object.post_processing[0]["options"]
assert "df" not in options
assert options["index"] == ["a"]
def test_post_processing_keeps_options_of_a_variadic_operation():
"""An operation that accepts `**kwargs` accepts every option."""
def variadic(df, **kwargs):
return df
post_processing = [{"operation": "variadic", "options": {"anything": 1}}]
with patch.object(pandas_postprocessing, "variadic", variadic, create=True):
query_object = QueryObject(row_limit=1, post_processing=post_processing)
assert query_object.post_processing == post_processing
def test_post_processing_drops_a_variadic_positional_option():
"""
A `*args` parameter cannot be filled by a keyword argument.
`exec_post_processing` calls the operation as `operation(df, **options)`,
so an option named after a `*args` parameter would raise `TypeError:
variadic_positional() got an unexpected keyword argument 'args'` even
though the name appears in the signature.
"""
def variadic_positional(df, *args, index=None): # pylint: disable=unused-argument
return df
with patch.object(
pandas_postprocessing, "variadic_positional", variadic_positional, create=True
):
query_object = QueryObject(
row_limit=1,
post_processing=[
{
"operation": "variadic_positional",
"options": {"args": [1], "index": ["a"]},
}
],
)
options = query_object.post_processing[0]["options"]
assert "args" not in options
assert options["index"] == ["a"]
def test_post_processing_keeps_an_entry_without_an_operation():
"""
An entry that names no operation is kept, so that `exec_post_processing`
reports it as an `InvalidPostProcessingError`.
"""
post_processing = [{"options": {"a": 1}}]
query_object = QueryObject(row_limit=1, post_processing=post_processing)
assert query_object.post_processing == post_processing
-182
View File
@@ -637,91 +637,6 @@ class TestWebDriverPlaywrightErrorHandling:
"",
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.take_tiled_screenshot")
def test_large_report_dashboard_tiles_even_when_measured_height_is_short(
self, mock_take_tiled, mock_browser_manager
):
"""Regression: the GOOD (full) report path is the tiled one; BAD
(blank/partial) runs mis-route a large dashboard to the single-shot
non-tiled capture because ``scrollHeight`` is measured while charts are
still virtualized/collapsed (<= one tile). A scheduled report whose
dashboard is large by chart count must take the tiled path regardless
of that stale height measurement, so every region is scrolled into
view and waited on instead of captured as a windowed partial.
"""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
mock_element = MagicMock()
mock_chart_container = MagicMock()
mock_browser_manager.get_browser.return_value = mock_browser
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
def locator_side_effect(selector):
if selector == ".chart-container":
locator = MagicMock()
locator.all.return_value = [mock_chart_container]
return locator
return mock_element
mock_page.locator.side_effect = locator_side_effect
mock_take_tiled.return_value = b"tiled_screenshot"
def evaluate_side_effect(script):
if script == 'document.querySelectorAll(".chart-container").length':
return 52 # mounted containers
if "const target = document.querySelector" in script:
# Non-zero but <= one tile: the classic mid-layout measurement
# that previously vetoed tiling and dropped to the non-tiled
# path.
return 1500
return None
mock_page.evaluate.side_effect = evaluate_side_effect
with patch("superset.utils.webdriver.app") as mock_app:
mock_app.config = {
"WEBDRIVER_OPTION_ARGS": [],
"WEBDRIVER_WINDOW": {"pixel_density": 1},
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
"SCREENSHOT_SELENIUM_HEADSTART": 1,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
"SCREENSHOT_TILED_ENABLED": True,
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
# Larger than the measured 1500px height, so only the new
# report-mode branch (not `dashboard_height > tile_height`) can
# select tiling here.
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 2000,
}
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
mock_auth.return_value = mock_context
driver = WebDriverPlaywright("chrome")
result = driver.get_screenshot(
"http://example.com/dashboard/805",
"standalone",
mock_user,
report_execution_context=_report_context(),
)
assert result == b"tiled_screenshot"
mock_take_tiled.assert_called_once()
# The non-tiled single-shot capture must not run for this large report.
mock_page.screenshot.assert_not_called()
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
@@ -1253,103 +1168,6 @@ class TestWebDriverPlaywrightChartReadiness:
page.wait_for_function.assert_not_called()
page.screenshot.assert_not_called()
def test_report_readiness_forces_below_fold_render_and_waits_for_all_holders(
self,
):
"""Regression for blank/partial report PDFs.
The non-tiled report capture takes a single full-page screenshot that
includes below-the-fold holders, so the readiness gate must (a) force
every virtualized row to render up front and (b) require *all* mounted
holders -- not just the viewport-visible ones -- to reach a terminal
state. Otherwise an off-screen holder that never rendered is captured
blank and silently delivered as a Success.
"""
from superset.utils.screenshot_utils import (
FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS,
REPORT_ALL_CHART_HOLDERS_READY_JS,
)
page = MagicMock()
page.evaluate.return_value = [{"chartId": "7", "state": "rendered"}]
WebDriverPlaywright._wait_for_charts_ready(
page,
"http://example.com/dashboard/805",
5,
"standalone",
report_execution_context=_report_context(),
)
# (a) Off-screen rows are forced to render before the wait.
assert any(
call.args and call.args[0] == FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS
for call in page.evaluate.call_args_list
)
# (b) The readiness predicate is the all-holders variant: it must not
# skip below-the-fold holders (no viewport-intersection test), so a
# virtualized/unrendered off-screen holder cannot satisfy the gate.
predicate = page.wait_for_function.call_args.args[0]
assert predicate == REPORT_ALL_CHART_HOLDERS_READY_JS
assert "getBoundingClientRect" not in predicate
assert "window.innerHeight" not in predicate
@patch("superset.utils.webdriver.logger")
def test_report_readiness_below_fold_unrendered_fails_loudly(self, mock_logger):
"""When an off-screen holder never renders within budget the report
must fail loudly (raise) rather than capture/deliver a blank
screenshot, and the terminal log must surface the below-the-fold
unready holders that the viewport-scoped diagnostic hides as
'virtualized'.
"""
from superset.utils.screenshot_utils import (
FIND_ALL_UNREADY_CHART_HOLDERS_JS,
FIND_CHART_HOLDER_STATES_JS,
)
from superset.utils.webdriver import PlaywrightTimeout
page = MagicMock()
# 22 on-screen rendered holders + 30 off-screen holders that the
# viewport-scoped diagnostic labels 'virtualized' (and would otherwise
# count as "ready").
holder_states = [
{"chartId": str(i), "state": "rendered"} for i in range(22)
] + [{"chartId": str(i), "state": "virtualized"} for i in range(22, 52)]
below_fold_unready = [
{"chartId": str(i), "state": "nothing_mounted"} for i in range(22, 52)
]
def _evaluate(script, *args):
if script == FIND_ALL_UNREADY_CHART_HOLDERS_JS:
return below_fold_unready
if script == FIND_CHART_HOLDER_STATES_JS:
return holder_states
return None
page.evaluate.side_effect = _evaluate
page.wait_for_function.side_effect = PlaywrightTimeout(
"below-fold holders never rendered"
)
with pytest.raises(PlaywrightTimeout):
WebDriverPlaywright._wait_for_charts_ready(
page,
"http://example.com/dashboard/805",
5,
"standalone",
report_execution_context=_report_context(),
)
terminal_call = next(
call
for call in mock_logger.warning.call_args_list
if call.args and call.args[0].startswith("report_readiness_terminal")
)
assert "terminal_reason=readiness_timeout" in terminal_call.args[0]
# The below-the-fold offenders are surfaced explicitly.
assert "all_unready_holders=" in terminal_call.args[0]
assert below_fold_unready in terminal_call.args
@patch("superset.utils.webdriver.logger")
def test_chart_capture_ready_logs_container_state_not_holder_counts(
self, mock_logger