Compare commits

..
Author SHA1 Message Date
Elizabeth Thompson 1cadf39ce8 test(sql): cover SqlglotError fallback branch in parse_predicate
The regression test only exercised the ParseError branch, leaving the
generic sqlglot.errors.SqlglotError fallback in
SQLStatement.parse_predicate uncovered and dropping line coverage below
the 100% gate. Add a test that mocks sqlglot.parse_one to raise a bare
SqlglotError and asserts it is converted to a SupersetParseError.
2026-08-28 22:14:52 +00:00
Elizabeth Thompson 876b8641e2 fix(sql): catch sqlglot ParseError when parsing RLS predicates
SQLStatement.parse_predicate called sqlglot.parse_one unguarded, so a
syntactically invalid RLS predicate raised a raw sqlglot ParseError.
Reachable via apply_rls (e.g. POST /api/v1/sqllab/estimate with
RLS_IN_SQLLAB enabled), this surfaced as an opaque 500 instead of a
typed 422.

Wrap the call to convert ParseError/SqlglotError into SupersetParseError,
mirroring the existing idiom in SQLStatement._parse.
2026-08-28 16:49:17 +00:00
69 changed files with 680 additions and 2278 deletions
+8 -5
View File
@@ -24,6 +24,14 @@ updates:
- dependency-name: "@types/react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues
# GH thread: https://github.com/jsdom/jsdom/issues/3492
- dependency-name: "jest-environment-jsdom"
# `@swc/plugin-transform-imports` doesn't work with current Webpack-SWC hybrid setup
# See https://github.com/apache/superset/pull/37384#issuecomment-3793991389
# TODO: remove the plugin once Lodash usage has been migrated to a more readily tree-shakeable alternative
- dependency-name: "@swc/plugin-transform-imports"
# deck.gl and luma.gl share strict peer constraints across the root and
# plugin workspaces, and root overrides pin their transitive versions.
# Upgrade both families together in a manually validated change.
@@ -79,11 +87,6 @@ updates:
patterns:
- "ag-grid-react"
- "ag-grid-community"
swc:
patterns:
- "@swc/core"
- "@swc/plugin-emotion"
- "@swc/plugin-transform-imports"
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
-21
View File
@@ -66,27 +66,6 @@ jobs:
- name: "Set up liccheck"
run: |
# liccheck (as of 0.9.2) still does a bare `import pkg_resources`
# without declaring setuptools as a dependency, relying on it
# having historically been bundled. setuptools 81+ (installed
# above via requirements/base.txt) dropped the pkg_resources
# subpackage entirely, so liccheck's own import breaks outright.
#
# Reinstalling an older setuptools would restore pkg_resources but
# would also downgrade the *real* setuptools install, which then
# trips liccheck's own working_set.resolve() -- it cross-checks
# requirements/base.txt's declared `setuptools==84.0.0` against
# what's actually installed, and a downgrade makes those disagree.
#
# Instead, vendor just the pkg_resources/ package files from an
# old setuptools wheel into site-packages, leaving the real
# setuptools install (and its dist-info metadata) untouched. This
# gives liccheck an importable pkg_resources whose own working-set
# scan still correctly reports the real installed setuptools
# version, so no conflict is raised.
pip download "setuptools<81" --no-deps -d /tmp/old-setuptools
python -m zipfile -e /tmp/old-setuptools/setuptools-*.whl /tmp/old-setuptools-extracted/
cp -r /tmp/old-setuptools-extracted/pkg_resources "$(python -c 'import site; print(site.getsitepackages()[0])')/"
uv pip install --system liccheck
- name: "Run liccheck"
run: |
+2 -2
View File
@@ -62,7 +62,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.17",
"baseline-browser-mapping": "^2.11.16",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
@@ -76,7 +76,7 @@
"react-svg-pan-zoom": "^3.13.1",
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.3.0",
"reselect": "^5.2.0",
"storybook": "^10.5.10",
"swagger-ui-react": "^5.32.14",
"swc-loader": "^0.2.7",
+11 -11
View File
@@ -6529,10 +6529,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.17, baseline-browser-mapping@^2.9.19:
version "2.11.17"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz#a168205490077c5d7c542f1610016af4ade8f875"
integrity sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.16, baseline-browser-mapping@^2.9.19:
version "2.11.16"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz#0fa19a4ece2e34439ecaa3fdca8a59acfbd287fb"
integrity sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==
batch@0.6.1:
version "0.6.1"
@@ -6614,9 +6614,9 @@ boxen@^7.0.0:
wrap-ansi "^8.1.0"
brace-expansion@^1.1.7:
version "1.1.18"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab"
integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==
version "1.1.15"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.15.tgz#a6d90d54067236e5f42570a3b7378d594d9b7738"
integrity sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
@@ -14106,10 +14106,10 @@ reselect@^4.0.0:
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524"
integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61"
integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1"
integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==
resize-observer-polyfill@1.5.1:
version "1.5.1"
+2 -2
View File
@@ -16,7 +16,7 @@
# under the License.
[build-system]
requires = ["setuptools>=84.0.0", "wheel"]
requires = ["setuptools>=40.9.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
@@ -67,7 +67,7 @@ dependencies = [
"flask-sqlalchemy>=3.1.1, <4.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
"greenlet<=3.5.5, >=3.5.5",
"greenlet<=3.5.4, >=3.5.4",
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
+2 -2
View File
@@ -52,11 +52,11 @@ marshmallow-sqlalchemy>=1.5.0
# needed for python 3.12 support
openapi-schema-validator>=0.6.3
# Pin setuptools <85 until all dependencies migrate from pkg_resources to importlib.metadata
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
# Known affected packages: Preset's 'clients' package
# See docs/docs/contributing/pkg-resources-migration.md for details
setuptools<85
setuptools<81
# google-auth 2.53+ dropped its transitive dependency on cachetools, which is
# imported directly by superset.db_engine_specs.aws_iam. We declare cachetools
+2 -2
View File
@@ -163,7 +163,7 @@ google-auth==2.53.0
# via
# -r requirements/base.in
# shillelagh
greenlet==3.5.5
greenlet==3.5.4
# via
# apache-superset (pyproject.toml)
# shillelagh
@@ -366,7 +366,7 @@ rpds-py==0.25.0
# via
# jsonschema
# referencing
setuptools==84.0.0
setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.5
# via apache-superset (pyproject.toml)
+3 -3
View File
@@ -375,7 +375,7 @@ googleapis-common-protos==1.66.0
# via
# google-api-core
# grpcio-status
greenlet==3.5.5
greenlet==3.5.4
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -663,7 +663,7 @@ pillow==12.3.0
# -c requirements/base-constraint.txt
# apache-superset
# matplotlib
pip==26.2.1
pip==25.1.1
# via apache-superset
platformdirs==4.3.8
# via
@@ -920,7 +920,7 @@ secretstorage==3.5.0
# via keyring
semver==3.0.4
# via apache-superset-extensions-cli
setuptools==84.0.0
setuptools==80.9.0
# via
# -c requirements/base-constraint.txt
# nodeenv
@@ -390,3 +390,19 @@ def get_session() -> scoped_session:
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Role",
"Group",
"Tag",
"KeyValue",
"Subject",
"CoreModel",
"get_session",
]
@@ -183,3 +183,10 @@ def prompt(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = [
"tool",
"prompt",
"ToolAnnotations",
]
@@ -55,3 +55,9 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"QueryDAO",
"SavedQueryDAO",
]
@@ -71,3 +71,9 @@ class SavedQuery(CoreModel):
database_id: int | None
description: str | None
user_id: int | None
__all__ = [
"Query",
"SavedQuery",
]
@@ -46,3 +46,6 @@ def get_sqlglot_dialect(database: "Database") -> Dialects:
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]
@@ -165,3 +165,13 @@ class AsyncQueryHandle:
:returns: True if cancellation was successful
"""
raise NotImplementedError("Method will be replaced during initialization")
__all__ = [
"QueryStatus",
"QueryOptions",
"QueryResult",
"StatementResult",
"AsyncQueryHandle",
"CacheOptions",
]
@@ -27,3 +27,6 @@ class RestApi(BaseApi):
"""
allow_browser_login = True
__all__ = ["RestApi"]
@@ -98,3 +98,6 @@ def api(
"API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["api"]
@@ -164,3 +164,6 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
:return: SemanticViewModel instance or None
"""
...
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
@@ -97,3 +97,6 @@ def semantic_layer(
"Semantic layer decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["semantic_layer"]
@@ -80,3 +80,6 @@ class SemanticViewModel(CoreModel):
semantic_layer_uuid: UUID
created_on: datetime | None
changed_on: datetime | None
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
@@ -71,3 +71,6 @@ class TaskDAO(BaseDAO[Task]):
:returns: Task instance or None if not found or not active
"""
...
__all__ = ["TaskDAO"]
@@ -144,3 +144,9 @@ def get_context() -> TaskContext:
)
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"task",
"get_context",
]
@@ -161,3 +161,9 @@ class TaskSubscriber(CoreModel):
changed_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
__all__ = [
"Task",
"TaskSubscriber",
]
@@ -226,3 +226,12 @@ class TaskContext(ABC):
cleanup_partial_work()
"""
...
__all__ = [
"TaskStatus",
"TaskScope",
"TaskProperties",
"TaskContext",
"TaskOptions",
]
+103 -54
View File
@@ -2086,6 +2086,14 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -2099,6 +2107,18 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -3195,9 +3215,9 @@
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -3814,13 +3834,9 @@
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
},
"node_modules/d3-format": {
"version": "1.4.5",
@@ -4301,6 +4317,18 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -4826,9 +4854,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5558,19 +5586,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -7830,6 +7848,11 @@
"node": ">=8"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@@ -7992,9 +8015,9 @@
"license": "MIT"
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -9819,7 +9842,7 @@
"debug": "4.4.0",
"execa": "4.1.0",
"istanbul-lib-coverage": "^3.0.0",
"js-yaml": "4.3.1",
"js-yaml": "4.1.1",
"nyc": "15.1.0",
"tinyglobby": "^0.2.14"
},
@@ -10098,7 +10121,7 @@
"requires": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": ">=10"
"minimatch": "^10.2.4"
}
},
"@eslint/config-helpers": {
@@ -10190,10 +10213,18 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "4.3.1",
"js-yaml": "4.1.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -10203,6 +10234,14 @@
"path-exists": "^4.0.0"
}
},
"js-yaml": {
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -11146,9 +11185,9 @@
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
},
"brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"peer": true,
"requires": {
@@ -11591,9 +11630,9 @@
}
},
"d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
},
"d3-format": {
"version": "1.4.5",
@@ -11605,7 +11644,7 @@
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
"requires": {
"d3-color": "3.1.0"
"d3-color": "1"
}
},
"d3-scale": {
@@ -11853,7 +11892,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": ">=10",
"minimatch": "^10.2.4",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -11942,6 +11981,11 @@
"eslint-visitor-keys": "^5.0.1"
}
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -12290,7 +12334,7 @@
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "<10",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
@@ -12301,9 +12345,9 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -12314,7 +12358,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"requires": {
"brace-expansion": "1.1.18"
"brace-expansion": "^1.1.7"
}
}
}
@@ -12808,9 +12852,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"requires": {
"argparse": "^2.0.1"
}
@@ -13391,7 +13435,7 @@
"dev": true,
"peer": true,
"requires": {
"brace-expansion": ">=5.0.9"
"brace-expansion": "^5.0.5"
}
},
"minimist": {
@@ -14339,6 +14383,11 @@
"which": "^2.0.1"
}
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@@ -14445,7 +14494,7 @@
"requires": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "<10"
"minimatch": "^3.0.4"
},
"dependencies": {
"balanced-match": {
@@ -14454,9 +14503,9 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -14467,7 +14516,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"requires": {
"brace-expansion": "1.1.18"
"brace-expansion": "^1.1.7"
}
}
}
+2 -8
View File
@@ -30,20 +30,14 @@
"overrides": {
"@babel/core": "^7.29.6",
"@cypress/code-coverage": {
"js-yaml": "4.3.1"
"js-yaml": "4.1.1"
},
"@cypress/request": "^3.0.0",
"cypress": {
"form-data": "^2.3.4"
},
"d3-interpolate": {
"d3-color": "3.1.0"
},
"minimatch@<10": {
"brace-expansion": "1.1.18"
},
"minimatch@>=10": {
"brace-expansion": ">=5.0.9"
"brace-expansion": ">=5.0.8"
},
"qs": "^6.14.2",
"uuid": "^11.1.1"
+1 -1
View File
@@ -77,7 +77,7 @@ module.exports = {
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
],
preset: 'ts-jest',
transform: {
+35 -35
View File
@@ -84,7 +84,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^3.0.0",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -218,13 +218,13 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.17",
"baseline-browser-mapping": "^2.11.16",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.9.0",
"eslint": "^10.8.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -15753,9 +15753,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz",
"integrity": "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==",
"version": "2.11.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz",
"integrity": "sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -17451,12 +17451,12 @@
}
},
"node_modules/content-disposition": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=22"
"node": ">=18"
},
"funding": {
"type": "opencollective",
@@ -17804,9 +17804,9 @@
"license": "Python-2.0"
},
"node_modules/cosmiconfig/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@@ -19858,9 +19858,9 @@
}
},
"node_modules/eslint": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -26738,9 +26738,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -27662,9 +27662,9 @@
}
},
"node_modules/lerna/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@@ -30733,9 +30733,9 @@
}
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"license": "MIT"
},
@@ -34750,9 +34750,9 @@
}
},
"node_modules/react-diff-viewer-continued/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"funding": [
{
"type": "github",
@@ -36063,9 +36063,9 @@
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
@@ -38445,9 +38445,9 @@
"license": "ISC"
},
"node_modules/stylelint/node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
@@ -43010,7 +43010,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.3.0",
"reselect": "^5.2.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.15"
+5 -17
View File
@@ -161,7 +161,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^3.0.0",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -295,13 +295,13 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.17",
"baseline-browser-mapping": "^2.11.16",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.9.0",
"eslint": "^10.8.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -383,9 +383,6 @@
"@great-expectations/jsonforms-antd-renderers": {
"antd": "$antd"
},
"@istanbuljs/load-nyc-config": {
"js-yaml": "^3.15.1"
},
"@jest/globals": "^30.4.0",
"@jest/types": "^30.4.0",
"@luma.gl/constants": "~9.2.5",
@@ -395,9 +392,6 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"core-js": "^3.38.1",
"cosmiconfig": {
"js-yaml": "^4.3.1"
},
"dompurify": "^3.4.13",
"esbuild": "^0.28.1",
"eslint-plugin-import": {
@@ -414,22 +408,16 @@
"jest-mock": "^30.4.0",
"jest-runtime": "^30.4.0",
"jest-util": "^30.4.0",
"js-yaml-loader": {
"js-yaml": "^3.15.1"
},
"jspdf": "^4.2.0",
"lerna": {
"js-yaml": "^4.3.1"
"js-yaml": "^4.3.0"
},
"minimatch@>=10": {
"brace-expansion": ">=5.0.8"
},
"nanoid@>=3 <4": "3.3.18",
"nwsapi": "^2.2.24",
"nwsapi": "^2.2.13",
"puppeteer": "^22.4.1",
"react-diff-viewer-continued": {
"js-yaml": "^4.3.1"
},
"tar": "^7.5.16",
"typescript-json-schema": "^0.68.0",
"underscore": "^1.13.7",
@@ -89,7 +89,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.3.0",
"reselect": "^5.2.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.15"
@@ -30,7 +30,6 @@ import type {
QueryFormData,
} from '../query';
import type { JsonResponse } from '../connection';
import type { MenuItem } from '../components/Menu';
/**
* A function which returns text (or marked-up text)
@@ -165,13 +164,6 @@ export interface SliceHeaderExtension {
dashboardId: number;
}
/**
* Interface for extensions to the Slice Header more-options menu
*/
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
sliceName: string;
}
/**
* Interface for extensions to Embed Modal
*/
@@ -270,9 +262,6 @@ export type Extensions = Partial<{
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
'dashboard.slice.header.menu': (
context: SliceHeaderMenuExtension,
) => MenuItem[];
'sqleditor.extension.customAutocomplete': (
args: CustomAutoCompleteArgs,
) => CustomAutocomplete[] | undefined;
@@ -462,14 +462,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) =>
n <= Math.max(rowCount, serverPageLength);
const getServerPagination = (n: number) => n <= rowCount;
return (
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
).filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[];
}, [data.length, rowCount, serverPageLength, serverPagination]);
}, [data.length, rowCount, serverPagination]);
const getValueRange = useCallback(
function getValueRange(key: string, alignPositiveNegative: boolean) {
@@ -67,23 +67,14 @@ async function renderAndWait(props = mockedProps) {
container = renderedContainer;
}
// A modal that wasn't handed an `etag` reads the dataset itself and can't save
// until that lands, so tests must wait before acting on the Save button.
async function waitForSaveEnabled() {
await waitFor(() =>
expect(screen.getByTestId('datasource-modal-save')).toBeEnabled(),
);
}
beforeEach(async () => {
beforeEach(() => {
fetchMock.clearHistory().removeRoutes();
cleanup();
renderAndWait();
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, { result: {} });
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
await waitForSaveEnabled();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -127,7 +118,6 @@ describe('DatasourceModal', () => {
onDatasourceSave:
onDatasourceSave as unknown as typeof mockedProps.onDatasourceSave,
});
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
const okButton = await screen.findByRole('button', { name: 'Confirm' });
@@ -161,96 +151,6 @@ describe('DatasourceModal', () => {
putSpy.mockRestore();
});
test('sends the supplied etag as If-Match so a stale save is refused', async () => {
cleanup();
renderAndWait({ ...mockedProps, etag: '"v1"' } as typeof mockedProps);
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v1"');
});
});
test('reads the etag from the dataset when the caller supplies none', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, {
body: { result: {} },
headers: { ETag: '"v2"' },
});
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
// The form is seeded from the same read as the validator, so saving is
// unavailable until it lands.
expect(screen.getByTestId('datasource-modal-save')).toBeDisabled();
await screen.findByTestId('datasource-editor');
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v2"');
});
});
test('never saves unguarded while the validator read is in flight', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
// A read that never resolves: the save path must stay closed rather than
// fall through to an unconditional PUT.
fetchMock.get(GET_DATASOURCE_ENDPOINT, new Promise(() => {}));
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
const saveButton = await screen.findByTestId('datasource-modal-save');
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(
fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put'),
).toBeUndefined();
});
test('shows a conflict dialog instead of a generic error on 412', async () => {
const putSpy = jest
.spyOn(SupersetClient, 'put')
.mockRejectedValue(new Response('', { status: 412 }));
try {
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
const conflictElements = await screen.findAllByText(
'Dataset changed since you opened it',
);
expect(conflictElements.length).toBeGreaterThan(0);
expect(
screen.queryByText('Error saving dataset'),
).not.toBeInTheDocument();
} finally {
putSpy.mockRestore();
}
});
test('shows sync columns checkbox when SQL changes', async () => {
cleanup();
const datasourceWithSQL = {
@@ -263,24 +163,15 @@ describe('DatasourceModal', () => {
};
const { rerender } = render(
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
{ store, useRouter: true },
);
// Update with modified SQL
rerender(
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -317,24 +208,15 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -387,24 +269,15 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -21,7 +21,6 @@ import {
screen,
fireEvent,
act,
waitFor,
defaultStore as store,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
@@ -73,9 +72,6 @@ test('DatasourceModal - should handle sync columns state without imperative moda
render(<DatasourceModal {...mockedProps} />, { store });
const saveButton = screen.getByTestId('datasource-modal-save');
// The modal fetches the current dataset version on open; save stays disabled
// until that settles
await waitFor(() => expect(saveButton).toBeEnabled());
// This should not throw any DOM errors
await act(async () => {
@@ -33,14 +33,12 @@ import {
Icons,
Button,
Checkbox,
Loading,
Modal,
AsyncEsmComponent,
} from '@superset-ui/core/components';
import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import { withCertificationFields } from '../utils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
import type { DatasourceModalProps } from '../types';
@@ -93,18 +91,12 @@ export function buildExtraJsonObject(
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
datasource,
etag,
onDatasourceSave,
onHide,
show,
}) => {
const theme = useTheme();
const [currentDatasource, setCurrentDatasource] = useState(datasource);
// SQL of the server snapshot the form started from. The caller's, unless
// this modal read the dataset itself — then "did the SQL change?" has to be
// asked against the snapshot the payload is actually built from.
const [seededSql, setSeededSql] = useState<string | undefined>();
const [versionEtag, setVersionEtag] = useState(etag);
const [syncColumns, setSyncColumns] = useState(false);
const currencies = useSelector<
{
@@ -119,52 +111,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const [isLoadingDatasource, setIsLoadingDatasource] = useState(false);
// Callers that read the dataset themselves (the dataset list) hand down the
// ETag of that read. The rest — Explore, where `datasource` comes from the
// page's bootstrap state — read it here, and must seed the form from the
// *same* response: a payload built from an older snapshot than the ETag
// guarding it would still be accepted, and would still clobber.
useEffect(() => {
setVersionEtag(etag);
if (etag || !show || !datasource.id) {
return undefined;
}
let cancelled = false;
setIsLoadingDatasource(true);
SupersetClient.get({
endpoint: `/api/v1/dataset/${datasource.id}`,
})
.then(({ json, response }) => {
if (cancelled) {
return;
}
const seeded = {
...datasource,
...json.result,
columns: withCertificationFields(json.result.columns),
};
setSeededSql(seeded.sql);
setCurrentDatasource(seeded);
setVersionEtag(response.headers.get('ETag') ?? undefined);
})
.catch(() => {
// The read failed outright, so there is no fresher snapshot to edit
// and no validator to send. Fall back to the caller's snapshot and an
// unconditional save, which is what this modal did before the guard.
})
.finally(() => {
if (!cancelled) {
setIsLoadingDatasource(false);
}
});
return () => {
cancelled = true;
};
}, [datasource.id, etag, show]);
const baselineSql = seededSql ?? datasource.sql;
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
@@ -251,13 +197,11 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
jsonPayload: buildPayload(currentDatasource),
...(versionEtag ? { headers: { 'If-Match': versionEtag } } : {}),
});
const { json, response } = await SupersetClient.get({
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
setVersionEtag(response.headers.get('ETag') ?? undefined);
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
@@ -269,19 +213,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onHide();
} catch (response) {
setIsSaving(false);
if ((response as Response)?.status === 412) {
modal.error({
title: t('Dataset changed since you opened it'),
okButtonProps: { danger: true, className: 'btn-danger' },
content: t(
'Someone else, or another one of your browser tabs, saved this ' +
'dataset after you opened it. Saving now would undo those ' +
'changes, so it was cancelled. Copy your edits, close this ' +
'dialog, and reopen the dataset to reapply them.',
),
});
return;
}
const error = await getClientErrorObject(response);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
@@ -333,7 +264,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
here may affect other charts
in undesirable ways.`)}
/>
{baselineSql !== currentDatasource.sql && (
{datasource.sql !== currentDatasource.sql && (
<div
css={theme => ({
marginBottom: theme.marginMD,
@@ -367,14 +298,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
{t('Are you sure you want to save and apply changes?')}
</div>
),
[currentDatasource.sql, baselineSql, syncColumns],
[currentDatasource.sql, datasource.sql, syncColumns],
);
useEffect(() => {
if (baselineSql !== currentDatasource.sql) {
if (datasource.sql !== currentDatasource.sql) {
setSyncColumns(true);
}
}, [baselineSql, currentDatasource.sql]);
}, [datasource.sql, currentDatasource.sql]);
const onClickSave = () => {
setConfirmModalOpen(true);
@@ -425,7 +356,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onClick={onClickSave}
disabled={
isSaving ||
isLoadingDatasource ||
errors.length > 0 ||
currentDatasource.is_managed_externally
}
@@ -451,18 +381,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}}
draggable
>
{isLoadingDatasource ? (
<Loading />
) : (
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
)}
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
{contextHolder}
<Modal
title={t('Confirm save')}
@@ -20,5 +20,4 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
import DatasourceModal from './DatasourceModal';
export { ChangeDatasourceModal, DatasourceModal };
export { withCertificationFields } from './utils';
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
@@ -29,12 +29,6 @@ export interface DatasourceModalProps {
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
datasource: DatasetObject;
/**
* ETag of the dataset read the form was seeded from. Replayed as `If-Match`
* on save so a stale form can't clobber a newer write. Fetched by the modal
* when the caller doesn't already have one.
*/
etag?: string;
onChange: () => {};
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => {};
@@ -27,7 +27,6 @@ import { nanoid } from 'nanoid';
import { SupersetClient } from '@superset-ui/core';
import { tn } from '@apache-superset/core/translation';
import rison from 'rison';
import type { ColumnObject } from 'src/features/datasets/types';
// Type definitions
@@ -249,29 +248,3 @@ export async function fetchSyncedColumns(
const { json } = await SupersetClient.get({ endpoint, signal });
return json as ColumnMetadata[];
}
/**
* Lift each column's certification out of its `extra` JSON into the flat
* fields the datasource editor binds to.
*/
export function withCertificationFields(columns: ColumnObject[] = []) {
return columns.map(column => {
// Malformed `extra` must not take out the whole column list, the way an
// uncaught parse would — same fallback as `hydrateMetricExtra`.
let parsedExtra;
try {
parsedExtra = JSON.parse(column.extra || '{}') || {};
} catch {
parsedExtra = {};
}
const {
certification: { details = '', certified_by: certifiedBy = '' } = {},
} = parsedExtra;
return {
...column,
certification_details: details || '',
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
});
}
@@ -23,7 +23,7 @@ import {
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
import { FeatureFlag, VizType } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import downloadAsImage from 'src/utils/downloadAsImage';
@@ -165,9 +165,6 @@ beforeEach(() => {
afterEach(() => {
Reflect.deleteProperty(document, 'fullscreenElement');
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
// not leak into other tests (the empty array is guarded, so nothing injects).
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
});
test('Should render', () => {
@@ -176,58 +173,6 @@ test('Should render', () => {
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
});
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
{ key: 'custom-ext', label: 'Custom Menu Extension' },
]);
renderWrapper();
openMenu();
const injected = screen.getByText('Custom Menu Extension');
expect(injected).toBeInTheDocument();
// Sits above the built-in entries.
const forceRefresh = screen.getByText('Force refresh');
expect(
injected.compareDocumentPosition(forceRefresh) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
renderWrapper();
openMenu();
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
// The menu still renders its built-in entries unchanged (no dangling divider
// is added since the empty array is guarded).
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
throw new Error('boom');
});
renderWrapper();
openMenu();
// The throw is isolated: the built-in menu still renders.
expect(screen.getByText('Force refresh')).toBeInTheDocument();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
});
test('Injects nothing when the extension returns a non-array', () => {
getExtensionsRegistry().set(
'dashboard.slice.header.menu',
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
(() => undefined) as never,
);
renderWrapper();
openMenu();
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Should render default props', () => {
const props = createProps();
@@ -34,13 +34,11 @@ import {
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
VizType,
BinaryQueryObjectFilterClause,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme, styled } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
@@ -167,8 +165,6 @@ const queueChartResize = () => {
}, 300);
};
const extensionsRegistry = getExtensionsRegistry();
const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
@@ -518,26 +514,6 @@ const SliceHeaderControls = (
},
];
const sliceHeaderMenuExtension = extensionsRegistry.get(
'dashboard.slice.header.menu',
);
if (sliceHeaderMenuExtension) {
// Isolate the extension: a bad registration (throwing, or returning a
// non-array) must not take down the whole dashboard render.
try {
const extensionItems = sliceHeaderMenuExtension({
sliceId: slice.slice_id,
sliceName: slice.slice_name,
dashboardId,
});
if (Array.isArray(extensionItems) && extensionItems.length) {
newMenuItems.unshift(...extensionItems, { type: 'divider' });
}
} catch (error) {
logging.error('dashboard.slice.header.menu extension failed', error);
}
}
if (slice.description) {
newMenuItems.push({
key: MenuKeys.ToggleChartDescription,
@@ -297,12 +297,6 @@ test('Click on Edit dataset', async () => {
const props = createProps();
fetchMock.removeRoute(getDbWithQuery);
fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
fetchMock.removeRoute(getDatasetWithAllMockRouteName);
fetchMock.get(
getDatasetWithAll,
{ result: {} },
{ name: getDatasetWithAllMockRouteName },
);
render(<DatasourceControl {...props} />, {
useRedux: true,
useRouter: true,
@@ -313,9 +307,7 @@ test('Click on Edit dataset', async () => {
await userEvent.click(screen.getByText('Edit dataset'));
});
expect(
await screen.findByTestId('mock-datasource-editor'),
).toBeInTheDocument();
expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
});
test('Edit dataset should be disabled when user is not admin', async () => {
@@ -43,6 +43,7 @@ import {
} from 'src/views/CRUD/utils';
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { ColumnObject } from 'src/features/datasets/types';
import { useListViewResource } from 'src/views/CRUD/hooks';
import {
ActionButton,
@@ -61,7 +62,6 @@ import {
} from '@superset-ui/core/components';
import {
DatasourceModal,
withCertificationFields,
GenericLink,
ImportModal as ImportModelsModal,
ModifiedInfo,
@@ -496,8 +496,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [datasetCurrentlyEditing, setDatasetCurrentlyEditing] =
useState<Dataset | null>(null);
const [datasetCurrentlyEditingEtag, setDatasetCurrentlyEditingEtag] =
useState<string | undefined>();
const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
useState<VirtualDataset | null>(null);
@@ -567,11 +565,24 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
SupersetClient.get({
endpoint: `/api/v1/dataset/${id}`,
})
.then(({ json = {}, response }) => {
setDatasetCurrentlyEditingEtag(
response.headers.get('ETag') ?? undefined,
.then(({ json = {} }) => {
const addCertificationFields = json.result.columns.map(
(column: ColumnObject) => {
const {
certification: {
details = '',
certified_by: certifiedBy = '',
} = {},
} = JSON.parse(column.extra || '{}') || {};
return {
...column,
certification_details: details || '',
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
},
);
json.result.columns = withCertificationFields(json.result.columns);
json.result.columns = [...addCertificationFields];
setDatasetCurrentlyEditing(json.result);
})
.catch(() => {
@@ -1513,7 +1524,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
{datasetCurrentlyEditing && (
<DatasourceModal
datasource={datasetCurrentlyEditing}
etag={datasetCurrentlyEditingEtag}
onDatasourceSave={refreshData}
onHide={closeDatasetEditModal}
show
+4 -4
View File
@@ -26,7 +26,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.9.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.11.0",
"oxfmt": "^0.64.0",
@@ -1624,9 +1624,9 @@
}
},
"node_modules/eslint": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -34,7 +34,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.9.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.11.0",
"oxfmt": "^0.64.0",
+6
View File
@@ -18,6 +18,7 @@ import logging
from flask import Response
from flask_appbuilder.api import expose, protect, safe
from flask_appbuilder.security.decorators import has_access_api
from superset.commands.dashboard.filter_state.create import CreateFilterStateCommand
from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand
@@ -25,6 +26,7 @@ from superset.commands.dashboard.filter_state.get import GetFilterStateCommand
from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand
from superset.extensions import event_logger
from superset.temporary_cache.api import TemporaryCacheRestApi
from superset.views.base import api
logger = logging.getLogger(__name__)
@@ -46,6 +48,8 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
def get_delete_command(self) -> type[DeleteFilterStateCommand]:
return DeleteFilterStateCommand
@api
@has_access_api
@expose("/<int:pk>/filter_state", methods=("POST",))
@protect()
@safe
@@ -169,6 +173,8 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
"""
return super().post(pk)
@api
@has_access_api
@expose("/<int:pk>/filter_state/<string:key>", methods=("PUT",))
@protect()
@safe
+6 -54
View File
@@ -29,7 +29,7 @@ from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.api.schemas import get_item_schema
from flask_appbuilder.const import API_RESULT_RES_KEY, API_SELECT_COLUMNS_RIS_KEY
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import gettext as _, ngettext
from flask_babel import ngettext
from jinja2.exceptions import TemplateError
from marshmallow import ValidationError
from sqlalchemy.orm.exc import MultipleResultsFound
@@ -95,20 +95,13 @@ from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
from superset.utils import json
from superset.utils.core import parse_boolean_string, send_export_zip
from superset.versioning.api_helpers import (
concurrency_token_from,
current_entity_etag_uuid,
current_entity_version_info,
entity_concurrency_token,
get_version_endpoint,
list_versions_endpoint,
lock_entity_for_update,
restore_version_endpoint,
)
from superset.versioning.etag import (
is_conditional_write,
raise_for_stale_write,
set_version_etag,
StaleEntityError,
)
from superset.versioning.etag import set_version_etag
from superset.versioning.schemas import VersionListItemSchema
from superset.views.base import DatasourceFilter
from superset.views.base_api import (
@@ -549,14 +542,6 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
schema:
type: boolean
name: override_columns
- in: header
schema:
type: string
name: If-Match
description: >-
Optional optimistic-concurrency guard. Pass the ``ETag`` returned
by a prior read of this dataset; the update is rejected with 412
if the dataset has changed since.
requestBody:
description: Dataset schema
required: true
@@ -633,17 +618,6 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
412:
description: >-
The dataset changed since the version identified by the
request's ``If-Match`` header; the update was not applied.
content:
application/json:
schema:
type: object
properties:
message:
type: string
422:
$ref: '#/components/responses/422'
500:
@@ -660,32 +634,10 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
except ValidationError as error:
return self.response_400(message=error.messages)
# Serialise conditional saves on this dataset: the guard below reads
# the live version, the command writes, and the two must not interleave
# with another request's. Only a conditional save pays for the lock; an
# unconditional PUT behaves exactly as it did before the guard existed.
if is_conditional_write():
lock_entity_for_update(SqlaTable, pk)
# Live version identifiers before the update (empty + query-free when
# ``ENABLE_VERSIONING_CAPTURE`` is off).
old_info = current_entity_version_info(SqlaTable, pk)
try:
raise_for_stale_write(concurrency_token_from(old_info))
except StaleEntityError:
return set_version_etag(
self.response(
412,
message=_(
"The dataset was changed by another user or browser tab "
"after you opened it. Reopen it to pick up the latest "
"version, then reapply your changes."
),
),
concurrency_token_from(old_info),
)
try:
# Two commands, two commits, two Continuum transactions for an
# ``override_columns`` save — deliberately NOT merged into one
@@ -709,13 +661,13 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
new_info = current_entity_version_info(
SqlaTable, changed_model.id, changed_model.uuid
)
etag_version_uuid = concurrency_token_from(new_info)
etag_version_uuid = new_info.version_uuid
if override_columns:
RefreshDatasetCommand(pk).run()
# The ETag must reflect the entity's *current live* version,
# which after the refresh is the refresh's transaction —
# re-read it rather than reusing the pre-refresh uuid.
etag_version_uuid = entity_concurrency_token(
etag_version_uuid = current_entity_etag_uuid(
SqlaTable, changed_model.id, changed_model.uuid
)
response = self.response(
@@ -1748,7 +1700,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
return set_version_etag(
self.response(200, **response),
entity_concurrency_token(SqlaTable, table.id, table.uuid),
current_entity_etag_uuid(SqlaTable, table.id, table.uuid),
)
@expose("/<int:pk>/drill_info/", methods=("GET",))
+1 -10
View File
@@ -26,7 +26,7 @@ from collections import defaultdict, deque
from datetime import datetime
from re import Pattern
from textwrap import dedent
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
from typing import Any, cast, Optional, TYPE_CHECKING
from urllib import parse
import pandas as pd
@@ -182,15 +182,6 @@ class PrestoBaseEngineSpec(BaseEngineSpec, metaclass=ABCMeta):
# which raises a query error. Use = true/false instead.
use_equality_for_boolean_filters = True
# Presto/Trino's coordinator sends query results as JSON, which has no
# literal for NaN/Infinity/-Infinity, so REAL/DOUBLE columns holding
# those values arrive as quoted strings. Coerce them back to real
# floats so numeric post-processing (e.g. a pivot's mean) doesn't choke
# on a string value.
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
types.FLOAT: lambda val: float(val) if isinstance(val, str) else val
}
column_type_mappings = (
(
re.compile(r"^boolean.*", re.IGNORECASE),
+1 -12
View File
@@ -22,8 +22,7 @@ import math
import threading
import time
from collections.abc import Sequence
from decimal import Decimal
from typing import Any, Callable, TYPE_CHECKING
from typing import Any, TYPE_CHECKING
import requests
from flask import copy_current_request_context, ctx, current_app as app, Flask, g
@@ -31,7 +30,6 @@ from flask_babel import gettext as __
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.exc import NoSuchTableError
from sqlalchemy.types import DECIMAL, TypeEngine
from superset import cache_manager, db
from superset.common.db_query_status import QueryStatus
@@ -80,15 +78,6 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
"$.oauth2_client_info.secret": "OAuth2 client secret",
}
# Trino's DBAPI driver can return DECIMAL columns as plain strings
# (e.g. when a value's precision/scale can't be inferred from the
# column type alone), which later breaks numeric post-processing
# (e.g. pivot with a mean aggregate). Coerce them back to Decimal.
column_type_mutators: dict[TypeEngine, Callable[[Any], Any]] = {
**PrestoBaseEngineSpec.column_type_mutators,
DECIMAL: lambda val: Decimal(val) if isinstance(val, str) else val,
}
# The full set of columns Trino's "<table>$partitions" exposes for an
# Iceberg table. The real partition keys are nested in the "partition" ROW,
# so none of these are user partition columns.
-9
View File
@@ -30,7 +30,6 @@ from superset.commands.temporary_cache.exceptions import (
TemporaryCacheResourceNotFoundError,
)
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.exceptions import SupersetTemplateException
from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema
from superset.extensions import event_logger
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
@@ -111,8 +110,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("PUT",))
@protect()
@@ -186,8 +183,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("GET",))
@protect()
@@ -239,8 +234,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("DELETE",))
@protect()
@@ -293,5 +286,3 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
+1 -10
View File
@@ -16,8 +16,6 @@
# under the License.
from typing import Optional
from jinja2.exceptions import TemplateError
from superset import security_manager
from superset.commands.chart.exceptions import (
ChartAccessDeniedError,
@@ -35,7 +33,6 @@ from superset.commands.exceptions import (
from superset.daos.chart import ChartDAO
from superset.daos.dataset import DatasetDAO
from superset.daos.query import QueryDAO
from superset.exceptions import SupersetTemplateException
from superset.utils.core import DatasourceType
@@ -56,13 +53,7 @@ def check_query_access(query_id: int) -> Optional[bool]:
# Access checks below, no need to validate them twice as they can be expensive.
query = QueryDAO.find_by_id(query_id, skip_base_filter=True)
if query:
try:
security_manager.raise_for_access(query=query)
except TemplateError as ex:
# raise_for_access() Jinja-renders the query's SQL to resolve
# the tables it touches; a malformed template surfaces here as
# a raw jinja2 exception rather than a Superset one.
raise SupersetTemplateException(str(ex)) from ex
security_manager.raise_for_access(query=query)
return True
raise QueryNotFoundValidationError()
-12
View File
@@ -4823,18 +4823,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
template_processor=template_processor
)
is_metric_filter = True
elif (
col_obj is None
and isinstance(flt_col, str)
and flt_col in adhoc_columns_by_label
):
sqla_col, _unused = self.adhoc_column_to_sqla(
col=adhoc_columns_by_label[flt_col],
template_processor=template_processor,
)
if isinstance(sqla_col, ColumnElement):
applied_adhoc_filters_columns.append(flt_col)
filter_grain = flt.get("grain")
# Check if this filter should be skipped because it was handled in
+19 -1
View File
@@ -1539,7 +1539,25 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
:return: The parsed predicate.
"""
_check_script_length(predicate, self.engine)
return sqlglot.parse_one(predicate, dialect=self._dialect)
try:
return sqlglot.parse_one(predicate, dialect=self._dialect)
except sqlglot.errors.ParseError as ex:
kwargs = (
{
"highlight": ex.errors[0]["highlight"],
"line": ex.errors[0]["line"],
"column": ex.errors[0]["col"],
}
if ex.errors
else {}
)
raise SupersetParseError(predicate, self.engine, **kwargs) from ex
except sqlglot.errors.SqlglotError as ex:
raise SupersetParseError(
predicate,
self.engine,
message="Unable to parse predicate",
) from ex
def apply_rls(
self,
+1 -189
View File
@@ -16,7 +16,6 @@
# under the License.
from typing import Any, Optional
import pandas as pd
from flask_babel import gettext as _
from pandas import DataFrame
@@ -27,116 +26,6 @@ from superset.utils.pandas_postprocessing.utils import (
validate_column_args,
)
_PERCENT_MODES = frozenset({"percent_row", "percent_col", "percent_total"})
# Aggregate operator names that produce additive results across groups —
# the sum of the per-cell values equals the row/column/grand rollup the
# database would compute over the underlying rows. ``show_values_as``
# percent transforms divide each cell by that sum, so they are only
# meaningful when the sum IS the rollup. For non-additive operators
# (mean, median, min, max, etc.) the "percent of row" the exports would
# show is not the "percent of row" the chart's DB rollup would show,
# and the two disagree. See sadpandajoe's finding on #42976.
_ADDITIVE_OPERATORS = frozenset({"sum", "nansum", "count", "count_nonzero"})
def _div_preserving_nan(numerator: DataFrame, denominator: Any, axis: int) -> DataFrame:
"""Divide ``numerator`` by ``denominator``, preserving NaN numerators.
A genuine SQL NULL numerator must stay NaN (rendered blank) rather than
become ``0.0`` matches the client-side #42810 semantics guarding
against measured "0.0%" values for values that should stay blank.
"""
result = numerator.div(denominator, axis=axis)
return result.mask(numerator.isna(), other=float("nan"))
def _apply_percent_transform_to_group(g: DataFrame, mode: str) -> DataFrame:
"""Apply a percent-of-{row,col,total} transform to a single-metric block.
Called both for the whole DataFrame when it holds one metric, and
per-metric-group for MultiIndex / flat-multi-metric pivots. A zero or
NaN denominator produces NaN cells rather than ``Infinity``/``NaN``
from division-by-zero, matching the client's ``if (acc === null)
return null`` guard in ``fractionOf``.
"""
if mode == "percent_row":
row_totals = g.sum(axis=PandasAxis.COLUMN, skipna=True).replace(0, float("nan"))
return _div_preserving_nan(g, row_totals, axis=PandasAxis.ROW)
if mode == "percent_col":
col_totals = g.sum(axis=PandasAxis.ROW, skipna=True).replace(0, float("nan"))
return _div_preserving_nan(g, col_totals, axis=PandasAxis.COLUMN)
# percent_total
grand = g.sum(skipna=True).sum(skipna=True)
if pd.isna(grand) or grand == 0:
return g * float("nan")
return _div_preserving_nan(g, grand, axis=PandasAxis.ROW)
def _apply_show_values_as(df: DataFrame, mode: str) -> DataFrame:
"""Divide each metric cell by the appropriate rollup total.
Mirrors the client-side ``fractionOf`` semantic in
``plugin-chart-pivot-table/src/react-pivottable/utilities.ts:739``:
- ``percent_row``: cell / row-total (sum across the columns axis)
- ``percent_col``: cell / column-total (sum across the rows axis)
- ``percent_total``: cell / grand-total (sum of the metric block)
Per-metric isolation the totals are computed *within each metric* so
one metric's numerator is never divided by another metric's total,
matching the client's ``metricAxis`` handling. This applies to both
shapes ``pivot_table`` can produce:
- **MultiIndex columns** (level 0 = metric): iterate the level-0
groups explicitly. Explicit iteration avoids the deprecated
``df.groupby(level=0, axis=1)`` pattern (removed in pandas 3.x).
- **Flat columns with >1 column** (multi-metric pivot with no
``columns`` groupby each column IS a metric): treat each column
as its own single-column metric block.
- **Flat columns with 1 column** (single-metric pivot with no
``columns`` groupby): the whole block is one metric.
NULL preservation is *structural* only: cells that ``pivot_table``
left as ``NaN`` because the (row, column) group had no input rows
at all stay ``NaN`` in the output. Cells whose input rows all held
SQL NULL values have already collapsed to ``0.0`` inside
``pivot_table`` (pandas ``sum([NaN]) == 0``), so they render as
``0%``, not blank reconstructing "blank vs measured zero" from an
aggregated value is not possible at this layer.
"""
if df.empty:
# Empty pivots can carry an empty ``MultiIndex`` with zero level-0
# groups; iterating and concatenating produces
# ``ValueError: No objects to concatenate``. Nothing to transform.
return df
is_multi_metric_wide = isinstance(df.columns, pd.MultiIndex)
is_flat_multi_metric = not is_multi_metric_wide and df.shape[1] > 1
if is_multi_metric_wide:
# Iterate level-0 groups explicitly (pandas-3-safe).
metrics = df.columns.get_level_values(0).unique()
parts = []
for metric in metrics:
block = df.xs(metric, axis=PandasAxis.COLUMN, level=0, drop_level=False)
parts.append(_apply_percent_transform_to_group(block, mode))
# ``concat`` along columns preserves the MultiIndex; reorder to
# match the original column layout deterministically.
combined = pd.concat(parts, axis=PandasAxis.COLUMN)
return combined[df.columns]
if is_flat_multi_metric:
# Each flat column is its own metric — process independently.
parts = []
for col in df.columns:
block = df[[col]]
parts.append(_apply_percent_transform_to_group(block, mode))
return pd.concat(parts, axis=PandasAxis.COLUMN)[df.columns]
# Flat single-metric block — the whole DataFrame is one metric.
return _apply_percent_transform_to_group(df, mode)
def _restore_dropped_metric_columns(
df: DataFrame,
@@ -188,7 +77,7 @@ def _restore_dropped_metric_columns(
@validate_column_args("index", "columns")
def pivot( # pylint: disable=too-many-arguments # noqa: C901
def pivot( # pylint: disable=too-many-arguments
df: DataFrame,
index: list[str],
aggregates: dict[str, dict[str, Any]],
@@ -199,7 +88,6 @@ def pivot( # pylint: disable=too-many-arguments # noqa: C901
combine_value_with_metric: bool = False,
marginal_distributions: Optional[bool] = None,
marginal_distribution_name: Optional[str] = None,
show_values_as: Optional[str] = None,
) -> DataFrame:
"""
Perform a pivot operation on a DataFrame.
@@ -223,13 +111,6 @@ def pivot( # pylint: disable=too-many-arguments # noqa: C901
:param marginal_distributions: Add totals for row/column. Default to False
:param marginal_distribution_name: Name of row/column with marginal distribution.
Default to 'All'.
:param show_values_as: Optional post-pivot transform that expresses each
metric cell as a fraction of the row / column / grand total.
One of ``"percent_row"``, ``"percent_col"``, ``"percent_total"`` or
``None`` / ``"actual"`` (no-op, default). Mirrors the pivot chart's
client-side ``fractionOf`` semantic so server-side rendering paths
(CSV / XLSX exports, scheduled reports) can reproduce the browser
output. See #42809.
:return: A pivot table
:raises InvalidPostProcessingError: If the request in incorrect
"""
@@ -242,62 +123,6 @@ def pivot( # pylint: disable=too-many-arguments # noqa: C901
_("Pivot operation must include at least one aggregate")
)
# Fail fast on ``show_values_as`` misconfiguration *before* running the
# (potentially expensive) ``pivot_table`` call: an unknown mode should
# not silently perform a full pivot only to raise at the end, and the
# ``marginal_distributions`` combination should reject before pandas
# gets a chance to raise its own margins-related errors. ``None`` /
# ``""`` / ``"actual"`` are the no-op sentinels — anything else must
# be a known percent mode.
percent_mode: Optional[str] = None
if show_values_as not in (None, "", "actual"):
if show_values_as not in _PERCENT_MODES:
raise InvalidPostProcessingError(
_(
"Unsupported show_values_as value: %(mode)s. "
"Expected one of: percent_row, percent_col, percent_total, actual.",
mode=show_values_as,
)
)
if marginal_distributions:
# The pivot would carry an "All" margin row and/or column;
# summing across the axis would double-count by including the
# margin as part of its own denominator. Combining ``margins``
# with ``show_values_as`` needs a first-class design (probably
# computing percentages on the non-margin subset and then
# re-inserting the margin totals as-is), which is out of scope
# here. Reject explicitly rather than silently returning wrong
# numbers.
raise InvalidPostProcessingError(
_(
"show_values_as is not yet supported when "
"marginal_distributions is enabled."
)
)
# ``show_values_as`` divides each cell by the sum of its axis, so
# it is only meaningful when that sum equals the rollup the DB
# would compute. For non-additive aggregates (mean, median, min,
# max, distinct count, …) the summed per-cell values are not the
# row/column/grand rollup, and the exports would disagree with
# the chart. Reject up front rather than emit numbers that mix
# with the DB rollup incorrectly.
non_additive = [
name
for name, cfg in aggregates.items()
if not isinstance(cfg.get("operator"), str)
or cfg["operator"] not in _ADDITIVE_OPERATORS
]
if non_additive:
raise InvalidPostProcessingError(
_(
"show_values_as is only supported for additive aggregates "
"(sum, count); got non-additive operator(s) for: "
"%(metrics)s.",
metrics=", ".join(non_additive),
)
)
percent_mode = show_values_as
if columns and column_fill_value:
df[columns] = df[columns].fillna(value=column_fill_value)
@@ -338,19 +163,6 @@ def pivot( # pylint: disable=too-many-arguments # noqa: C901
elif pivot_key_set and not df.empty:
df = df.drop(df.columns.difference(pivot_key_set), axis=PandasAxis.COLUMN)
# Apply the ``show_values_as`` percent transform BEFORE the
# ``combine_value_with_metric`` reshape, not after. The reshape below
# swaps the column ``MultiIndex`` level order from ``(metric, category)``
# to ``(category, metric)``; if the percent transform runs against the
# post-reshape shape, its per-metric iteration
# (``df.columns.get_level_values(0).unique()``) walks categories thinking
# they are metrics, mixing metrics and producing wrong percentages. See
# sadpandajoe's finding on #42976. Running percent first keeps the
# per-metric-isolation invariant intact; the reshape then runs on the
# already-normalized values without changing them further.
if percent_mode is not None:
df = _apply_show_values_as(df, percent_mode)
if combine_value_with_metric:
# dropna=False preserves restored all-NaN metric rows that would otherwise
# be silently dropped by stack's default dropna=True behavior.
@@ -86,24 +86,16 @@ ALLOWLIST_CUMULATIVE_FUNCTIONS = (
PROPHET_TIME_GRAIN_MAP: dict[str, str] = {
TimeGrain.SECOND: "s",
TimeGrain.FIVE_SECONDS: "5s",
TimeGrain.THIRTY_SECONDS: "30s",
TimeGrain.MINUTE: "min",
TimeGrain.FIVE_MINUTES: "5min",
TimeGrain.TEN_MINUTES: "10min",
TimeGrain.FIFTEEN_MINUTES: "15min",
TimeGrain.THIRTY_MINUTES: "30min",
# An alternate ISO-8601 spelling of THIRTY_MINUTES that a number of engine
# specs expose instead; the two denote the same interval.
TimeGrain.HALF_HOUR: "30min",
TimeGrain.HOUR: "h",
TimeGrain.SIX_HOURS: "6h",
TimeGrain.DAY: "D",
TimeGrain.WEEK: "W",
TimeGrain.MONTH: "ME" if _PANDAS_VERSION >= (2, 2) else "M",
TimeGrain.QUARTER: "QE" if _PANDAS_VERSION >= (2, 2) else "Q",
# An alternate ISO-8601 spelling of QUARTER, as with HALF_HOUR above.
TimeGrain.QUARTER_YEAR: "QE" if _PANDAS_VERSION >= (2, 2) else "Q",
TimeGrain.YEAR: "YE" if _PANDAS_VERSION >= (2, 2) else "A",
TimeGrain.WEEK_STARTING_SUNDAY: "W-SUN",
TimeGrain.WEEK_STARTING_MONDAY: "W-MON",
-77
View File
@@ -72,11 +72,6 @@ class EntityVersionInfo:
version: int | None = None
transaction_id: int | None = None
version_uuid: str | None = None
#: Resolved uuid of the entity itself, carried so callers that need a
#: concurrency token for an entity with no version rows yet don't have to
#: re-run the ``SELECT uuid`` this helper already issued. Not part of the
#: API response.
entity_uuid: UUID | None = None
def _capture_enabled() -> bool:
@@ -128,7 +123,6 @@ def current_entity_version_info(
version=version,
transaction_id=transaction_id,
version_uuid=str(version_uuid) if version_uuid else None,
entity_uuid=entity_uuid,
)
@@ -150,77 +144,6 @@ def current_entity_etag_uuid(
return str(version_uuid) if version_uuid else None
# Sentinel Continuum transaction id for an entity that has no version rows
# yet. Continuum sequences start at 1, so it can never collide with a real
# one, and the derived uuid stops matching the moment the first version row
# lands — which is exactly the transition a concurrency guard must catch.
_UNVERSIONED_TRANSACTION_ID = 0
def unversioned_entity_token(entity_uuid: UUID) -> str:
"""Concurrency token for an entity Continuum hasn't versioned yet."""
return str(VersionDAO.derive_version_uuid(entity_uuid, _UNVERSIONED_TRANSACTION_ID))
def entity_concurrency_token(
model_cls: type[Model],
entity_id: int | None,
entity_uuid: UUID | None,
) -> str | None:
"""Resolve the optimistic-concurrency validator for *entity*.
Differs from :func:`current_entity_etag_uuid` in what it does for an
entity with no version rows: baseline rows are written lazily, on the
first update after the versioning migration, so a never-since-saved
entity has none. Reporting ``None`` there would leave the *first*
concurrent save on every such entity unguarded the exact case a
two-tab race hits on a pristine entity. Those entities get a
deterministic unversioned token instead.
``None`` still means "no validator exists": capture is off, or the
entity is missing.
"""
if entity_id is None or entity_uuid is None or not _capture_enabled():
return None
return current_entity_etag_uuid(
model_cls, entity_id, entity_uuid
) or unversioned_entity_token(entity_uuid)
def lock_entity_for_update(model_cls: type[Model], entity_id: int | None) -> None:
"""Row-lock *entity* so a conditional write's check and its update are atomic.
``If-Match`` is verified against a read taken before the update command
runs. Without a lock two overlapping requests can both read the same live
version, both pass the check, and then commit one after the other,
reintroducing the lost update the check exists to prevent. The lock is
held until the command commits, because both run in the same scoped
session.
Renders no ``FOR UPDATE`` on SQLite, which serialises writers anyway.
"""
try:
# The PUT route declares ``/<pk>`` (a string segment), so a non-numeric
# id must not raise a SQL cast error ahead of the command's 404.
entity_id = int(entity_id) # type: ignore[arg-type]
except (TypeError, ValueError):
return
db.session.execute(
sa.select(model_cls.id).where(model_cls.id == entity_id).with_for_update()
)
def concurrency_token_from(info: EntityVersionInfo) -> str | None:
"""Concurrency token for an already-resolved :class:`EntityVersionInfo`.
Lets a write endpoint reuse the pre-update version lookup it already
made rather than issuing a second one.
"""
if info.entity_uuid is None:
return None
return info.version_uuid or unversioned_entity_token(info.entity_uuid)
# Maps the versioned model class name to the keyword argument
# ``security_manager.raise_for_access`` expects for the per-resource
# gate. Slice → ``chart=``, Dashboard → ``dashboard=``, SqlaTable →
-44
View File
@@ -22,7 +22,6 @@ from typing import TYPE_CHECKING
from uuid import UUID
import sqlalchemy as sa
from flask import request
from flask_appbuilder import Model
from superset.extensions import db
@@ -77,46 +76,3 @@ def set_version_etag_by_uuid(
response,
VersionDAO.current_live_version_uuid(model_cls, entity_id, entity_uuid),
)
class StaleEntityError(Exception):
"""The request's ``If-Match`` doesn't match the entity's live version."""
def _entity_tag(tag: str) -> str:
"""Strip the content-coding suffix ``Flask-Compress`` appends to ETags.
A compressed response legitimately carries a different validator than the
identity one Flask-Compress rewrites ``"<uuid>"`` to ``"<uuid>:zstd"``
(see ``flask_compress``) so a client replaying the ETag it read never
matches the raw version uuid. Version uuids contain no ``:``, so cutting
at the first one recovers the entity identity from either form.
"""
return tag.split(":", 1)[0]
def is_conditional_write() -> bool:
"""Whether the request carries an ``If-Match`` precondition."""
return bool(request.if_match)
def raise_for_stale_write(current_version_uuid: str | None) -> None:
"""Enforce ``If-Match`` on a write request, if the client sent one.
Clients that read an entity's ``ETag`` may replay it as ``If-Match`` on a
subsequent write to get optimistic concurrency: the write is rejected when
the entity moved on in the meantime, instead of silently clobbering
whatever landed in between.
The condition is skipped rather than failing closed when the caller
has no validator to offer (``ENABLE_VERSIONING_CAPTURE`` off). Failing
closed there would block every conditional write on deployments running
without version capture, and those are no worse off than before they sent
the header.
"""
if_match = request.if_match
if not if_match or if_match.star_tag or current_version_uuid is None:
return
live = _entity_tag(str(current_version_uuid))
if not any(_entity_tag(tag) == live for tag in if_match.as_set(True)):
raise StaleEntityError()
+1
View File
@@ -687,6 +687,7 @@ class Superset(BaseSupersetView):
return json_success(json.dumps(sanitize_datasource_data(datasource.data)))
@event_logger.log_this
@has_access
@expose("/language_pack/<lang>/")
def language_pack(self, lang: str) -> FlaskResponse:
# Only allow expected language formats like "en", "pt_BR", etc.
@@ -1,340 +1,295 @@
# 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 unittest.mock import patch # noqa: F401
import pytest
from flask.ctx import AppContext
from flask_appbuilder.security.sqla.models import User
from sqlalchemy.orm import Session # noqa: F401
from superset import db
from superset.commands.dashboard.exceptions import (
DashboardAccessDeniedError, # noqa: F401
)
from superset.commands.temporary_cache.entry import Entry
from superset.extensions import cache_manager
from superset.models.dashboard import Dashboard
from superset.temporary_cache.utils import cache_key
from superset.utils import json
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices, # noqa: F401
load_world_bank_data, # noqa: F401
)
from tests.integration_tests.test_app import app # noqa: F401
KEY = "test-key"
INITIAL_VALUE = json.dumps({"test": "initial value"})
UPDATED_VALUE = json.dumps({"test": "updated value"})
@pytest.fixture
def dashboard_id(app_context: AppContext, load_world_bank_dashboard_with_slices) -> int: # noqa: F811
dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
return dashboard.id
@pytest.fixture
def admin_id(app_context: AppContext) -> int:
admin = db.session.query(User).filter_by(username="admin").one_or_none()
return admin.id
@pytest.fixture(autouse=True)
def cache(dashboard_id, admin_id):
entry: Entry = {"owner": admin_id, "value": INITIAL_VALUE}
cache_manager.filter_state_cache.set(cache_key(dashboard_id, KEY), entry)
def test_post(test_client, login_as_admin, dashboard_id: int):
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state",
json={
"value": INITIAL_VALUE,
},
)
assert resp.status_code == 201
def test_post_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state",
json={
"value": 1234,
},
)
assert resp.status_code == 400
def test_post_bad_request_non_json_string(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": "foo",
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
assert resp.status_code == 400
def test_post_access_denied(test_client, login_as, dashboard_id: int):
login_as("gamma")
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
assert resp.status_code == 404
def test_post_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key == second_key
def test_post_different_key_for_different_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=2", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_post_different_key_for_no_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_put(test_client, login_as_admin, dashboard_id: int):
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": UPDATED_VALUE,
},
)
assert resp.status_code == 200
def test_put_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key == second_key
def test_put_different_key_for_different_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=2", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_put_different_key_for_no_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_put_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": 1234,
},
)
assert resp.status_code == 400
def test_put_bad_request_non_json_string(
test_client, login_as_admin, dashboard_id: int
):
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": "foo",
},
)
assert resp.status_code == 400
def test_put_access_denied(test_client, login_as, dashboard_id: int):
login_as("gamma")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": UPDATED_VALUE,
},
)
assert resp.status_code == 404
@patch("superset.commands.dashboard.filter_state.create.check_access")
def test_post_authenticated_user_with_access(
mock_check_access, test_client, login_as, dashboard_id: int
):
login_as("alpha")
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
assert resp.status_code == 201
mock_check_access.assert_called_once_with(dashboard_id)
@patch("superset.commands.dashboard.filter_state.create.check_access")
@patch("superset.commands.dashboard.filter_state.update.check_access")
def test_put_authenticated_user_with_access(
mock_update_check_access,
mock_create_check_access,
test_client,
login_as,
dashboard_id: int,
):
login_as("alpha")
payload = {
"value": INITIAL_VALUE,
}
post_resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
assert post_resp.status_code == 201
key = json.loads(post_resp.data.decode("utf-8"))["key"]
put_payload = {
"value": UPDATED_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{key}", json=put_payload
)
assert resp.status_code == 200
mock_create_check_access.assert_called_once_with(dashboard_id)
mock_update_check_access.assert_called_once_with(dashboard_id)
def test_get_key_not_found(test_client, login_as_admin, dashboard_id: int):
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/unknown-key/")
assert resp.status_code == 404
def test_get_dashboard_not_found(test_client, login_as_admin):
resp = test_client.get(f"api/v1/dashboard/{-1}/filter_state/{KEY}")
assert resp.status_code == 404
def test_get_dashboard_filter_state(test_client, login_as_admin, dashboard_id: int):
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 200
data = json.loads(resp.data.decode("utf-8"))
assert INITIAL_VALUE == data.get("value")
def test_get_access_denied(test_client, login_as, dashboard_id):
login_as("gamma")
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 404
def test_delete(test_client, login_as_admin, dashboard_id: int):
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 200
def test_delete_access_denied(test_client, login_as, dashboard_id: int):
login_as("gamma")
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 404
def test_delete_not_owner(test_client, login_as, dashboard_id: int):
login_as("gamma")
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 404
# 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 unittest.mock import patch # noqa: F401
import pytest
from flask.ctx import AppContext
from flask_appbuilder.security.sqla.models import User
from sqlalchemy.orm import Session # noqa: F401
from superset import db
from superset.commands.dashboard.exceptions import (
DashboardAccessDeniedError, # noqa: F401
)
from superset.commands.temporary_cache.entry import Entry
from superset.extensions import cache_manager
from superset.models.dashboard import Dashboard
from superset.temporary_cache.utils import cache_key
from superset.utils import json
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices, # noqa: F401
load_world_bank_data, # noqa: F401
)
from tests.integration_tests.test_app import app # noqa: F401
KEY = "test-key"
INITIAL_VALUE = json.dumps({"test": "initial value"})
UPDATED_VALUE = json.dumps({"test": "updated value"})
@pytest.fixture
def dashboard_id(app_context: AppContext, load_world_bank_dashboard_with_slices) -> int: # noqa: F811
dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
return dashboard.id
@pytest.fixture
def admin_id(app_context: AppContext) -> int:
admin = db.session.query(User).filter_by(username="admin").one_or_none()
return admin.id
@pytest.fixture(autouse=True)
def cache(dashboard_id, admin_id):
entry: Entry = {"owner": admin_id, "value": INITIAL_VALUE}
cache_manager.filter_state_cache.set(cache_key(dashboard_id, KEY), entry)
def test_post(test_client, login_as_admin, dashboard_id: int):
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state",
json={
"value": INITIAL_VALUE,
},
)
assert resp.status_code == 201
def test_post_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state",
json={
"value": 1234,
},
)
assert resp.status_code == 400
def test_post_bad_request_non_json_string(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": "foo",
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
assert resp.status_code == 400
def test_post_access_denied(test_client, login_as, dashboard_id: int):
login_as("gamma")
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
assert resp.status_code == 404
def test_post_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key == second_key
def test_post_different_key_for_different_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=2", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_post_different_key_for_no_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.post(
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_put(test_client, login_as_admin, dashboard_id: int):
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": UPDATED_VALUE,
},
)
assert resp.status_code == 200
def test_put_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key == second_key
def test_put_different_key_for_different_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=2", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_put_different_key_for_no_tab_id(
test_client, login_as_admin, dashboard_id: int
):
payload = {
"value": INITIAL_VALUE,
}
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
first_key = data.get("key")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
)
data = json.loads(resp.data.decode("utf-8"))
second_key = data.get("key")
assert first_key != second_key
def test_put_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": 1234,
},
)
assert resp.status_code == 400
def test_put_bad_request_non_json_string(
test_client, login_as_admin, dashboard_id: int
):
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": "foo",
},
)
assert resp.status_code == 400
def test_put_access_denied(test_client, login_as, dashboard_id: int):
login_as("gamma")
resp = test_client.put(
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
json={
"value": UPDATED_VALUE,
},
)
assert resp.status_code == 404
def test_get_key_not_found(test_client, login_as_admin, dashboard_id: int):
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/unknown-key/")
assert resp.status_code == 404
def test_get_dashboard_not_found(test_client, login_as_admin):
resp = test_client.get(f"api/v1/dashboard/{-1}/filter_state/{KEY}")
assert resp.status_code == 404
def test_get_dashboard_filter_state(test_client, login_as_admin, dashboard_id: int):
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 200
data = json.loads(resp.data.decode("utf-8"))
assert INITIAL_VALUE == data.get("value")
def test_get_access_denied(test_client, login_as, dashboard_id):
login_as("gamma")
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 404
def test_delete(test_client, login_as_admin, dashboard_id: int):
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 200
def test_delete_access_denied(test_client, login_as, dashboard_id: int):
login_as("gamma")
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 404
def test_delete_not_owner(test_client, login_as, dashboard_id: int):
login_as("gamma")
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
assert resp.status_code == 404
@@ -1750,10 +1750,6 @@ class TestRolePermission(SupersetTestCase):
# user/tenant data) as content-addressed scripts; must load for
# anonymous principals (login page, embedded dashboards).
["Superset", "language_pack_script"],
# Language pack endpoint serves JS bundle translations, no auth
# needed; embedded dashboards fetch this without a guest-token
# header, so it must be reachable unauthenticated.
["Superset", "language_pack"],
]
unsecured_views = []
for view_class in appbuilder.baseviews:
@@ -1,66 +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.
"""Unit tests for DashboardFilterStateRestApi."""
import inspect
from superset.commands.dashboard.filter_state.create import CreateFilterStateCommand
from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand
from superset.commands.dashboard.filter_state.get import GetFilterStateCommand
from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
from superset.temporary_cache.api import TemporaryCacheRestApi
def test_dashboard_filter_state_rest_api_inheritance():
"""Ensure DashboardFilterStateRestApi correctly subclasses TemporaryCacheRestApi."""
assert issubclass(DashboardFilterStateRestApi, TemporaryCacheRestApi)
assert (
DashboardFilterStateRestApi.class_permission_name
== "DashboardFilterStateRestApi"
)
assert DashboardFilterStateRestApi.resource_name == "dashboard"
assert DashboardFilterStateRestApi.openapi_spec_tag == "Dashboard Filter State"
def test_dashboard_filter_state_command_factories():
"""Ensure factory methods return the expected command classes."""
api = DashboardFilterStateRestApi()
assert api.get_create_command() is CreateFilterStateCommand
assert api.get_update_command() is UpdateFilterStateCommand
assert api.get_get_command() is GetFilterStateCommand
assert api.get_delete_command() is DeleteFilterStateCommand
def test_post_put_methods_have_no_has_access_api_or_api_decorator():
"""
Ensure post and put methods are not decorated with @has_access_api or @api.
Because DashboardFilterStateRestApi is a temporary cache API, permission
verification is handled dynamically at the command level via
CheckAccessDataCommand. @has_access_api causes 401 Unauthorized for regular
users due to missing FAB permissions. The @api wrapper would catch and convert
uncaught auth errors into 500s.
"""
source_post = inspect.getsource(DashboardFilterStateRestApi.post)
source_put = inspect.getsource(DashboardFilterStateRestApi.put)
assert "has_access_api" not in source_post
assert "has_access_api" not in source_put
assert not any(line.strip().startswith("@api") for line in source_post.splitlines())
assert not any(line.strip().startswith("@api") for line in source_put.splitlines())
-85
View File
@@ -217,91 +217,6 @@ def test_handle_filters_args_returns_request_scoped_filters(
assert fresh_filters.get_joined_filters.call_count == 2
def _create_dataset(name: str) -> Any:
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(db.session.get_bind())
dataset = SqlaTable(
table_name=name,
database=Database(database_name=f"{name}_db", sqlalchemy_uri="sqlite://"),
)
db.session.add(dataset)
db.session.flush()
return dataset
def test_put_dataset_rejects_stale_if_match(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""
A PUT carrying an ``If-Match`` from an older version is refused with 412.
"""
from superset.versioning.api_helpers import EntityVersionInfo
dataset = _create_dataset("test_put_stale_if_match")
with patch(
"superset.datasets.api.current_entity_version_info",
return_value=EntityVersionInfo(
version=1,
transaction_id=2,
version_uuid="new",
entity_uuid=dataset.uuid,
),
):
response = client.put(
f"/api/v1/dataset/{dataset.id}",
json={"description": "from a stale tab"},
headers={"If-Match": '"old"'},
)
assert response.status_code == 412
assert response.headers["ETag"] == '"new"'
db.session.expire(dataset)
assert dataset.description is None
def test_put_dataset_guards_a_dataset_with_no_version_rows(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""Baseline rows are written lazily on the first update, so a dataset that
has never been saved has no version rows it must still be guarded, or
the first concurrent save on every pristine dataset goes unprotected.
"""
from superset.versioning.api_helpers import (
EntityVersionInfo,
unversioned_entity_token,
)
dataset = _create_dataset("test_put_unversioned_guard")
entity_uuid = dataset.uuid
with patch(
"superset.datasets.api.current_entity_version_info",
# A dataset that has since been versioned by another tab's save.
return_value=EntityVersionInfo(
version=0,
transaction_id=1,
version_uuid="written-by-the-other-tab",
entity_uuid=entity_uuid,
),
):
response = client.put(
f"/api/v1/dataset/{dataset.id}",
json={"description": "from the tab that opened first"},
headers={"If-Match": f'"{unversioned_entity_token(entity_uuid)}"'},
)
assert response.status_code == 412
db.session.expire(dataset)
assert dataset.description is None
def test_get_dataset_exposes_certification_metadata(
session: Session,
client: Any,
@@ -14,11 +14,9 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import math
from datetime import datetime
from typing import Any, Optional
from unittest import mock
from unittest.mock import Mock
import pytest
import pytz
@@ -92,37 +90,6 @@ def test_get_column_spec(
assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm)
@pytest.mark.parametrize(
"string_value,expected_float",
[
("NaN", math.nan),
("Infinity", math.inf),
("-Infinity", -math.inf),
],
)
def test_column_type_mutator_double_special_values(
string_value: str, expected_float: float
) -> None:
"""
Presto's coordinator sends results as JSON, which has no literal for
NaN/Infinity/-Infinity, so REAL/DOUBLE columns holding those values
arrive as quoted strings. They must be coerced back to real floats
(inherited from PrestoBaseEngineSpec, shared with TrinoEngineSpec).
"""
from superset.db_engine_specs.presto import PrestoEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [[string_value]]
mock_cursor.description = [("val", "double")]
(result_value,) = PrestoEngineSpec.fetch_data(mock_cursor)[0]
assert isinstance(result_value, float)
if math.isnan(expected_float):
assert math.isnan(result_value)
else:
assert result_value == expected_float
def test_get_schema_from_engine_params() -> None:
"""
Test the ``get_schema_from_engine_params`` method.
@@ -18,10 +18,8 @@
from __future__ import annotations
import copy
import math
from collections import namedtuple
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional
from unittest.mock import MagicMock, Mock, patch
@@ -328,96 +326,6 @@ def test_convert_dttm(
assert_convert_dttm(TrinoEngineSpec, target_type, expected_result, dttm)
@pytest.mark.parametrize(
"data,description,expected_result",
[
(
[["1.846619834", "abc"]],
[("dec", "decimal(12,9)"), ("str", "varchar(3)")],
[(Decimal("1.846619834"), "abc")],
),
(
[[Decimal("1.846619834"), "abc"]],
[("dec", "decimal(12,9)"), ("str", "varchar(3)")],
[(Decimal("1.846619834"), "abc")],
),
(
[["1.846619834", "abc"]],
[("dec", "decimal(12)"), ("str", "varchar(3)")],
[(Decimal("1.846619834"), "abc")],
),
(
[["1.846619834", "abc"]],
[("dec", "decimal"), ("str", "varchar(3)")],
[(Decimal("1.846619834"), "abc")],
),
(
[["1.846619834", "abc"]],
[("dec", "varchar(255)"), ("str", "varchar(3)")],
[["1.846619834", "abc"]],
),
(
[["1.846619834", "abc"]],
[("val", "double"), ("str", "varchar(3)")],
[(1.846619834, "abc")],
),
(
[[1.846619834, "abc"]],
[("val", "real"), ("str", "varchar(3)")],
[(1.846619834, "abc")],
),
],
)
def test_column_type_mutator(
data: list[Any],
description: list[Any],
expected_result: list[Any],
) -> None:
"""
Trino's DBAPI driver can return DECIMAL columns as plain strings.
Superset must coerce those back to ``Decimal`` at fetch time so that
downstream numeric post-processing (e.g. a pivot with a mean
aggregate) doesn't choke on a string value.
"""
from superset.db_engine_specs.trino import TrinoEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = data
mock_cursor.description = description
assert TrinoEngineSpec.fetch_data(mock_cursor) == expected_result
@pytest.mark.parametrize(
"string_value,expected_float",
[
("NaN", math.nan),
("Infinity", math.inf),
("-Infinity", -math.inf),
],
)
def test_column_type_mutator_double_special_values(
string_value: str, expected_float: float
) -> None:
"""
Trino's wire protocol has no JSON literal for NaN/Infinity/-Infinity, so
REAL/DOUBLE columns holding those values arrive as quoted strings. They
must be coerced back to real floats, same as string-typed DECIMALs.
"""
from superset.db_engine_specs.trino import TrinoEngineSpec
mock_cursor = Mock()
mock_cursor.fetchall.return_value = [[string_value]]
mock_cursor.description = [("val", "double")]
(result_value,) = TrinoEngineSpec.fetch_data(mock_cursor)[0]
assert isinstance(result_value, float)
if math.isnan(expected_float):
assert math.isnan(result_value)
else:
assert result_value == expected_float
def test_get_extra_table_metadata(mocker: MockerFixture) -> None:
from superset.db_engine_specs.trino import TrinoEngineSpec
+1 -24
View File
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
from flask_appbuilder.security.sqla.models import User
from jinja2.exceptions import TemplateSyntaxError
from pytest import raises # noqa: PT013
from pytest_mock import MockerFixture
@@ -31,7 +30,7 @@ from superset.commands.exceptions import (
DatasourceNotFoundValidationError,
QueryNotFoundValidationError,
)
from superset.exceptions import SupersetSecurityException, SupersetTemplateException
from superset.exceptions import SupersetSecurityException
from superset.utils.core import DatasourceType, override_user
dataset_find_by_id = "superset.daos.dataset.DatasetDAO.find_by_id"
@@ -341,28 +340,6 @@ def test_query_has_access(mocker: MockerFixture) -> None:
)
def test_query_malformed_jinja_template(mocker: MockerFixture) -> None:
"""
``raise_for_access(query=...)`` Jinja-renders the query's SQL to resolve
the tables it touches. A malformed template must surface as a
``SupersetTemplateException``, not the raw ``jinja2`` exception.
"""
from superset.explore.utils import check_datasource_access
from superset.models.sql_lab import Query
mocker.patch(query_find_by_id, return_value=Query())
mocker.patch(
raise_for_access,
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
)
with raises(SupersetTemplateException): # noqa: PT012
check_datasource_access(
datasource_id=1,
datasource_type=DatasourceType.QUERY,
)
def test_query_no_access(mocker: MockerFixture, client) -> None:
from superset.connectors.sqla.models import SqlaTable
from superset.explore.utils import check_datasource_access
-53
View File
@@ -5054,56 +5054,3 @@ def test_adhoc_type_probe_does_not_get_sampling_retry(
table.adhoc_column_to_sqla(adhoc_col)
retry.assert_not_called()
def test_filter_adhoc_column(database: Database) -> None:
"""
Test that filter works with adhoc column labels.
When filter contains a string that matches the label of an adhoc column
in the columns list, it should correctly convert to a SQLAlchemy column
instead of raising QueryObjectValidationError.
"""
from superset.connectors.sqla.models import SqlaTable, TableColumn
table = SqlaTable(
table_name="test_table",
database=database,
columns=[
TableColumn(column_name="name", type="TEXT"),
TableColumn(column_name="real_name", type="TEXT"),
],
)
# Should not raise QueryObjectValidationError
result = table.get_sqla_query(
columns=[
"name",
{
"expressionType": "SQL",
"label": "full_name",
"sqlExpression": "real_name",
},
],
orderby=[],
metrics=[],
extras={},
filter=[
{"col": "full_name", "op": "ILIKE", "val": "Zona%"}
], # Filter by adhoc column label
granularity=None,
is_timeseries=False,
)
assert result is not None
# Verify the WHERE predicate uses the resolved adhoc expression and value.
with database.get_sqla_engine() as engine:
sql = str(
result.sqla_query.compile(
dialect=engine.dialect,
compile_kwargs={"literal_binds": True},
)
)
assert "real_name AS full_name" in sql
assert "WHERE" in sql
assert "lower(real_name) LIKE lower('Zona%')" in sql
@@ -446,428 +446,3 @@ def test_pivot_only_entirely_absent_metrics_are_restored():
assert ("metric_partial", "A") in df.columns
assert ("metric_partial", "B") not in df.columns
assert df[("metric_partial", "A")].iloc[0] == 1.0
# --- show_values_as regression tests (#42809) --------------------------------
#
# ``show_values_as`` expresses each metric cell as a fraction of the row,
# column, or grand total after pivoting. Mirrors the client-side
# ``fractionOf`` semantic in
# ``plugin-chart-pivot-table/src/react-pivottable/utilities.ts:739`` so
# server-side rendering paths (CSV / XLSX exports, scheduled reports)
# match the browser output. See #42809.
#
# Fixture: a tiny 3-column DataFrame that keeps row/col/grand totals easy
# to eyeball. Two rows (``r1``, ``r2``), two columns (``c1``, ``c2``),
# single metric ``v``. Grand total is 100 so every percent-of-total
# assertion is trivially checkable.
def _show_values_as_fixture() -> DataFrame:
"""Long-format input that pivots to::
v
col c1 c2
row
r1 10 20
r2 30 40
row totals: r1=30, r2=70; col totals: c1=40, c2=60; grand=100.
"""
return DataFrame(
{
"row": ["r1", "r1", "r2", "r2"],
"col": ["c1", "c2", "c1", "c2"],
"v": [10, 20, 30, 40],
}
)
def test_pivot_show_values_as_actual_is_noop() -> None:
"""``show_values_as='actual'`` (and ``None``) leaves values unchanged."""
df = _show_values_as_fixture()
aggregates = {"v": {"operator": "sum"}}
baseline = pivot(df=df, index=["row"], columns=["col"], aggregates=aggregates)
for mode in (None, "actual"):
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates=aggregates,
show_values_as=mode,
)
pd.testing.assert_frame_equal(result, baseline)
def test_pivot_show_values_as_percent_row() -> None:
"""Each cell = cell / row-total; each row sums to 1.0."""
result = pivot(
df=_show_values_as_fixture(),
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_row",
)
# r1: 10/30, 20/30; r2: 30/70, 40/70
assert result.loc["r1", ("v", "c1")] == pytest.approx(10 / 30)
assert result.loc["r1", ("v", "c2")] == pytest.approx(20 / 30)
assert result.loc["r2", ("v", "c1")] == pytest.approx(30 / 70)
assert result.loc["r2", ("v", "c2")] == pytest.approx(40 / 70)
assert result.sum(axis=1).tolist() == pytest.approx([1.0, 1.0])
def test_pivot_show_values_as_percent_col() -> None:
"""Each cell = cell / column-total; each column sums to 1.0."""
result = pivot(
df=_show_values_as_fixture(),
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_col",
)
# c1 total=40: 10/40, 30/40; c2 total=60: 20/60, 40/60
assert result.loc["r1", ("v", "c1")] == pytest.approx(10 / 40)
assert result.loc["r2", ("v", "c1")] == pytest.approx(30 / 40)
assert result.loc["r1", ("v", "c2")] == pytest.approx(20 / 60)
assert result.loc["r2", ("v", "c2")] == pytest.approx(40 / 60)
assert result.sum(axis=0).tolist() == pytest.approx([1.0, 1.0])
def test_pivot_show_values_as_percent_total() -> None:
"""Each cell = cell / grand-total; the whole frame sums to 1.0."""
result = pivot(
df=_show_values_as_fixture(),
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_total",
)
# grand=100: each cell divided by 100
assert result.loc["r1", ("v", "c1")] == pytest.approx(0.10)
assert result.loc["r1", ("v", "c2")] == pytest.approx(0.20)
assert result.loc["r2", ("v", "c1")] == pytest.approx(0.30)
assert result.loc["r2", ("v", "c2")] == pytest.approx(0.40)
assert result.values.sum() == pytest.approx(1.0)
def test_pivot_show_values_as_preserves_nan_numerator() -> None:
"""A NaN/NULL numerator stays NaN — matches the client-side #42810 guard
that a genuine SQL NULL should render blank, not "0.0%".
The fixture uses a **missing** (row, col) combination ``r1`` has no
``c2`` row so ``pivot_table`` produces a genuine NaN cell for
(``r1``, ``c2``). Using a ``NaN`` *input value* with ``operator='sum'``
would not exercise this path because ``pandas`` ``.sum(skipna=True)``
on a single-value ``[NaN]`` group returns ``0.0``, not ``NaN``.
"""
df = DataFrame(
{
"row": ["r1", "r2", "r2"], # r1 has no c2 row → post-pivot NaN
"col": ["c1", "c1", "c2"],
"v": [10, 30, 40],
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_row",
)
# The genuinely-NaN cell stays NaN through the percent transform.
assert pd.isna(result.loc["r1", ("v", "c2")])
# The other cell in the same row divides correctly against just its
# own value (row total is 10 since c2 is NaN and skipna=True).
assert result.loc["r1", ("v", "c1")] == pytest.approx(1.0)
def test_pivot_show_values_as_percent_total_zero_grand_total_yields_nan() -> None:
"""Grand total of zero yields NaN cells rather than Infinity — matches the
client's ``if (acc === null) return null`` division-by-zero guard."""
df = DataFrame(
{
"row": ["r1", "r1", "r2", "r2"],
"col": ["c1", "c2", "c1", "c2"],
"v": [0, 0, 0, 0],
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_total",
)
# No cell should be Infinity or a real number; all should be NaN.
assert result.isna().values.all()
def test_pivot_show_values_as_percent_row_multi_metric_keeps_metrics_separate() -> None:
"""On a multi-metric pivot (``MultiIndex`` columns), per-row totals are
computed *within each metric*. Metric A's percentages must sum to 1.0
per row independent of metric B's values."""
df = DataFrame(
{
"row": ["r1", "r1", "r2", "r2"],
"col": ["c1", "c2", "c1", "c2"],
"a": [10, 20, 30, 40],
"b": [1, 3, 5, 7],
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"a": {"operator": "sum"}, "b": {"operator": "sum"}},
show_values_as="percent_row",
)
# Metric ``a``: row totals 30 and 70; each row of ``a`` sums to 1.
assert result["a"].sum(axis=1).tolist() == pytest.approx([1.0, 1.0])
# Metric ``b``: row totals 4 and 12; each row of ``b`` sums to 1.
assert result["b"].sum(axis=1).tolist() == pytest.approx([1.0, 1.0])
# Metric ``a`` percentages must not be contaminated by metric ``b`` values.
assert result.loc["r1", ("a", "c1")] == pytest.approx(10 / 30)
assert result.loc["r1", ("b", "c1")] == pytest.approx(1 / 4)
def test_pivot_show_values_as_invalid_mode_raises() -> None:
"""An unknown ``show_values_as`` value raises ``InvalidPostProcessingError``
rather than silently falling through to a no-op."""
with pytest.raises(InvalidPostProcessingError):
pivot(
df=_show_values_as_fixture(),
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_of_moon",
)
def test_pivot_show_values_as_empty_string_is_noop() -> None:
"""Empty-string ``show_values_as`` is treated as a no-op alongside
``None`` and ``"actual"`` it must NOT reach the percent-mode
validator (which would raise on it) or silently divide.
"""
df = _show_values_as_fixture()
aggregates = {"v": {"operator": "sum"}}
baseline = pivot(df=df, index=["row"], columns=["col"], aggregates=aggregates)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates=aggregates,
show_values_as="",
)
pd.testing.assert_frame_equal(result, baseline)
def test_pivot_show_values_as_percent_total_flat_multi_metric() -> None:
"""A multi-metric pivot with **no** ``columns`` groupby produces a
**flat** column index each column IS its own metric. ``percent_total``
must divide each metric column by its OWN grand total (never mixing
metrics), otherwise one metric's magnitude changes another metric's
percentages.
"""
df = DataFrame({"row": ["r1", "r2"], "a": [10, 30], "b": [1, 3]})
result = pivot(
df=df,
index=["row"],
aggregates={"a": {"operator": "sum"}, "b": {"operator": "sum"}},
show_values_as="percent_total",
)
# Each metric column sums to 1.0 independently.
assert result["a"].sum() == pytest.approx(1.0)
assert result["b"].sum() == pytest.approx(1.0)
# And metric a's magnitude (10, 30 → grand 40) doesn't leak into
# metric b's percentages (which use grand 4).
assert result.loc["r1", "a"] == pytest.approx(10 / 40)
assert result.loc["r1", "b"] == pytest.approx(1 / 4)
def test_pivot_show_values_as_percent_row_zero_row_total_yields_nan() -> None:
"""A row whose values sum to zero yields NaN cells in that row rather
than ``Infinity``/``NaN`` from division-by-zero. Other rows still
divide correctly.
"""
df = DataFrame(
{
"row": ["r1", "r1", "r2", "r2"],
"col": ["c1", "c2", "c1", "c2"],
"v": [0, 0, 30, 40], # r1's row-total is 0
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_row",
)
assert pd.isna(result.loc["r1", ("v", "c1")])
assert pd.isna(result.loc["r1", ("v", "c2")])
# r2 still divides correctly against its own row-total (70).
assert result.loc["r2", ("v", "c1")] == pytest.approx(30 / 70)
def test_pivot_show_values_as_percent_col_zero_col_total_yields_nan() -> None:
"""A column whose values sum to zero yields NaN cells in that column
rather than ``Infinity``/``NaN`` from division-by-zero. Other columns
still divide correctly.
"""
df = DataFrame(
{
"row": ["r1", "r1", "r2", "r2"],
"col": ["c1", "c2", "c1", "c2"],
"v": [0, 20, 0, 40], # c1's column-total is 0
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_col",
)
assert pd.isna(result.loc["r1", ("v", "c1")])
assert pd.isna(result.loc["r2", ("v", "c1")])
# c2 still divides correctly against its own column-total (60).
assert result.loc["r1", ("v", "c2")] == pytest.approx(20 / 60)
def test_pivot_show_values_as_with_marginal_distributions_raises() -> None:
"""``show_values_as`` combined with ``marginal_distributions`` would
include the ``All`` margin row/column in the row/column/grand-total
denominators, producing wrong percentages. Combining the two needs a
first-class design; for now the combination raises loudly rather than
silently returning wrong numbers.
"""
with pytest.raises(InvalidPostProcessingError, match="marginal_distributions"):
pivot(
df=_show_values_as_fixture(),
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
marginal_distributions=True,
show_values_as="percent_row",
)
def test_pivot_show_values_as_with_combine_value_with_metric_preserves_per_metric() -> (
None
):
"""Regression test for sadpandajoe's finding on #42976.
``combine_value_with_metric`` reshapes the column ``MultiIndex`` from
``(metric, category)`` to ``(category, metric)``. Historically the
``show_values_as`` transform ran *after* this reshape, so its per-metric
iteration walked categories thinking they were metrics mixing metric
magnitudes and producing wrong percentages (e.g. metric ``a``'s row
would sum to ~1.78 instead of 1.0 because metric ``b``'s values leaked
into ``a``'s denominators).
The transform now runs *before* the reshape so per-metric isolation
stays intact regardless of the final column layout.
"""
df = DataFrame(
{
"row": ["r1", "r1", "r2", "r2"],
"col": ["c1", "c2", "c1", "c2"],
"a": [10, 20, 30, 40],
"b": [1, 3, 5, 7],
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"a": {"operator": "sum"}, "b": {"operator": "sum"}},
combine_value_with_metric=True,
show_values_as="percent_row",
)
# After combine_value_with_metric, the column MultiIndex is
# ``(category, metric)``. Per-metric row sums are pulled via cross-section
# on level 1 (the metric axis).
for metric, expected in (("a", [1.0, 1.0]), ("b", [1.0, 1.0])):
per_metric = result.xs(metric, axis=1, level=1)
assert per_metric.sum(axis=1).tolist() == pytest.approx(expected), (
f"metric {metric!r} rows must each sum to 1.0 after "
"percent_row on a combined pivot; got contamination from "
"other metrics"
)
# And the actual values match the natural per-metric percentages,
# not the mixed-metric ones that the bug produced.
assert result.loc["r1", ("c1", "a")] == pytest.approx(10 / 30)
assert result.loc["r1", ("c1", "b")] == pytest.approx(1 / 4)
def test_pivot_show_values_as_rejects_non_additive_aggregate() -> None:
"""``show_values_as`` requires additive aggregates.
For a ``mean`` aggregate, the summed per-cell values are not the
row/column/grand rollup the DB would compute over the underlying
rows, so ``cell / sum(cells)`` disagrees with the "share of the
real row total" the chart shows. Reject up front rather than emit
numbers that mix with the DB rollup incorrectly.
"""
with pytest.raises(InvalidPostProcessingError, match="additive"):
pivot(
df=_show_values_as_fixture(),
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "mean"}},
show_values_as="percent_row",
)
def test_pivot_show_values_as_on_empty_pivot_returns_empty_frame() -> None:
"""Empty inputs must not crash the percent transform.
An empty pivot with a column grouping has a ``MultiIndex`` with zero
level-0 groups; the metric-iteration loop then feeds ``pd.concat``
an empty list and raises ``ValueError: No objects to concatenate``.
The empty frame should pass through unchanged.
"""
empty = DataFrame({"row": [], "col": [], "v": []}).astype(
{"row": str, "col": str, "v": float}
)
result = pivot(
df=empty,
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_row",
)
assert result.empty
def test_pivot_show_values_as_preserves_structural_nan() -> None:
"""Structurally-missing cells (no input rows for that (row, col)) stay NaN.
NULL preservation is scoped to the structural case: cells that
``pivot_table`` left as ``NaN`` because no input row exists for that
(row, column) group must render as blank (``NaN``), not as ``0%``.
Value-is-NULL cells are a separate case documented on
``_apply_show_values_as``.
"""
df = DataFrame(
{
"row": ["r1", "r2", "r2"],
"col": ["c1", "c1", "c2"],
"v": [10.0, 30.0, 40.0],
}
)
result = pivot(
df=df,
index=["row"],
columns=["col"],
aggregates={"v": {"operator": "sum"}},
show_values_as="percent_row",
)
# r1 has no c2 row → cell is structurally missing → stays NaN.
assert pd.isna(result.loc["r1", ("v", "c2")])
# r1's row-total is just c1 (10.0), so c1 is 100%.
assert result.loc["r1", ("v", "c1")] == pytest.approx(1.0)
@@ -392,49 +392,3 @@ def test_prophet_does_not_clamp_yhat_below_zero_for_negative_actuals():
forecast_periods = 2
forecast_yhat = result["balance__yhat"].iloc[-forecast_periods:]
assert (forecast_yhat < 0).any()
def test_prophet_every_time_grain_is_mapped():
"""No `TimeGrain` may be missing from `PROPHET_TIME_GRAIN_MAP`.
Engine specs offer these grains in the Time Grain control, and the
`ChartDataProphetOptionsSchema.time_grain` field advertises them in the
OpenAPI spec, so an unmapped grain surfaces as "Unsupported time grain"
on a value the UI and the API both present as valid.
"""
from superset.constants import TimeGrain
from superset.utils.pandas_postprocessing.utils import PROPHET_TIME_GRAIN_MAP
unmapped = [
grain.name for grain in TimeGrain if grain not in PROPHET_TIME_GRAIN_MAP
]
assert not unmapped, f"TimeGrain values missing a pandas frequency: {unmapped}"
@pytest.mark.parametrize(
"time_grain",
["PT5S", "PT30S", "PT0.5H", "PT6H", "P0.25Y"],
)
def test_prophet_previously_unmapped_time_grains(time_grain):
"""These five grains raised "Unsupported time grain" despite being offered
by engine specs such as Postgres, SQLite, Presto and Druid."""
df = prophet(
df=prophet_df, time_grain=time_grain, periods=3, confidence_interval=0.9
)
assert {"a__yhat", "a__yhat_upper", "a__yhat_lower"} <= set(df.columns)
def test_prophet_alias_time_grains_match_their_canonical_form():
"""`HALF_HOUR`/`QUARTER_YEAR` are alternate ISO-8601 spellings of
`THIRTY_MINUTES`/`QUARTER` and must resolve to the same frequency."""
from superset.constants import TimeGrain
from superset.utils.pandas_postprocessing.utils import PROPHET_TIME_GRAIN_MAP
assert (
PROPHET_TIME_GRAIN_MAP[TimeGrain.HALF_HOUR]
== PROPHET_TIME_GRAIN_MAP[TimeGrain.THIRTY_MINUTES]
)
assert (
PROPHET_TIME_GRAIN_MAP[TimeGrain.QUARTER_YEAR]
== PROPHET_TIME_GRAIN_MAP[TimeGrain.QUARTER]
)
+35
View File
@@ -5578,6 +5578,41 @@ def test_parse_predicate_length_check() -> None:
stmt.parse_predicate("x" * 101)
def test_parse_predicate_invalid_sql_raises_superset_parse_error() -> None:
"""
A syntactically invalid RLS predicate raises ``SupersetParseError``.
``parse_predicate`` is reachable via ``apply_rls`` for any RLS clause
configured on a queried table; an invalid clause must surface as the
typed 422 parse error rather than leaking a raw ``sqlglot`` exception.
"""
stmt = SQLStatement("SELECT 1", "postgresql")
with pytest.raises(SupersetParseError) as excinfo:
stmt.parse_predicate("a >")
assert excinfo.value.status == 422
def test_parse_predicate_sqlglot_error_raises_superset_parse_error(
mocker: MockerFixture,
) -> None:
"""
A non-``ParseError`` ``sqlglot`` failure also surfaces as a typed error.
``parse_predicate`` catches the generic ``SqlglotError`` base class as a
fallback so any sqlglot failure (e.g. tokenize errors) is converted into a
``SupersetParseError`` rather than leaking a raw sqlglot exception.
"""
# Build the statement before patching, since the constructor also parses.
stmt = SQLStatement("SELECT 1", "postgresql")
mocker.patch(
"sqlglot.parse_one",
side_effect=sqlglot.errors.SqlglotError("boom"),
)
with pytest.raises(SupersetParseError) as excinfo:
stmt.parse_predicate("a > 1")
assert excinfo.value.status == 422
@pytest.mark.usefixtures("_small_parse_cap")
def test_transpile_to_dialect_length_check() -> None:
"""
-109
View File
@@ -1,109 +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 uuid import UUID
import pytest
from flask import Flask
from superset.versioning.etag import raise_for_stale_write, StaleEntityError
LIVE = "9f1f4c1e-0000-4000-8000-000000000001"
ENTITY = UUID("9f1f4c1e-0000-4000-8000-0000000000aa")
def _put(app: Flask, if_match: str | None):
headers = {"If-Match": if_match} if if_match is not None else {}
return app.test_request_context("/api/v1/dataset/1", method="PUT", headers=headers)
def test_no_if_match_header_passes(app: Flask) -> None:
with _put(app, None):
raise_for_stale_write(LIVE)
def test_matching_if_match_passes(app: Flask) -> None:
with _put(app, f'"{LIVE}"'):
raise_for_stale_write(LIVE)
def test_star_if_match_passes(app: Flask) -> None:
with _put(app, "*"):
raise_for_stale_write(LIVE)
def test_compressed_if_match_passes(app: Flask) -> None:
"""Flask-Compress rewrites the ETag of a compressed response to
``"<uuid>:<algorithm>"``; a client replaying that must still match."""
with _put(app, f'"{LIVE}:zstd"'):
raise_for_stale_write(LIVE)
def test_compressed_stale_if_match_still_raises(app: Flask) -> None:
with _put(app, '"9f1f4c1e-0000-4000-8000-000000000002:gzip"'):
with pytest.raises(StaleEntityError):
raise_for_stale_write(LIVE)
def test_stale_if_match_raises(app: Flask) -> None:
with _put(app, '"9f1f4c1e-0000-4000-8000-000000000002"'):
with pytest.raises(StaleEntityError):
raise_for_stale_write(LIVE)
def test_if_match_list_containing_live_passes(app: Flask) -> None:
with _put(app, f'"9f1f4c1e-0000-4000-8000-000000000002", "{LIVE}"'):
raise_for_stale_write(LIVE)
def test_no_validator_available_passes(app: Flask) -> None:
"""Version capture off (or no version rows yet) degrades to an
unconditional write rather than blocking every save."""
with _put(app, f'"{LIVE}"'):
raise_for_stale_write(None)
def test_unversioned_token_is_stable_and_entity_specific() -> None:
"""A not-yet-versioned entity still gets a validator, derived from its own
uuid so two such entities never share one."""
from superset.versioning.api_helpers import unversioned_entity_token
other = UUID("9f1f4c1e-0000-4000-8000-0000000000ff")
assert unversioned_entity_token(ENTITY) == unversioned_entity_token(ENTITY)
assert unversioned_entity_token(ENTITY) != unversioned_entity_token(other)
def test_unversioned_token_differs_from_first_real_version(app: Flask) -> None:
"""The first version row must invalidate the unversioned token, or the
first concurrent save on a pristine entity would go unguarded."""
from superset.daos.version import derive_version_uuid
from superset.versioning.api_helpers import unversioned_entity_token
stale = unversioned_entity_token(ENTITY)
first_real = str(derive_version_uuid(ENTITY, 1))
assert stale != first_real
with _put(app, f'"{stale}"'):
with pytest.raises(StaleEntityError):
raise_for_stale_write(first_real)
def test_unversioned_token_matches_while_still_unversioned(app: Flask) -> None:
from superset.versioning.api_helpers import unversioned_entity_token
token = unversioned_entity_token(ENTITY)
with _put(app, f'"{token}"'):
raise_for_stale_write(token)
+1 -35
View File
@@ -16,14 +16,11 @@
# under the License.
"""Tests for superset.views.base module"""
from typing import Any, TYPE_CHECKING
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from superset.app import SupersetApp
@patch("superset.views.base.utils.get_user_id", return_value=1)
@patch(
@@ -318,34 +315,3 @@ def test_deprecated_new_target_message_has_no_stray_space() -> None:
formatted = template % tuple(args)
assert "5.0.0. Use the following API endpoint instead" in formatted
assert "5.0.0 . Use" not in formatted
def test_language_pack_endpoint_is_public(app: "SupersetApp") -> None:
"""The language pack endpoint must be accessible without authentication.
Translation data is non-sensitive and the embedded dashboard SPA needs to
fetch it with a bare ``fetch()`` (no guest-token header). Previously the
endpoint was protected by ``@has_access`` which caused a 302 redirect to
``/login/`` for unauthenticated requests, silently breaking i18n in
embedded dashboards (issue #42433).
When the compiled catalog exists the endpoint returns 200 with JSON.
When it is missing the endpoint returns 404 (never a 302 to /login/).
"""
with app.test_client() as client:
resp = client.get("/language_pack/en/")
# The endpoint must be reachable without authentication.
# In the test environment compiled catalogs may not exist, so
# a 404 is acceptable; any 3xx redirect would indicate the old
# @has_access guard is still in place.
assert resp.status_code in (200, 404)
assert not (300 <= resp.status_code < 400)
if resp.status_code == 200:
assert resp.is_json
def test_language_pack_endpoint_rejects_invalid_lang(app: "SupersetApp") -> None:
"""Invalid language codes are rejected with 400."""
with app.test_client() as client:
resp = client.get("/language_pack/zz/")
assert resp.status_code in (400, 404)