Compare commits

..
Author SHA1 Message Date
Elizabeth Thompson b4fac82c39 fix(explore): catch TemplateError when validating access for query-backed form_data
check_query_access() calls raise_for_access(query=query), which Jinja-renders
the query's SQL to resolve table references. A malformed template surfaces
as a raw jinja2.exceptions.TemplateError instead of a Superset exception,
leaking as an opaque 500 from the explore form_data endpoints (used by the
chart Explore/Drill-by cache) whenever datasource_type=query.

Wrap the call and re-raise as the existing SupersetTemplateException (422),
matching the same conversion already used in datasets/api.py, and map it to
a proper response in ExploreFormDataRestApi's four handlers.
2026-08-24 16:41:44 +00:00
121 changed files with 1810 additions and 5976 deletions
@@ -105,7 +105,6 @@ jobs:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-alert: false
summary-always: true
@@ -53,6 +53,12 @@ jobs:
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
# errors via pytest.ini's `filterwarnings` to actually run in CI, so a
# regression on those fails the build instead of relying on a
# contributor remembering to set this locally. See the migration
# battleplan: https://github.com/apache/superset/discussions/40273
SQLALCHEMY_WARN_20: "1"
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-1
View File
@@ -58,7 +58,6 @@ the old counter to use the outcome-specific replacements.
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
- [43388](https://github.com/apache/superset/pull/43388): The MCP service now refuses to start (`MCPAuthConfigError`) if `MCP_DEV_USERNAME` and `MCP_AUTH_ENABLED = True` are both set, and separately if `MCP_AUTH_ENABLED = True` but no usable JWT key material is configured (RSA key/JWKS, or an explicit `MCP_JWT_SECRET` for HMAC) — both previously started with authentication silently weaker than configured. Deployments combining a dev-mode username with JWT auth enabled, or enabling JWT auth without key material, must pick one before upgrading: unset `MCP_DEV_USERNAME` for a real auth deployment, or unset `MCP_AUTH_ENABLED` (or configure the key material) for a dev-mode one. Response caching (`MCP_CACHE_CONFIG["enabled"] = True`) now also excludes every tool with a side effect by default, not only a partial list, so a previously-cached mutating tool call is no longer served from cache; no config change is needed to pick this up.
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
+1 -6
View File
@@ -782,18 +782,13 @@ Enable response caching for read-heavy workloads (dashboards/datasets that don't
```python
MCP_CACHE_CONFIG = {
"enabled": True,
# Cache keys don't include the requesting principal and hits are served
# ahead of auth/RBAC, so a shared cache can return one caller's response
# to another. Required for caching to actually start -- only appropriate
# when every request is guaranteed to come from the same principal.
"dangerously_share_cache_across_principals": True,
"CACHE_KEY_PREFIX": "mcp_cache_",
"call_tool_ttl": 3600,
}
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
```
Every tool with a side effect (create/update/delete/execute) is always excluded from caching regardless of this setting -- see the `excluded_tools` default in `superset/mcp_service/mcp_config.py` for the current list.
Mutating tools (`generate_chart`, `update_chart`, `execute_sql`, `generate_dashboard`) are always excluded from caching regardless of this setting.
---
@@ -26,8 +26,7 @@ page and its menu entry are hidden, and deletes are permanent as before.
## Finding archived objects
Open **Recently Archived** and pick a type — **Chart**, **Dashboard**, or
**Dataset** (shown as **Datasource** when semantic layers are enabled) — from
the Type selector. The view shows one type at a time; each
**Dataset** — from the Type selector. The view shows one type at a time; each
type is read from its own list endpoint, so the same row-level access rules that
govern the normal lists apply here.
+5 -5
View File
@@ -58,11 +58,11 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.9",
"@storybook/addon-docs": "^10.5.8",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.0",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.15",
"@swc/core": "^1.15.47",
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.13",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.9",
"storybook": "^10.5.8",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
+101 -101
View File
@@ -3789,7 +3789,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/select@~1.10.0", "@rc-component/select@~1.10.1":
"@rc-component/select@~1.10.0":
version "1.10.1"
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
@@ -3824,7 +3824,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/table@~1.11.1":
"@rc-component/table@~1.11.0":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
@@ -3866,7 +3866,7 @@
"@rc-component/util" "^1.7.0"
clsx "^2.1.1"
"@rc-component/tree-select@~1.16.1":
"@rc-component/tree-select@~1.16.0":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
@@ -4122,23 +4122,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.9.tgz#6d871977f7ad833dc142d12ee43490105dec4d07"
integrity sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==
"@storybook/addon-docs@^10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.9"
"@storybook/csf-plugin" "10.5.8"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.9"
"@storybook/react-dom-shim" "10.5.8"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz#805e4c93a1704b220351d62bb0c74ce3b78c5e10"
integrity sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==
"@storybook/csf-plugin@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
dependencies:
unplugin "^2.3.5"
@@ -4152,10 +4152,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz#549793845bb8b966acd36002d33d7b54cc5c92d4"
integrity sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==
"@storybook/react-dom-shim@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -4855,86 +4855,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.0.tgz#8c5a2af031c62ebcb6354aa6975bfb7eac895223"
integrity sha512-SJQPl+xG/zB8bNjC/gTg3WOmOvz7EzlQD+VShfCKFYPNr2qvb+vATUY11vYEjnMWCn6wV8H8eAtjQrVflYyX5A==
"@swc/core-darwin-arm64@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz#345ce6a1bf4033da189c2e3eff1244190195d15b"
integrity sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==
"@swc/core-darwin-x64@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.16.0.tgz#0d2496c0429d7e8bc45b50348adf9d105bb56793"
integrity sha512-ql2JVch8V5t1i+HxiiuD4oVDI1dOku4/e3QiCkplONrm3SLitqNAP+nztHN51fSG2IgGuOwpAi3hgA+ukT5yQg==
"@swc/core-darwin-x64@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz#f3debf50b5c1602bf392acb412bd33fd6d7e4f98"
integrity sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==
"@swc/core-linux-arm-gnueabihf@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.0.tgz#5f02a85842a04cd21f2ab9e8e67dc4b16f7024a4"
integrity sha512-PcdDBaRbe39y37h1rXVkhNy7mEU7f8b34KD761C68R23EsfMsj5oDPVddRzGdSRAvwwSfH0WSNEHgYmc/AJipg==
"@swc/core-linux-arm-gnueabihf@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz#14a247a12c6d3de1ee63fa4fdbf5a4302936b5d6"
integrity sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==
"@swc/core-linux-arm64-gnu@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.0.tgz#6264498c88c51649511c6b4af532d330d3cf0631"
integrity sha512-t21IUztHQ/COucy7Kk9eIlehmq08H/hYq7aRA6fZox3S5ddi6TxWPK6e5S/+aTCf6+Od9qQ+LIpjHMiTy737vA==
"@swc/core-linux-arm64-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz#3b8d09c481ae51c7b72d98fb6ce98f7b90065a1a"
integrity sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==
"@swc/core-linux-arm64-musl@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.0.tgz#7a451eba69aa9a80799b9b8b9af46bf6f49803bd"
integrity sha512-d9+iajbMB87b0umgbP+Gy3yBDSDgty4Q6H5pZ8fgTb/dOoKIwwynP4L4kvWCOFg2i49kxmAAUs1uJZh9s0E+RQ==
"@swc/core-linux-arm64-musl@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz#7ff2baa16e67b29017fdf7c6b69e40de7920ce1a"
integrity sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==
"@swc/core-linux-ppc64-gnu@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.0.tgz#99a7ba46a56190a52c646506e940dffe554c5d10"
integrity sha512-QRpeKGOg+B0qmo3BFU+6rL/gpoKYYJ7OFSMf5DNMafohYZ/iq2qvAH9Gcrf8NxROj3iooKOVewJ+YgahH1nSLw==
"@swc/core-linux-ppc64-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz#a3841982fe2eb2d889648c8e212b6d821db316d6"
integrity sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==
"@swc/core-linux-s390x-gnu@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.0.tgz#61473e056d1dd0d4690352a875c14f41bdd9f60a"
integrity sha512-q+Vr/hmHCcRXT/WFzOJC+T6GGEEtq2iaTtmyLfxO7yzu4ckgcqSNkg9m181wfNhuMwfNBoBhOfwQCnLsGZ5F4g==
"@swc/core-linux-s390x-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz#edbfd705d6285f7dce48915871478bc9603904c3"
integrity sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==
"@swc/core-linux-x64-gnu@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.0.tgz#008fc149a9135bca92b1e1f63037e2612c4d0fb5"
integrity sha512-DWVBc3QnpsSgKoq8N4rmZeZa5r/XrHdLkITsExN/tvTdqPtAPDPt+Ysy33OfgBlyN8lNe4xwsXWe6DXlRkJeRQ==
"@swc/core-linux-x64-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz#e7f61a7771d6a9b5b274521ba61809b3d7644325"
integrity sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==
"@swc/core-linux-x64-musl@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.0.tgz#4300ea0c63864dc3989ca0e956b4a5e4c666196c"
integrity sha512-6XCgDSc1HPf/5dpjvABhKHICiBcsuZyW3hQMkn8sxel0TqprkJGp+H4iaBYIUTPixhrBub2hBPtfjcZLE6yL3w==
"@swc/core-linux-x64-musl@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz#7c1ef8305444bcc7894de177fe225f2d8f3be609"
integrity sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==
"@swc/core-win32-arm64-msvc@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.0.tgz#1d4146b7c1aada2992692cdc72bb0b43a885136e"
integrity sha512-T/+9VVCZJ3AKEth9IP3U9AJ2YscQq+7LUqRTvfR4a2q36+Ri22oOwUizpAKOqQ42vb2Y/kOa4TOcJOfHoDIT/w==
"@swc/core-win32-arm64-msvc@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz#953856d26b28956d1a18ef10e5f221202b2cb8f1"
integrity sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==
"@swc/core-win32-ia32-msvc@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.0.tgz#c5c2a60905ffa9e4647214bef75778f0c73ba0d4"
integrity sha512-Pr1lsR/PMs8ndL0UWMrW8nLZ7H7sspIxBRDdjL8f+YJ/FJNASgzfunbVVXAqj0csgIJYHPZy+OW9smjFmk1Rcg==
"@swc/core-win32-ia32-msvc@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz#2743a5bccc49f252c23bad3135193640cbdcef3a"
integrity sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==
"@swc/core-win32-x64-msvc@1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.0.tgz#67dd85a90437e6fa9951cce7842f6cac3ec3f60d"
integrity sha512-ktdeYLgOQdaonvsj5tJijqgpb0wk7gfF80wCFVA0kucI1hhSUIyfcGbjo5+9sdqv38OhMnTdLoA6xbqgOgPQjw==
"@swc/core-win32-x64-msvc@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz#9674ad0c9187b7cbe5cc3080b31b960d3ee688b9"
integrity sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==
"@swc/core@^1.15.40", "@swc/core@^1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.16.0.tgz#79cd13789725d3e3ad0df605dc88d9e255d7ebfd"
integrity sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==
"@swc/core@^1.15.40", "@swc/core@^1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.47.tgz#6226e842160e247eb79a9aeac1095ebddb56639f"
integrity sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.28"
"@swc/types" "^0.1.27"
optionalDependencies:
"@swc/core-darwin-arm64" "1.16.0"
"@swc/core-darwin-x64" "1.16.0"
"@swc/core-linux-arm-gnueabihf" "1.16.0"
"@swc/core-linux-arm64-gnu" "1.16.0"
"@swc/core-linux-arm64-musl" "1.16.0"
"@swc/core-linux-ppc64-gnu" "1.16.0"
"@swc/core-linux-s390x-gnu" "1.16.0"
"@swc/core-linux-x64-gnu" "1.16.0"
"@swc/core-linux-x64-musl" "1.16.0"
"@swc/core-win32-arm64-msvc" "1.16.0"
"@swc/core-win32-ia32-msvc" "1.16.0"
"@swc/core-win32-x64-msvc" "1.16.0"
"@swc/core-darwin-arm64" "1.15.47"
"@swc/core-darwin-x64" "1.15.47"
"@swc/core-linux-arm-gnueabihf" "1.15.47"
"@swc/core-linux-arm64-gnu" "1.15.47"
"@swc/core-linux-arm64-musl" "1.15.47"
"@swc/core-linux-ppc64-gnu" "1.15.47"
"@swc/core-linux-s390x-gnu" "1.15.47"
"@swc/core-linux-x64-gnu" "1.15.47"
"@swc/core-linux-x64-musl" "1.15.47"
"@swc/core-win32-arm64-msvc" "1.15.47"
"@swc/core-win32-ia32-msvc" "1.15.47"
"@swc/core-win32-x64-msvc" "1.15.47"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -5021,10 +5021,10 @@
"@swc/html-win32-ia32-msvc" "1.15.43"
"@swc/html-win32-x64-msvc" "1.15.43"
"@swc/types@^0.1.28":
version "0.1.28"
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.28.tgz#e3cd892383fba3b8904c40518bbe1265a50753f2"
integrity sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==
"@swc/types@^0.1.27":
version "0.1.27"
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.27.tgz#12080b0c426dea450634f202d9a3c82ac396e793"
integrity sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==
dependencies:
"@swc/counter" "^0.1.3"
@@ -6181,10 +6181,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
antd@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6217,16 +6217,16 @@ antd@^6.6.1:
"@rc-component/rate" "~1.0.1"
"@rc-component/resize-observer" "^1.1.2"
"@rc-component/segmented" "~1.3.0"
"@rc-component/select" "~1.10.1"
"@rc-component/select" "~1.10.0"
"@rc-component/slider" "~1.1.1"
"@rc-component/steps" "~1.2.2"
"@rc-component/switch" "~1.0.3"
"@rc-component/table" "~1.11.1"
"@rc-component/table" "~1.11.0"
"@rc-component/tabs" "~1.12.0"
"@rc-component/tooltip" "~1.5.0"
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/tree-select" "~1.16.1"
"@rc-component/tree-select" "~1.16.0"
"@rc-component/trigger" "^3.10.1"
"@rc-component/upload" "~1.1.1"
"@rc-component/util" "^1.12.0"
@@ -6522,10 +6522,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.15, baseline-browser-mapping@^2.9.19:
version "2.11.15"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz#9c0cac93d7d304f3d61bb41088a102cd62e68676"
integrity sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
version "2.11.13"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
batch@0.6.1:
version "0.6.1"
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.9:
version "10.5.9"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.9.tgz#61f476fd73785dcf09e9198ddf404b9b8c06964a"
integrity sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==
storybook@^10.5.8:
version "10.5.8"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
+8 -8
View File
@@ -80,7 +80,7 @@ dependencies = [
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
# Flask-AppBuilder workaround. Tracking issue:
# https://github.com/apache/superset/issues/33162
"marshmallow>=4.3.1, <5",
"marshmallow>=3.0, <5",
"marshmallow-union>=0.1.15.post1",
"msgpack>=1.2.0, <1.3",
"nh3>=0.3.5, <0.4",
@@ -101,7 +101,7 @@ dependencies = [
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"pygeohash",
"pyarrow>=25.0.1, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyarrow>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.3, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=5.0.0, <9.0",
@@ -111,10 +111,10 @@ dependencies = [
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"slack_sdk>=3.43.0, <4",
"sqlalchemy>=2.0.52, <2.1",
"sqlalchemy>=2.0.0, <2.1",
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.17.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
"sqlglot>=30.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
# newer pandas needs 0.9+
"tabulate>=0.10.0, <1.0",
"typing-extensions>=4.16.0, <5",
@@ -141,7 +141,7 @@ bigquery = [
"sqlalchemy-bigquery>=1.17.2",
"google-cloud-bigquery>=3.42.3",
]
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
@@ -197,7 +197,7 @@ fastmcp = [
# landed (discussion #40273).
firebird = ["sqlalchemy-firebird>=2.2.0"]
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
gevent = ["gevent>=26.8.0"]
gevent = ["gevent>=26.7.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
hive = [
@@ -232,7 +232,7 @@ playwright = ["playwright>=1.62.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.338.0"]
prophet = ["prophet>=1.4.0, <2"]
prophet = ["prophet>=1.3.0, <2"]
# sqlalchemy-redshift cuts hard from SQLAlchemy 1.4-only (0.8.x) to 2.0-only
# (>=1.0.0) with no dual-compat release. Bumped now that Superset's own
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
@@ -255,7 +255,7 @@ tdengine = [
"taospy>=2.8.10",
"taos-ws-py>=0.7.0"
]
teradata = ["teradatasql>=20.0.0.65"]
teradata = ["teradatasql>=20.0.0.64"]
thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
+1 -1
View File
@@ -30,7 +30,7 @@ cryptography>=50.0.0,<51.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.4.1,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
pyarrow>=25.0.1,<26.0.0
pyarrow>=24.0.0,<26.0.0
# Security: CVE-2026-27459 - pyopenssl certificate validation
pyopenssl>=26.0.0,<27.0.0
# Security: CVE-2026-25645 (MEDIUM) - Insecure Temporary File
+4 -4
View File
@@ -222,7 +222,7 @@ markupsafe==3.0.2
# mako
# werkzeug
# wtforms
marshmallow==4.3.1
marshmallow==4.3.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
@@ -287,7 +287,7 @@ prison==0.2.1
# via flask-appbuilder
prompt-toolkit==3.0.51
# via click-repl
pyarrow==25.0.1
pyarrow==25.0.0
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -381,7 +381,7 @@ six==1.17.0
# wtforms-json
slack-sdk==3.43.0
# via apache-superset (pyproject.toml)
sqlalchemy==2.0.52
sqlalchemy==2.0.51
# via
# apache-superset (pyproject.toml)
# alembic
@@ -399,7 +399,7 @@ sqlalchemy-utils==0.42.1
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==30.17.0
sqlglot==30.16.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
+8 -6
View File
@@ -337,7 +337,7 @@ geopy==2.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
gevent==26.8.0
gevent==26.7.0
# via apache-superset
google-api-core==2.33.0
# via
@@ -434,6 +434,8 @@ importlib-metadata==8.7.0
# via
# keyring
# opentelemetry-api
importlib-resources==6.5.2
# via prophet
iniconfig==2.0.0
# via pytest
isodate==0.7.2
@@ -528,7 +530,7 @@ markupsafe==3.0.2
# mako
# werkzeug
# wtforms
marshmallow==4.3.1
marshmallow==4.3.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -691,7 +693,7 @@ prompt-toolkit==3.0.51
# via
# -c requirements/base-constraint.txt
# click-repl
prophet==1.4.0
prophet==1.3.0
# via apache-superset
proto-plus==1.25.0
# via google-api-core
@@ -709,7 +711,7 @@ psycopg2-binary==2.9.12
# via apache-superset
py-key-value-aio==0.4.4
# via fastmcp-slim
pyarrow==25.0.1
pyarrow==25.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -948,7 +950,7 @@ slack-sdk==3.43.0
# apache-superset
sniffio==1.3.1
# via anyio
sqlalchemy==2.0.52
sqlalchemy==2.0.51
# via
# -c requirements/base-constraint.txt
# alembic
@@ -974,7 +976,7 @@ sqlalchemy-utils==0.42.1
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==30.17.0
sqlglot==30.16.0
# via
# -c requirements/base-constraint.txt
# apache-superset
-1
View File
@@ -42,7 +42,6 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
PATTERNS = {
"python": [
r"^\.github/workflows/.*python",
r"^\.github/workflows/frontend-bundle-size-nightly\.yml$",
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
r"^docker-compose-image-tag\.yml$",
r"^tests/",
+348 -182
View File
@@ -45,9 +45,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.8.0",
"@rjsf/core": "^6.7.1",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.8.0",
"@rjsf/validator-ajv8": "^6.7.1",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -81,12 +81,12 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -180,14 +180,14 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.9",
"@storybook/addon-links": "10.5.9",
"@storybook/react-webpack5": "10.5.9",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -218,7 +218,7 @@
"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.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -235,7 +235,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.9",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -266,7 +266,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -9946,9 +9946,9 @@
}
},
"node_modules/@rc-component/select": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz",
"integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.0.tgz",
"integrity": "sha512-u/3yuF2kEXvTJXPy3P7qkVBkGGZcQo+m1uTuQqJa6qCnwdmDoEn1Rs3zTTi6y/RsLGiQGM0drPN5c/RfflCnow==",
"license": "MIT",
"dependencies": {
"@rc-component/overflow": "^1.0.0",
@@ -10340,9 +10340,9 @@
"license": "MIT"
},
"node_modules/@rjsf/core": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.8.0.tgz",
"integrity": "sha512-HZ2e/l/QNcz8PTslBXyGWkiycMe3LgIfwgXo0qO3DPgyFKj4G+obJTKBq/W+DaIUmz1HicvH5WtOdXWWqK+gbA==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.1.tgz",
"integrity": "sha512-/CQfIGUzcXceBNRhEH3wsTvxcT8dMrjPLXhYSfcJUTftKxOCdisqi2wFwt7LOyIvFvzHUxag0r0xXs/MXS9jmA==",
"license": "Apache-2.0",
"dependencies": {
"lodash": "^4.18.1",
@@ -10354,19 +10354,19 @@
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.8.0",
"@rjsf/utils": "^6.7.1",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.8.0.tgz",
"integrity": "sha512-gHcqPFSHdOz29tZiLlzDvD+Gfq21zVIFBufprSYTiHpUdcVNJEK6+V5aw++FtQncHdDWnTf2YCky/e1Bol2NEQ==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.7.1.tgz",
"integrity": "sha512-6goBapMwyHcXvjLkCnFs4S3P1oKUi1H083BdPk4pDZALFWn5ZdG50ECNfHSddBmL3O0pyx1/WFq1C/MuR7Y54A==",
"license": "Apache-2.0",
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.2",
"fast-uri": "^4.1.1",
"jsonpointer": "^5.0.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
@@ -10380,9 +10380,9 @@
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.8.0.tgz",
"integrity": "sha512-F36I952/miMFZzWSlupwFHbl+j+5bVQ3tR6HtBS+vXV10kY5dT3OwTjbFRo4fQM1KpqrUZC0t3/E5CZRX1o1jA==",
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.1.tgz",
"integrity": "sha512-oG9reR8VgUUTxfsO8WybZWTjKs6SLUdhmUCp55SXmJvwVbeKZ+Mz4SI+y+T1Mdpbm1kLZWQPRyF2Md97soWXkw==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.20.0",
@@ -10394,7 +10394,7 @@
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.8.0"
"@rjsf/utils": "^6.7.1"
}
},
"node_modules/@rtsao/scc": {
@@ -10765,16 +10765,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.9.tgz",
"integrity": "sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.8.tgz",
"integrity": "sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.9",
"@storybook/csf-plugin": "10.5.8",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.9",
"@storybook/react-dom-shim": "10.5.8",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10785,7 +10785,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10794,9 +10794,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz",
"integrity": "sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10809,7 +10809,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "10.5.9",
"storybook": "10.5.8",
"vite": "*",
"webpack": "*"
},
@@ -10829,9 +10829,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10843,7 +10843,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10855,9 +10855,9 @@
}
},
"node_modules/@storybook/addon-links": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.9.tgz",
"integrity": "sha512-ZDbPl6ia6hqjoV+CpQU3DjkXpc0TxUq6+y/rFD8w21dJMdqNWzY8zajHC8r4CfTWANjM1pGPUYisWtTKi1MxZw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.8.tgz",
"integrity": "sha512-mpWw4alBJVGqgVh897LZ2keN/xnMHcH93wKJG+oGg4+cdEUA+06hCs5T4k+AS5Aa+EZ6LvdOoi2VPHssyQlCCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10870,7 +10870,7 @@
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10964,15 +10964,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.9.tgz",
"integrity": "sha512-mrCJub/WAt6RAU1P+bpBvcdTHMHePXLuNM+TuJl0Sl3r9Ta0YjDvtdODweud9sTEpVN3ao/fk0LpiaDMfLd84w==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.5.9",
"@storybook/preset-react-webpack": "10.5.9",
"@storybook/react": "10.5.9"
"@storybook/builder-webpack5": "10.5.8",
"@storybook/preset-react-webpack": "10.5.8",
"@storybook/react": "10.5.8"
},
"funding": {
"type": "opencollective",
@@ -10981,7 +10981,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10991,13 +10991,13 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.9.tgz",
"integrity": "sha512-XTLC95jP75V9NfhoUzDNKDCn9r0ZqXz4ZhrNzQXVDz4vNWkjU3/uDL6Ywh9WFu6Xrb+onQqSR9hlHjS/DOZj7Q==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.9",
"@storybook/core-webpack": "10.5.8",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -11019,7 +11019,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11028,9 +11028,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11041,17 +11041,17 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.9"
"storybook": "10.5.8"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.9.tgz",
"integrity": "sha512-LOr8SoM2CejVCHeLyxbdRMiKuQb2q95CE5D75oK3+513mMZ8LxVPVApxy8B6FvFYNe9CTqVK+95jETefOqabTw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.8.tgz",
"integrity": "sha512-0JjgVoX5t9Wb+gwddYHx/Ej7KFqwd65lpHXEhBoT4pFWRqVI0pvfHu42M+DRGjsgOye3uE+3pH4yHR3+0/fCHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.9",
"@storybook/core-webpack": "10.5.8",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -11068,7 +11068,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11077,9 +11077,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11090,18 +11090,18 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.9"
"storybook": "10.5.8"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.9.tgz",
"integrity": "sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.9",
"@storybook/react-dom-shim": "10.5.8",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -11114,7 +11114,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -11130,9 +11130,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11144,7 +11144,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -11550,15 +11550,15 @@
}
},
"node_modules/@swc/core": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.0.tgz",
"integrity": "sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.28"
"@swc/types": "^0.1.27"
},
"engines": {
"node": ">=10"
@@ -11568,18 +11568,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.16.0",
"@swc/core-darwin-x64": "1.16.0",
"@swc/core-linux-arm-gnueabihf": "1.16.0",
"@swc/core-linux-arm64-gnu": "1.16.0",
"@swc/core-linux-arm64-musl": "1.16.0",
"@swc/core-linux-ppc64-gnu": "1.16.0",
"@swc/core-linux-s390x-gnu": "1.16.0",
"@swc/core-linux-x64-gnu": "1.16.0",
"@swc/core-linux-x64-musl": "1.16.0",
"@swc/core-win32-arm64-msvc": "1.16.0",
"@swc/core-win32-ia32-msvc": "1.16.0",
"@swc/core-win32-x64-msvc": "1.16.0"
"@swc/core-darwin-arm64": "1.15.47",
"@swc/core-darwin-x64": "1.15.47",
"@swc/core-linux-arm-gnueabihf": "1.15.47",
"@swc/core-linux-arm64-gnu": "1.15.47",
"@swc/core-linux-arm64-musl": "1.15.47",
"@swc/core-linux-ppc64-gnu": "1.15.47",
"@swc/core-linux-s390x-gnu": "1.15.47",
"@swc/core-linux-x64-gnu": "1.15.47",
"@swc/core-linux-x64-musl": "1.15.47",
"@swc/core-win32-arm64-msvc": "1.15.47",
"@swc/core-win32-ia32-msvc": "1.15.47",
"@swc/core-win32-x64-msvc": "1.15.47"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -11591,9 +11591,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.0.tgz",
"integrity": "sha512-SJQPl+xG/zB8bNjC/gTg3WOmOvz7EzlQD+VShfCKFYPNr2qvb+vATUY11vYEjnMWCn6wV8H8eAtjQrVflYyX5A==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz",
"integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==",
"cpu": [
"arm64"
],
@@ -11607,9 +11607,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.0.tgz",
"integrity": "sha512-ql2JVch8V5t1i+HxiiuD4oVDI1dOku4/e3QiCkplONrm3SLitqNAP+nztHN51fSG2IgGuOwpAi3hgA+ukT5yQg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz",
"integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==",
"cpu": [
"x64"
],
@@ -11623,9 +11623,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.0.tgz",
"integrity": "sha512-PcdDBaRbe39y37h1rXVkhNy7mEU7f8b34KD761C68R23EsfMsj5oDPVddRzGdSRAvwwSfH0WSNEHgYmc/AJipg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz",
"integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==",
"cpu": [
"arm"
],
@@ -11639,15 +11639,12 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.0.tgz",
"integrity": "sha512-t21IUztHQ/COucy7Kk9eIlehmq08H/hYq7aRA6fZox3S5ddi6TxWPK6e5S/+aTCf6+Od9qQ+LIpjHMiTy737vA==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz",
"integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11658,15 +11655,12 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.0.tgz",
"integrity": "sha512-d9+iajbMB87b0umgbP+Gy3yBDSDgty4Q6H5pZ8fgTb/dOoKIwwynP4L4kvWCOFg2i49kxmAAUs1uJZh9s0E+RQ==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz",
"integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11677,15 +11671,12 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.0.tgz",
"integrity": "sha512-QRpeKGOg+B0qmo3BFU+6rL/gpoKYYJ7OFSMf5DNMafohYZ/iq2qvAH9Gcrf8NxROj3iooKOVewJ+YgahH1nSLw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz",
"integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11696,15 +11687,12 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.0.tgz",
"integrity": "sha512-q+Vr/hmHCcRXT/WFzOJC+T6GGEEtq2iaTtmyLfxO7yzu4ckgcqSNkg9m181wfNhuMwfNBoBhOfwQCnLsGZ5F4g==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz",
"integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11715,15 +11703,12 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.0.tgz",
"integrity": "sha512-DWVBc3QnpsSgKoq8N4rmZeZa5r/XrHdLkITsExN/tvTdqPtAPDPt+Ysy33OfgBlyN8lNe4xwsXWe6DXlRkJeRQ==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz",
"integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11734,15 +11719,12 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.0.tgz",
"integrity": "sha512-6XCgDSc1HPf/5dpjvABhKHICiBcsuZyW3hQMkn8sxel0TqprkJGp+H4iaBYIUTPixhrBub2hBPtfjcZLE6yL3w==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz",
"integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11753,9 +11735,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.0.tgz",
"integrity": "sha512-T/+9VVCZJ3AKEth9IP3U9AJ2YscQq+7LUqRTvfR4a2q36+Ri22oOwUizpAKOqQ42vb2Y/kOa4TOcJOfHoDIT/w==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz",
"integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==",
"cpu": [
"arm64"
],
@@ -11769,9 +11751,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.0.tgz",
"integrity": "sha512-Pr1lsR/PMs8ndL0UWMrW8nLZ7H7sspIxBRDdjL8f+YJ/FJNASgzfunbVVXAqj0csgIJYHPZy+OW9smjFmk1Rcg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz",
"integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==",
"cpu": [
"ia32"
],
@@ -11785,9 +11767,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.0.tgz",
"integrity": "sha512-ktdeYLgOQdaonvsj5tJijqgpb0wk7gfF80wCFVA0kucI1hhSUIyfcGbjo5+9sdqv38OhMnTdLoA6xbqgOgPQjw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz",
"integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==",
"cpu": [
"x64"
],
@@ -11826,9 +11808,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-15.0.0.tgz",
"integrity": "sha512-B0L0KuItii5XatOskjeFW4kNPXYEDo5JYm+k5Lze3LEY46q4L7foVkXiUFbNn0GjbKJCOv+nU2nM57k4LYLbHw==",
"version": "14.19.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11836,9 +11818,9 @@
}
},
"node_modules/@swc/plugin-transform-imports": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-13.0.0.tgz",
"integrity": "sha512-G8Wp8zX92O5F2YQ8OSqoAbNqPiU7VTLKFBtmN4W0y29SaNUDi8rLwvos5P5J1qdrQP3BmnQnS1wdZioMZXlJmw==",
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-12.5.0.tgz",
"integrity": "sha512-b9ReG4NY9OwIIqXLlTuOb7k4N2yRBl501iNiBEKaiTazpxXxg6nR2XKOPojlyu1yb5YnK3s3EjTZX3DGSIDKNg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11846,9 +11828,9 @@
}
},
"node_modules/@swc/types": {
"version": "0.1.28",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@@ -14928,9 +14910,9 @@
}
},
"node_modules/antd": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.1.tgz",
"integrity": "sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.0.tgz",
"integrity": "sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
@@ -14964,16 +14946,16 @@
"@rc-component/rate": "~1.0.1",
"@rc-component/resize-observer": "^1.1.2",
"@rc-component/segmented": "~1.3.0",
"@rc-component/select": "~1.10.1",
"@rc-component/select": "~1.10.0",
"@rc-component/slider": "~1.1.1",
"@rc-component/steps": "~1.2.2",
"@rc-component/switch": "~1.0.3",
"@rc-component/table": "~1.11.1",
"@rc-component/table": "~1.11.0",
"@rc-component/tabs": "~1.12.0",
"@rc-component/tooltip": "~1.5.0",
"@rc-component/tour": "~2.4.0",
"@rc-component/tree": "~1.4.0",
"@rc-component/tree-select": "~1.16.1",
"@rc-component/tree-select": "~1.16.0",
"@rc-component/trigger": "^3.10.1",
"@rc-component/upload": "~1.1.1",
"@rc-component/util": "^1.12.0",
@@ -15715,9 +15697,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
"version": "2.11.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -18559,9 +18541,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"version": "1.11.22",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -19140,10 +19122,11 @@
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -20236,9 +20219,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.9.tgz",
"integrity": "sha512-4Hrqccy/zttV0S/32TSUbqtizzj4sbkgYvv7UzHveH58v96PhrO+fSpOFAqYvzNDzdOU6smKwObFtzX+RZqj4w==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -20247,7 +20230,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "10.5.9"
"storybook": "10.5.8"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -37734,9 +37717,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.9.tgz",
"integrity": "sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -42985,7 +42968,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -43145,6 +43128,189 @@
"version": "0.20.3",
"license": "Apache-2.0"
},
"plugins/legacy-plugin-chart-calendar": {
"name": "@superset-ui/legacy-plugin-chart-calendar",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3-array": "^3.2.4",
"d3-selection": "^3.0.0",
"d3-tip": "^0.9.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-chord": {
"name": "@superset-ui/legacy-plugin-chart-chord",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
}
},
"plugins/legacy-plugin-chart-country-map": {
"name": "@superset-ui/legacy-plugin-chart-country-map",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-array": "^3.2.4",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-horizon": {
"name": "@superset-ui/legacy-plugin-chart-horizon",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-paired-t-test": {
"name": "@superset-ui/legacy-plugin-chart-paired-t-test",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"distributions": "^2.2.0",
"prop-types": "^15.8.1",
"reactable": "^1.1.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-parallel-coordinates": {
"name": "@superset-ui/legacy-plugin-chart-parallel-coordinates",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3v3": "npm:d3@3.5.17",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-partition": {
"name": "@superset-ui/legacy-plugin-chart-partition",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-hierarchy": "^3.1.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-rose": {
"name": "@superset-ui/legacy-plugin-chart-rose",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"nvd3-fork": "^2.0.5",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-world-map": {
"name": "@superset-ui/legacy-plugin-chart-world-map",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-array": "^3.2.4",
"datamaps": "^0.5.10",
"prop-types": "^15.8.1",
"tinycolor2": "^1.6.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-preset-chart-nvd3": {
"name": "@superset-ui/legacy-preset-chart-nvd3",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.4.12",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"nvd3-fork": "^2.0.5",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.21",
"react": "^18.3.0"
}
},
"plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
+13 -13
View File
@@ -122,9 +122,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.8.0",
"@rjsf/core": "^6.7.1",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.8.0",
"@rjsf/validator-ajv8": "^6.7.1",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -158,12 +158,12 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -257,14 +257,14 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.9",
"@storybook/addon-links": "10.5.9",
"@storybook/react-webpack5": "10.5.9",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -295,7 +295,7 @@
"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.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -312,7 +312,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.9",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -343,7 +343,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -122,12 +122,6 @@ export const timeComparisonControls: ({
}
return newState;
},
// Re-run this control's validation whenever `time_compare` changes so
// the "date required" error clears once a non-custom shift is picked.
// Without it the stale error survives in Redux (see the
// dependantControls path in exploreReducer's SET_FIELD_VALUE handler)
// and blocks further chart updates until a page refresh.
validationDependencies: ['time_compare'],
},
},
],
@@ -17,7 +17,7 @@
* under the License.
*/
import { QueryFormMetric } from '@superset-ui/core';
import { getTotalsMetrics, toTotalsAggregate } from './getTotalsMetrics';
import { getTotalsMetrics } from './getTotalsMetrics';
const simpleMetric = (aggregate: string): QueryFormMetric =>
({
@@ -76,31 +76,4 @@ describe('getTotalsMetrics', () => {
test('returns an empty array when given no metrics', () => {
expect(getTotalsMetrics([], 'AVG')).toEqual([]);
});
test("ORIGINAL keeps each metric's own aggregate", () => {
const metrics = [
simpleMetric('COUNT_DISTINCT'),
sqlMetric(),
savedMetric(),
];
const result = getTotalsMetrics(metrics, 'ORIGINAL');
expect(result).toBe(metrics);
expect(result[0]).toEqual(
expect.objectContaining({ aggregate: 'COUNT_DISTINCT' }),
);
});
});
describe('toTotalsAggregate', () => {
test.each(['SUM', 'AVG'] as const)('passes %s through', value => {
expect(toTotalsAggregate(value)).toBe(value);
});
test.each([undefined, null, '', 'MEDIAN', 'sum'])(
'falls back to ORIGINAL for %p',
value => {
expect(toTotalsAggregate(value)).toBe('ORIGINAL');
},
);
});
@@ -18,46 +18,26 @@
*/
import { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
/**
* How the "Show summary" totals row aggregates each metric.
*
* ``ORIGINAL`` keeps every metric's own aggregation. It is the default because
* overriding is not universally valid: ``SUM`` over a ``COUNT_DISTINCT`` of a
* non-numeric column (a uuid, say) is rejected outright by the database, and
* over a numeric id column it silently produces a meaningless number.
*/
export type TotalsAggregate = 'ORIGINAL' | 'SUM' | 'AVG';
export type TotalsAggregate = 'SUM' | 'AVG';
/**
* Build the metrics for a chart's "Show summary" totals query.
* Build the metrics for a chart's "Show summary" totals query, overriding
* each Simple (adhoc) metric's aggregate function with the user-chosen
* totals aggregate. The totals query has no GROUP BY, so the database
* evaluates each metric fresh over all rows -- swapping the aggregate here
* is a correct, independent computation, not a re-aggregation of
* already-aggregated per-row values.
*
* With SUM or AVG, each Simple (adhoc) metric is cloned with its aggregate
* replaced. The totals query has no GROUP BY, so the database evaluates each
* metric fresh over all rows -- that swap is an independent computation, not a
* re-aggregation of already-aggregated per-row values.
*
* Custom-SQL and saved (string) metrics always pass through unchanged: there is
* no safe way to rewrite an arbitrary SQL expression's aggregate without
* parsing it, so the totals row keeps their own native aggregate.
* Custom-SQL metrics and saved (string) metrics pass through unchanged:
* there is no safe way to rewrite an arbitrary SQL expression's aggregate
* function without parsing it, so the totals row keeps their own native
* aggregate for those.
*/
export function getTotalsMetrics(
metrics: QueryFormMetric[],
aggregate: TotalsAggregate,
): QueryFormMetric[] {
if (aggregate === 'ORIGINAL') {
return metrics;
}
return metrics.map(metric =>
isAdhocMetricSimple(metric) ? { ...metric, aggregate } : metric,
);
}
/**
* Narrow a raw ``totals_aggregate`` form-data value to a TotalsAggregate.
*
* Anything other than an explicit SUM/AVG including charts saved before the
* control existed keeps each metric's own aggregation.
*/
export function toTotalsAggregate(value: unknown): TotalsAggregate {
return value === 'SUM' || value === 'AVG' ? value : 'ORIGINAL';
}
@@ -67,7 +67,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -71,9 +71,6 @@ export type AntdExposedProps = Pick<
| 'virtual'
| 'getPopupContainer'
| 'menuItemSelectedIcon'
// lets a caller with long option labels stop the popup inheriting the
// trigger's width, which otherwise truncates every option
| 'popupMatchSelectWidth'
>;
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
@@ -64,8 +64,9 @@ export default function createSmartNumberFormatter(
description,
formatFunc: value => `${getSign(value)}${formatValue(value)}`,
id:
id ??
(signed ? NumberFormats.SMART_NUMBER_SIGNED : NumberFormats.SMART_NUMBER),
id || signed
? NumberFormats.SMART_NUMBER_SIGNED
: NumberFormats.SMART_NUMBER,
label: label ?? 'Adaptive formatter',
});
}
@@ -24,12 +24,6 @@ describe('createSmartNumberFormatter(options)', () => {
const formatter = createSmartNumberFormatter();
expect(formatter).toBeInstanceOf(NumberFormatter);
});
test('uses the supplied formatter id regardless of signed option', () => {
expect(createSmartNumberFormatter({ id: 'custom' }).id).toBe('custom');
expect(
createSmartNumberFormatter({ id: 'custom-signed', signed: true }).id,
).toBe('custom-signed');
});
describe('using default options', () => {
const formatter = createSmartNumberFormatter();
test('formats 0 correctly', () => {
@@ -96,57 +96,16 @@ export class Menu {
itemText: string,
options?: { timeout?: number },
): Promise<void> {
const popup = await this.openSubmenu(submenuText, {
timeout: options?.timeout,
itemText,
});
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
* Opens a submenu and returns its popup locator, without selecting an item.
* Useful when the caller needs to read the popup's contents (e.g. the set of
* offered items) rather than clicking a known item.
*
* Uses hover as primary approach, falls back to keyboard then dispatchEvent -
* same fallback chain as {@link selectSubmenuItem}.
*
* @param submenuText - The text of the submenu to open (e.g., "Download")
* @param options - Optional timeout, an `itemText` to scope the popup lookup
* to (useful when multiple submenu popups could otherwise match), and a
* `popupSelector` override for submenus that render with an additional,
* more specific class than the generic Ant Design popup class.
*/
async openSubmenu(
submenuText: string,
options?: { timeout?: number; itemText?: string; popupSelector?: string },
): Promise<Locator> {
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
const matchPopup = (): Locator => {
const base = this.page.locator(
options?.popupSelector ?? Menu.SELECTORS.SUBMENU_POPUP,
);
return options?.itemText
? base.filter({ hasText: options.itemText })
: base;
};
// Try hover first (most natural user interaction)
let popup = await this.openSubmenuWithHover(
submenuText,
matchPopup,
timeout,
);
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
// Fallback to keyboard navigation
if (!popup) {
popup = await this.openSubmenuWithKeyboard(
submenuText,
matchPopup,
itemText,
timeout,
);
}
@@ -155,7 +114,7 @@ export class Menu {
if (!popup) {
popup = await this.openSubmenuWithDispatchEvent(
submenuText,
matchPopup,
itemText,
timeout,
);
}
@@ -166,7 +125,10 @@ export class Menu {
);
}
return popup;
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
@@ -175,14 +137,17 @@ export class Menu {
*/
private async openSubmenuWithHover(
submenuText: string,
matchPopup: () => Locator,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.hover();
const popup = matchPopup();
// Find the popup that contains the expected item (scopes to correct popup)
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
// Allow Ant Design's slide-in animation to complete before clicking.
@@ -201,7 +166,7 @@ export class Menu {
*/
private async openSubmenuWithKeyboard(
submenuText: string,
matchPopup: () => Locator,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
@@ -209,7 +174,9 @@ export class Menu {
await submenuTitle.focus();
await this.page.keyboard.press('ArrowRight');
const popup = matchPopup();
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
@@ -224,7 +191,7 @@ export class Menu {
*/
private async openSubmenuWithDispatchEvent(
submenuText: string,
matchPopup: () => Locator,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
@@ -247,7 +214,9 @@ export class Menu {
);
});
const popup = matchPopup();
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
@@ -1,133 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Modal } from '../core';
/**
* The "Drill to detail" modal (`DrillDetailModal.tsx`), opened from a chart's
* "More Options" menu or its right-click context menu. Renders the chart's
* underlying sample rows, optionally scoped to a drilled-by value, via the
* `/datasource/samples` API.
*/
export class DrillDetailModal extends Modal {
private static readonly SELECTORS = {
CLOSE_BUTTON: '[data-test="close-drilltodetail-modal"]',
ROW_COUNT_LABEL: '[data-test="row-count-label"]',
METADATA_BAR: '[data-test="metadata-bar"]',
FILTER_COLUMN: '[data-test="filter-col"]',
FILTER_VALUE: '[data-test="filter-val"]',
PAGE_ITEM: '.ant-pagination-item',
ACTIVE_PAGE_ITEM: '.ant-pagination-item-active',
GRID_CELL: '.virtual-table-cell',
} as const;
private readonly specificLocator: Locator;
constructor(page: Page) {
super(page);
// Matched by accessible name rather than a data-test: the antd Modal's own
// data-test (`${name}-modal`) is derived from this same i18n'd `name`
// prop, so it isn't a locale-independent alternative. No data-test exists
// on the dialog root itself.
this.specificLocator = page.getByRole('dialog', {
name: /^Drill to detail:/,
});
}
override get element(): Locator {
return this.specificLocator;
}
/**
* The applied-filter value tags (`<col>=<val>`). Empty when the drill was
* whole-chart (no row/point-level filter applied).
*/
get filterValues(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_VALUE);
}
/** The applied-filter chip(s); each is closable via its own "Close" icon. */
get filterColumns(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_COLUMN);
}
/** Row-count label above the results grid, e.g. "1-50 of 500 rows". */
get rowCountLabel(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.ROW_COUNT_LABEL);
}
/** The metadata bar (column/row summary) shown once samples have loaded. */
get metadataBar(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.METADATA_BAR);
}
/** Pagination page-number items below the results grid. */
get pageItems(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.PAGE_ITEM);
}
/** The currently active pagination page-number item. */
get activePageItem(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.ACTIVE_PAGE_ITEM);
}
/** Cells of the virtualized results grid. */
get gridCells(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.GRID_CELL);
}
/**
* Removes the first applied filter by clicking its chip's Close icon,
* re-fetching the unfiltered samples.
*/
async clearFirstFilter(): Promise<void> {
await this.filterColumns.first().getByLabel('Close').click();
}
/** Navigates to the given 1-indexed pagination page. */
async goToPage(pageNumber: number): Promise<void> {
await this.pageItems.nth(pageNumber - 1).click();
}
/**
* Re-fetches the current samples query, resetting pagination to page 1.
*
* Matched by accessible name: the Reload icon carries an i18n'd
* `aria-label` (`t('Reload')`) and no data-test, so this breaks in
* non-English locales the same way `DrillDetailModal.tsx`'s dialog `name`
* does above; the predecessor Cypress test used the same English string.
*/
async reload(): Promise<void> {
await this.element.getByRole('button', { name: 'Reload' }).click();
}
/**
* Closes the modal via its footer Close button.
*
* Targets the button by data-test rather than Modal.clickFooterButton,
* which finds buttons by their visible text. The button label is i18n'd
* ("Close" / "Fermer" / ), so name-based lookups break in non-English
* locales; see DeleteConfirmationModal.clickDelete for the same rationale.
*/
async close(): Promise<void> {
await this.element.locator(DrillDetailModal.SELECTORS.CLOSE_BUTTON).click();
await this.waitForHidden();
}
}
@@ -21,7 +21,6 @@
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DrillDetailModal } from './DrillDetailModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';
@@ -20,7 +20,6 @@
import { Page, Download, Locator, expect } from '@playwright/test';
import { Button, Input, Menu, Tabs } from '../components/core';
import { DashboardFilterBar } from '../components/dashboard';
import { DrillDetailModal } from '../components/modals';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
@@ -455,124 +454,4 @@ export class DashboardPage {
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
}
// ---------------------------------------------------------------------------
// Drill to detail
//
// Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
// the chart's "More Options" header menu, and a right-click context menu on
// the chart body (a cell, the big-number value, or a canvas data point). Both
// open the same DrillDetailModal, which renders the underlying sample rows for
// the (optionally filtered) chart by calling the `/datasource/samples` API.
// ---------------------------------------------------------------------------
/**
* Open the "Drill to detail" item from a chart's "More Options" header menu.
* This is the whole-chart entry point (no row-level filters applied).
*/
async openDrillToDetailFromMenu(chartId: number): Promise<void> {
const moreOptions = new Button(
this.page,
this.getChart(chartId).getByLabel('More Options', { exact: true }),
);
await moreOptions.click();
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
*/
drillModal(): DrillDetailModal {
return new DrillDetailModal(this.page);
}
/**
* Click the plain "Drill to detail" item in an open chart context menu
* (whole chart, no row-level filter).
*/
async contextMenuDrillToDetail(): Promise<void> {
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The "Drill to detail by" submenu parent (title) in an open context menu.
* Targeted by its submenu-title element rather than role+name because antd
* appends the arrow-icon name ("right") to the accessible name, and the leaf
* items ("Drill to detail by boy") would otherwise match a role+name lookup.
*/
drillBySubmenuTitle(): Locator {
return this.page.locator('.ant-dropdown-menu-submenu-title', {
hasText: 'Drill to detail by',
});
}
/**
* The chart context menu's Menu component, scoped to the open context
* menu's root. Used to open the "Drill to detail by" submenu robustly:
* plain hover is not reliably picked up by Ant Design's submenu trigger in
* headless Chromium, so this falls back to keyboard and dispatchEvent - see
* {@link Menu.openSubmenu}.
*/
private contextMenu(): Menu {
return new Menu(this.page, '[data-test="chart-context-menu"]');
}
/**
* Opens the "Drill to detail by" submenu and returns its popup, containing
* the leaf value items (e.g. "Drill to detail by boy").
*/
private openDrillBySubmenu(): Promise<Locator> {
return this.contextMenu().openSubmenu('Drill to detail by', {
popupSelector: '.chart-context-submenu',
});
}
/**
* From an open chart context menu, open the "Drill to detail by" submenu and
* click the entry for a specific value (e.g. "boy", "1965", "all").
*/
async contextMenuDrillToDetailBy(value: string): Promise<void> {
const popup = await this.openDrillBySubmenu();
// Use dispatchEvent instead of click to bypass viewport and pointer
// interception issues - see Menu.selectSubmenuItem.
await popup
.getByRole('menuitem', {
name: `Drill to detail by ${value}`,
exact: true,
})
.dispatchEvent('click');
}
/**
* From an open chart context menu, open "Drill to detail by" and return the
* concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
* aggregate "all" entry. Used by canvas charts where the value under the
* cursor is data-dependent: the test drills by whatever the menu actually
* offers and asserts that same value round-trips into the modal, which keeps
* the assertion independent of exact pixel/slice geometry.
*
* Reads rendered (HTML-stripped) menu text rather than the item's
* `aria-label`, which carries the raw, unstripped formatted value
* (`useDrillDetailMenuItems`). The two only diverge for formatted values
* that contain HTML markup; callers pass the returned value both to
* `contextMenuDrillToDetailBy` (accessible-name lookup) and to a
* displayed-text assertion on the modal's filter chip, so a value straddling
* both uses only works when it's markup-free. Every value currently offered
* by this dashboard's charts is a plain string, so this hasn't been
* reachable in practice; revisit if a test starts exercising HTML-formatted
* dimension values.
*/
async drillByOfferedValues(): Promise<string[]> {
const popup = await this.openDrillBySubmenu();
const items = popup.locator('[role="menuitem"]');
await items.first().waitFor();
const labels = await items.allInnerTexts();
return labels
.map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
.filter(v => v.length > 0 && v.toLowerCase() !== 'all');
}
}
@@ -1,747 +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.
*/
/**
* E2E migration of the Cypress "Drill to detail modal" suite
* (dashboard/drilltodetail.test.ts).
*
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
* chart optionally filtered to a single data point by either the chart's
* "More Options" header menu or a right-click context menu on the chart body.
* The modal calls the real `/datasource/samples` API, so this is genuinely
* end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
* dataset, renders it in the browser, drives the real menus, and asserts the
* resulting backend round-trip (the samples POST and the filter the modal
* applies).
*
* Why the original suite was fully `describe.skip`:
* "it has issues with autoscrolling and the locked title flakes intricately
* when the rightClick is obstructed by the title."
* That failure mode is Cypress-specific Cypress auto-scrolls the target under
* the sticky chart header before every action. Playwright scrolls once and the
* target stays put, so the entry points are portable here.
*
* What is migrated, and how it is kept deterministic:
* - Modal mechanics (open from header menu, pagination, reload-resets-page)
* and the no-filter big-number drill use stable DOM elements.
* - Table and Pivot drills right-click real DOM cells (no canvas pixels).
* - Canvas (echarts) charts Pie, Line, Scatter, generic/smooth/step
* time-series, Mixed, Box plot, Funnel, Gauge, Treemap DID rely on
* hard-coded pixel coordinates in Cypress to land on a specific slice/point.
* Instead of reproducing those brittle pixels, these tests scan a stable
* region of the canvas (see `rightClickCanvasDatum`), read whichever value
* the drill submenu actually offers for the point under the cursor, drill by
* that value, and assert the SAME value round-trips into the modal filter.
* This exercises the full canvas contextmenu datum samples pipeline
* while staying independent of exact geometry. `Big Number with Trendline`
* drills the whole chart (no datum filter), like `Big Number`.
*
* Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
* World Map, Radar skipped upstream for chart-specific reasons.
*/
import {
testWithAssets,
expect,
type TestAssets,
} from '../../helpers/fixtures';
import type { Page, TestInfo } from '@playwright/test';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import { createDashboardWithCharts } from './dashboard-test-helpers';
const DATASET_NAME = 'birth_names';
/**
* Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
* tests can assert the *invariant* (filtered < unfiltered) without hard-coding
* the dataset-specific totals the original Cypress suite baked in.
*/
function parseRowCount(text: string): number {
const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
if (!m) return NaN;
let n = parseFloat(m[1].replace(/,/g, ''));
const suffix = m[2].toLowerCase();
if (suffix === 'k') n *= 1e3;
if (suffix === 'm') n *= 1e6;
return n;
}
interface ChartSpec {
vizType: string;
chartNamePrefix: string;
params: Record<string, unknown>;
}
/**
* API-build a hermetic single-chart dashboard from birth_names and return its
* dashboard and chart ids. Thin single-chart wrapper around
* `createDashboardWithCharts`, the build helper shared by the other migrated
* dashboard specs reused here rather than hand-rolling position-json and id
* extraction again.
*/
async function buildSingleChartDashboard(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
spec: ChartSpec,
): Promise<{ dashboardId: number; chartId: number }> {
const { dashboardId, charts } = await createDashboardWithCharts(
page,
testAssets,
testInfo,
{
datasetName: DATASET_NAME,
chartNamePrefix: spec.chartNamePrefix,
dashboardTitlePrefix: spec.chartNamePrefix,
chartSpecs: [{ viz_type: spec.vizType, params: spec.params }],
},
);
return { dashboardId, chartId: charts[0].id };
}
/**
* Right-click an echarts canvas until a data point is hit i.e. until the
* context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
* that item disabled, as a plain menu item rather than a submenu title).
*
* echarts renders to a single canvas, so there is no per-datum DOM element to
* target and the exact pixel of a mark depends on chart geometry (donut hole,
* legend size, axis padding). Rather than hard-code Cypress's brittle pixel
* coordinates, this scans a small set of candidate points a radial ring for
* pie/radial charts, a grid for cartesian charts and stops at the first that
* lands on a mark. The drill value is then whatever that mark represents, so the
* caller asserts a value round-trip rather than a specific geometry.
*/
async function rightClickCanvasDatum(
page: Page,
dashboard: DashboardPage,
canvas: ReturnType<Page['locator']>,
pattern: 'ring' | 'grid' | 'dense',
): Promise<void> {
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas has no bounding box');
const ringPoints = (): Array<{ x: number; y: number }> => {
const pts: Array<{ x: number; y: number }> = [];
const cx = box.width / 2;
const cy = box.height / 2;
const minSide = Math.min(box.width, box.height);
for (const rf of [0.3, 0.22, 0.38]) {
for (let a = 0; a < 360; a += 45) {
const rad = (a * Math.PI) / 180;
pts.push({
x: cx + Math.cos(rad) * minSide * rf,
y: cy + Math.sin(rad) * minSide * rf,
});
}
}
return pts;
};
const gridPoints = (): Array<{ x: number; y: number }> => {
const pts: Array<{ x: number; y: number }> = [];
for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
pts.push({ x: box.width * xf, y: box.height * yf });
}
}
return pts;
};
// 'dense' merges both scans for radial/stacked shapes (gauge, funnel, box
// plot) whose drillable marks don't fall neatly on a single ring or grid.
let candidates: Array<{ x: number; y: number }>;
if (pattern === 'ring') candidates = ringPoints();
else if (pattern === 'grid') candidates = gridPoints();
else candidates = [...gridPoints(), ...ringPoints()];
// The submenu *title* element only exists when "Drill to detail by" is an
// enabled submenu (a real datum was hit); a miss renders a disabled item.
const enabledDrillBy = dashboard.drillBySubmenuTitle();
const contextMenu = page.locator('[data-test="chart-context-menu"]');
for (const pt of candidates) {
await canvas.click({ button: 'right', position: pt });
const hit = await enabledDrillBy
.waitFor({ state: 'visible', timeout: 400 })
.then(() => true)
.catch(() => false);
if (hit) return;
await page.keyboard.press('Escape');
// Wait for the portal to actually close before the next right-click;
// otherwise a still-open (or mid-close-animation) menu can make the
// next click/locator behave nondeterministically on slower/contended CI.
await contextMenu
.waitFor({ state: 'hidden', timeout: 400 })
.catch(() => {});
}
throw new Error(
`no drillable datum found on canvas after scanning ${candidates.length} points`,
);
}
/** A samples POST fired (proves the modal hit the real backend). */
function expectSamplesPost(page: Page) {
return page.waitForResponse(
r =>
r.url().includes('/datasource/samples') &&
r.request().method() === 'POST',
{ timeout: TIMEOUT.API_RESPONSE },
);
}
async function loadDashboardWithChart(
dashboard: DashboardPage,
dashboardId: number,
chartId: number,
): Promise<void> {
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard
.getChart(chartId)
.locator('[data-test="chart-container"]')
.first()
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
await dashboard.waitForChartsToLoad();
}
/**
* From an already-open "Drill to detail by" submenu, drill by the first
* offered value and assert that same value lands in the modal filter. The
* shared tail of every "drill by whatever value is under the cursor" test
* canvas charts and the pivot table alike, which differ only in how they open
* the submenu in the first place.
*/
async function drillByFirstOfferedValueAndAssert(
page: Page,
dashboard: DashboardPage,
): Promise<void> {
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const [value] = offered;
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues.first()).toContainText(
value,
);
}
/**
* Full canvas-drill round-trip for an echarts (canvas-rendered) chart: build a
* hermetic single-chart dashboard, render it, right-click a real datum, drill by
* whatever value the submenu offers under the cursor, and assert that same value
* lands in the modal filter. Geometry-independent see rightClickCanvasDatum.
* Reused across every canvas viz type so each migrated chart is a thin caller.
*/
async function expectCanvasDrillByValueRoundTrips(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
spec: ChartSpec,
pattern: 'ring' | 'grid' | 'dense',
): Promise<void> {
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testInfo,
spec,
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
const canvas = dashboard.getChart(chartId).locator('canvas').first();
await expect(canvas).toBeVisible();
await rightClickCanvasDatum(page, dashboard, canvas, pattern);
await drillByFirstOfferedValueAndAssert(page, dashboard);
}
/**
* Right-click a big-number chart's rendered value to open its context menu,
* drill the whole chart (no row/point filter), and assert the modal opened
* with no filter tags and a real row count. Shared by Big Number and Big
* Number with Trendline, which differ only in their chart params.
*/
async function expectWholeChartDrillFromContextMenu(
page: Page,
dashboard: DashboardPage,
chartId: number,
): Promise<void> {
const samples = expectSamplesPost(page);
await dashboard
.getChart(chartId)
.locator('.header-line')
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal().element).toBeVisible();
// Whole-chart drill: no per-value filter tag.
await expect(dashboard.drillModal().filterValues).toHaveCount(0);
await expect(dashboard.drillModal().rowCountLabel).toContainText('rows');
}
// Shared form-data fragment for the echarts time-series family (line/scatter/
// generic/smooth/step): one temporal axis, one metric, split by gender series.
const TIMESERIES_PARAMS = {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
row_limit: 1000,
};
testWithAssets(
'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'big_number_total',
chartNamePrefix: 'drill_bignum',
params: { metric: 'count', adhoc_filters: [] },
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
// Open the modal from the chart's "More Options" header menu.
const samplesOnOpen = expectSamplesPost(page);
await dashboard.openDrillToDetailFromMenu(chartId);
await samplesOnOpen;
const modal = dashboard.drillModal();
await expect(modal.element).toBeVisible();
await expect(modal.element).toContainText('Drill to detail:');
// The metadata bar and a real row count prove the modal loaded backend data.
await expect(modal.metadataBar).toBeVisible();
await expect(modal.rowCountLabel).toContainText('rows');
// No drill filter was applied (whole-chart drill).
await expect(modal.filterValues).toHaveCount(0);
// The full dataset spans multiple pages, and the grid has rendered rows.
expect(await modal.pageItems.count()).toBeGreaterThan(1);
await expect(modal.gridCells.first()).toBeVisible();
await expect(modal.activePageItem).toContainText('1');
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
// active page to 2.
const samplesOnPage2 = expectSamplesPost(page);
await modal.goToPage(2);
await samplesOnPage2;
await expect(modal.activePageItem).toContainText('2');
// Reload re-fetches and resets back to the first page.
const samplesOnReload = expectSamplesPost(page);
await modal.reload();
await samplesOnReload;
await expect(modal.activePageItem).toContainText('1');
},
);
testWithAssets(
'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'big_number_total',
chartNamePrefix: 'drill_bignum_rc',
params: { metric: 'count', adhoc_filters: [] },
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
},
);
testWithAssets(
'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'table',
chartNamePrefix: 'drill_table',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
// Right-click the "boy" dimension cell and drill by it.
const samplesOnDrill = expectSamplesPost(page);
await dashboard
.getChart(chartId)
.getByText('boy', { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy('boy');
await samplesOnDrill;
const modal = dashboard.drillModal();
await expect(modal.element).toBeVisible();
await expect(modal.filterValues.first()).toContainText('boy');
const filteredCount = parseRowCount(await modal.rowCountLabel.innerText());
expect(filteredCount).toBeGreaterThan(0);
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
const samplesOnClear = expectSamplesPost(page);
await modal.clearFirstFilter();
await samplesOnClear;
await expect(modal.filterValues).toHaveCount(0);
await expect
.poll(async () => parseRowCount(await modal.rowCountLabel.innerText()))
.toBeGreaterThan(filteredCount);
},
);
testWithAssets(
'drill-to-detail modal: pivot table cell right-click drills by the cell value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'pivot_table_v2',
chartNamePrefix: 'drill_pivot',
params: {
groupbyRows: ['gender'],
groupbyColumns: [],
metrics: ['count'],
aggregateFunction: 'Sum',
rowTotals: false,
colTotals: false,
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
await dashboard
.getChart(chartId)
.locator('[role="gridcell"]')
.first()
.click({ button: 'right' });
// The cell's row dimension determines the offered value; drill by it and
// assert the same value lands in the modal filter.
await drillByFirstOfferedValueAndAssert(page, dashboard);
},
);
testWithAssets(
'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Pie is a donut by default (center is a hole), so scan the ring for a slice.
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'pie',
chartNamePrefix: 'drill_pie',
params: { groupby: ['gender'], metric: 'count' },
},
'ring',
);
},
);
testWithAssets(
'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Scan the plot grid for a point on one of the series lines.
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'echarts_timeseries_line',
chartNamePrefix: 'drill_line',
params: TIMESERIES_PARAMS,
},
'grid',
);
},
);
testWithAssets(
'drill-to-detail modal: big number with trendline right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'big_number',
chartNamePrefix: 'drill_bignum_trend',
params: {
metric: 'count',
x_axis: 'ds',
time_grain_sqla: 'P1Y',
adhoc_filters: [],
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
},
);
interface CanvasDrillCase {
title: string;
spec: ChartSpec;
pattern: 'ring' | 'grid' | 'dense';
}
// Every remaining canvas (echarts) chart is a thin caller of
// expectCanvasDrillByValueRoundTrips, differing only in viz type, chart
// params, and which point-scan pattern finds a drillable mark.
const CANVAS_DRILL_CASES: CanvasDrillCase[] = [
{
title:
'drill-to-detail modal: scatter chart point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries_scatter',
chartNamePrefix: 'drill_scatter',
// Enlarge the markers so a region scan reliably lands on a point;
// scatter's default dots are a few pixels wide and a sparse grid misses
// them.
params: { ...TIMESERIES_PARAMS, markerSize: 20 },
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: generic time-series point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries',
chartNamePrefix: 'drill_generic',
params: TIMESERIES_PARAMS,
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: smooth line point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries_smooth',
chartNamePrefix: 'drill_smooth',
params: TIMESERIES_PARAMS,
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: step line point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries_step',
chartNamePrefix: 'drill_step',
params: TIMESERIES_PARAMS,
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: mixed time-series point right-click (canvas) drills by the point value',
spec: {
vizType: 'mixed_timeseries',
chartNamePrefix: 'drill_mixed',
params: {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
metrics_b: ['count'],
groupby_b: ['gender'],
row_limit: 1000,
},
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: box plot right-click (canvas) drills by the box value',
spec: {
vizType: 'box_plot',
chartNamePrefix: 'drill_boxplot',
params: {
groupby: ['gender'],
metrics: ['count'],
columns: ['ds'],
},
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: funnel segment right-click (canvas) drills by the segment value',
spec: {
vizType: 'funnel',
chartNamePrefix: 'drill_funnel',
params: { groupby: ['gender'], metric: 'count' },
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: gauge right-click (canvas) drills by the gauge value',
spec: {
vizType: 'gauge_chart',
chartNamePrefix: 'drill_gauge',
params: { groupby: ['gender'], metric: 'count' },
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: treemap tile right-click (canvas) drills by the tile value',
spec: {
vizType: 'treemap_v2',
chartNamePrefix: 'drill_treemap',
params: { metric: 'count', groupby: ['gender'] },
},
pattern: 'dense',
},
];
for (const { title, spec, pattern } of CANVAS_DRILL_CASES) {
testWithAssets(title, async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
testWithAssets.info(),
spec,
pattern,
);
});
}
testWithAssets(
'drill-to-detail modal: drilling a time-series point "by all" applies every dimension of that point',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'echarts_timeseries_line',
chartNamePrefix: 'drill_all',
// Two groupby dimensions so each point genuinely carries more than one
// drillable value — the whole point of "Drill to detail by all".
params: { ...TIMESERIES_PARAMS, groupby: ['gender', 'state'] },
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
const canvas = dashboard.getChart(chartId).locator('canvas').first();
await expect(canvas).toBeVisible();
await rightClickCanvasDatum(page, dashboard, canvas, 'grid');
// A line point carries two dimensions (the temporal value and the gender
// series), so "Drill to detail by all" must apply both as filters.
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThanOrEqual(2);
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy('all');
await samples;
await expect(dashboard.drillModal().element).toBeVisible();
expect(
await dashboard.drillModal().filterValues.count(),
).toBeGreaterThanOrEqual(2);
},
);
testWithAssets(
'drill-to-detail modal: table drills correctly by each of multiple dimension values',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'table',
chartNamePrefix: 'drill_table_multi',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
for (const value of ['boy', 'girl']) {
const samples = expectSamplesPost(page);
await dashboard
.getChart(chartId)
.getByText(value, { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
const modal = dashboard.drillModal();
await expect(modal.element).toBeVisible();
await expect(modal.filterValues.first()).toContainText(value);
await modal.close();
}
},
);
@@ -38,7 +38,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
toTotalsAggregate,
TotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -696,16 +696,13 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate,
);
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
// Raw-mode summary columns have no metric of their own to preserve, so
// ORIGINAL has nothing to fall back to; sum them as before.
const rawSummaryAggregate =
totalsAggregate === 'ORIGINAL' ? 'SUM' : totalsAggregate;
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
const totalsMetrics =
rawSummaryColumns.length > 0
? rawSummaryColumns.map(columnName => ({
expressionType: 'SIMPLE' as const,
aggregate: rawSummaryAggregate,
aggregate: totalsAggregate,
column: { column_name: columnName },
label: columnName,
}))
@@ -503,18 +503,14 @@ const config: ControlPanelConfig = {
label: t('Summary aggregation'),
renderTrigger: true,
description: t(
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
),
default: 'ORIGINAL',
default: 'SUM',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -1561,7 +1561,7 @@ describe('plugin-chart-ag-grid-table', () => {
expect(queries[1].metrics).toEqual(['count']);
});
test("defaults aggregate-mode totals to the metric's own aggregate", () => {
test('defaults aggregate-mode totals to SUM for a simple metric', () => {
const simpleMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'sales' },
@@ -1580,29 +1580,9 @@ describe('plugin-chart-ag-grid-table', () => {
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in aggregate-mode totals by default', () => {
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQuery(
{
viz_type: VizType.Table,
datasource: '11__table',
query_mode: QueryMode.Aggregate,
groupby: ['state'],
metrics: [countDistinctMetric],
show_totals: true,
},
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([countDistinctMetric]);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
});
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
@@ -38,7 +38,7 @@ export default function transformProps(chartProps: ChartProps) {
includeSeries,
isDarkMode: isThemeDark(theme),
linearColorScheme,
metrics: (metrics ?? []).map((m: { label?: string } | string) =>
metrics: metrics.map((m: { label?: string } | string) =>
typeof m === 'string' ? m : m.label || m,
),
colorMetric: secondaryMetric?.label || secondaryMetric,
@@ -1,41 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import transformProps from '../src/transformProps';
const createProps = () =>
({
width: 800,
height: 600,
formData: {
includeSeries: false,
linearColorScheme: 'superset_seq_1',
metrics: undefined,
secondaryMetric: 'sum__SP_POP_TOTL',
series: 'country_name',
showDatatable: false,
},
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
theme: {},
}) as unknown as ChartProps;
test('do not crash on undefined metrics', () => {
expect(() => transformProps(createProps())).not.toThrow();
});
@@ -1069,10 +1069,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const originKey = column.key.substring(column.label.length).trim();
if (!hasColumnColorFormatters && hasBasicColorFormatters) {
backgroundColor =
basicColorFormatters[row.index]?.[originKey]?.backgroundColor;
basicColorFormatters[row.index][originKey]?.backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorFormatters[row.index]?.[originKey]?.mainArrow
? basicColorFormatters[row.index][originKey]?.mainArrow
: '';
}
@@ -1134,11 +1134,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
basicColorColumnFormatters?.length > 0
) {
backgroundColor =
basicColorColumnFormatters[row.index]?.[column.key]
basicColorColumnFormatters[row.index][column.key]
?.backgroundColor || backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorColumnFormatters[row.index]?.[column.key]?.mainArrow
? basicColorColumnFormatters[row.index][column.key]?.mainArrow
: '';
}
const rowSurfaceColor =
@@ -1197,7 +1197,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
let arrowStyles = css`
color: ${
basicColorFormatters &&
basicColorFormatters[row.index]?.[originKey]?.arrowColor ===
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
@@ -1211,7 +1211,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
) {
arrowStyles = css`
color: ${
basicColorColumnFormatters[row.index]?.[column.key]
basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
@@ -34,7 +34,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
toTotalsAggregate,
TotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -349,7 +349,8 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate
) {
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
extraQueries.push({
...queryObject,
columns: [],
@@ -475,18 +475,14 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
label: t('Summary aggregation'),
description: t(
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
),
default: 'ORIGINAL',
default: 'SUM',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -20,7 +20,6 @@ import '@testing-library/jest-dom';
import {
getTextColorForBackground,
ObjectFormattingEnum,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import { supersetTheme } from '@apache-superset/core/theme';
import {
@@ -2076,59 +2075,6 @@ describe('plugin-chart-table', () => {
});
});
test('does not crash when a comparison-color-formatter array has no entry for a rendered row', () => {
// Regression test: the per-cell comparison-color lookups in the Cell
// renderer (`basicColorFormatters`/`basicColorColumnFormatters`,
// indexed by `row.index`) must stay safe even if those arrays ever
// end up with fewer entries than the number of rendered rows -- e.g.
// when "Show summary" is combined with time comparison and a
// comparison-based conditional color scheme ("Green for increase,
// red for decrease") applied to a Time Comparison column. Without
// the `?.` guard on the array-index lookup, this throws
// `TypeError: Cannot read properties of undefined (reading 'Main
// metric_1')`.
const propsInput = {
...testData.comparison,
rawFormData: {
...testData.comparison.rawFormData,
conditional_formatting: [
{ column: 'Main metric_1', colorScheme: ColorSchemeEnum.Green },
],
},
};
const transformedProps = transformProps(propsInput);
expect(transformedProps.data).toHaveLength(2);
expect(transformedProps.basicColorColumnFormatters).toHaveLength(2);
// Simulate the row-count mismatch: the formatter array has an entry
// for only the first row, matching the shape of the bug (an entry
// missing for one of the rendered rows).
const propsWithMissingFormatterEntry = {
...transformedProps,
basicColorColumnFormatters:
transformedProps.basicColorColumnFormatters!.slice(0, 1),
};
expect(() =>
render(
<TableChart {...propsWithMissingFormatterEntry} sticky={false} />,
),
).not.toThrow();
// the row that still has a formatter entry keeps its comparison
// background color and arrow: the "Main metric_1" cell for the
// first row (value 100) renders before the derived "△ metric_1"
// cell that happens to share the same value and aria label.
const [styledCell] = screen.getAllByTitle('100');
expect(styledCell).toHaveTextContent('↑100');
expect(getComputedStyle(styledCell).background).toContain(
'rgba(0, 150, 0, 0.2)',
);
// the row missing a formatter entry still renders its raw value
expect(screen.getAllByTitle('110').length).toBeGreaterThan(0);
});
test('preserves client-side search text across temporal table rerenders', async () => {
const formDataWithSearch = {
...testData.basic.formData,
@@ -340,7 +340,7 @@ describe('plugin-chart-table', () => {
label: 'sum_sales',
};
test("defaults to each metric's own aggregate", () => {
test('defaults the totals query metric aggregate to SUM', () => {
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
@@ -350,28 +350,9 @@ describe('plugin-chart-table', () => {
});
expect(queries).toHaveLength(2);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in the summary row by default', () => {
// Overriding this to SUM sums the counted column instead of counting
// it, which is meaningless on a numeric id and is rejected outright by
// the database on a non-numeric one (e.g. a uuid).
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: [countDistinctMetric],
groupby: ['category'],
show_totals: true,
});
expect(queries[1].metrics).toEqual([countDistinctMetric]);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
});
test('overrides simple metric aggregate with totals_aggregate for the summary query only', () => {
@@ -196,54 +196,6 @@ interface DatasourceObject {
folders?: DatasourceFolder[];
}
/**
* Lift the certification and warning fields a metric keeps inside its `extra`
* JSON blob onto the metric itself, which is the shape the editor's fields bind
* to.
*
* Two entry points feed the editor two different metric shapes: the dataset
* list hands over the API payload, where `extra` is still a JSON string, while
* Explore hands over its bootstrap payload, where `SqlMetric.data` has already
* flattened `extra` into `warning_markdown` and dropped the raw string. The
* parsed blob is therefore only authoritative when `extra` is actually present;
* otherwise the already-flattened value stands, instead of being reset to an
* empty field.
*
* A malformed `extra` string is treated the same as an absent one (falls
* through to the already-flattened value) rather than throwing, mirroring
* the backend's own tolerance for bad `extra` JSON in
* `CertificationMixin.get_extra_dict()`.
*/
export function hydrateMetricExtra(metric: Metric): Metric {
const {
certified_by: certifiedByMetric,
certification_details: certificationDetails,
} = metric;
let parsedExtra;
if (metric.extra) {
try {
parsedExtra = JSON.parse(metric.extra) || {};
} catch {
parsedExtra = undefined;
}
}
const {
certification: {
details = undefined,
certified_by: certifiedBy = undefined,
} = {},
} = parsedExtra || {};
const warningMarkdown = parsedExtra
? parsedExtra.warning_markdown
: metric.warning_markdown;
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}
interface DatasourceEditorOwnProps {
datasource: DatasourceObject;
onChange?: (datasource: DatasourceObject, errors: string[]) => void;
@@ -900,7 +852,25 @@ function DatasourceEditor({
const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
...propsDatasource,
editors: normalizeSubjectsToPickerValues(propsDatasource.editors || []),
metrics: propsDatasource.metrics?.map(hydrateMetricExtra),
metrics: propsDatasource.metrics?.map(metric => {
const {
certified_by: certifiedByMetric,
certification_details: certificationDetails,
} = metric;
const {
certification: {
details = undefined,
certified_by: certifiedBy = undefined,
} = {},
warning_markdown: warningMarkdown,
} = JSON.parse(metric.extra || '{}') || {};
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || metric.warning_markdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}),
}));
const [errors, setErrors] = useState<string[]>([]);
@@ -1,96 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { hydrateMetricExtra } from '../DatasourceEditor';
const metric = { metric_name: 'sum__num', expression: 'SUM(num)' };
test('lifts the warning and certification out of the extra JSON string', () => {
expect(
hydrateMetricExtra({
...metric,
extra: JSON.stringify({
warning_markdown: 'Handle with care',
certification: { certified_by: 'Data team', details: 'Reviewed' },
}),
}),
).toMatchObject({
warning_markdown: 'Handle with care',
certified_by: 'Data team',
certification_details: 'Reviewed',
});
});
test('keeps an already-flattened warning when the metric carries no extra (#42704)', () => {
// Explore's bootstrap payload flattens `extra` into `warning_markdown` and
// drops the raw string, so the flattened value is all there is to go on.
expect(
hydrateMetricExtra({ ...metric, warning_markdown: 'Handle with care' })
.warning_markdown,
).toBe('Handle with care');
});
test('lets an empty warning in extra clear the flattened value', () => {
expect(
hydrateMetricExtra({
...metric,
warning_markdown: 'stale',
extra: '{}',
}).warning_markdown,
).toBe('');
});
test('normalizes a missing warning to an empty string', () => {
expect(hydrateMetricExtra(metric).warning_markdown).toBe('');
});
test('resolves certification conflicts between the metric and its extra blob', () => {
expect(
hydrateMetricExtra({
...metric,
certified_by: 'Analytics',
certification_details: 'Owned by Analytics',
extra: JSON.stringify({
certification: { certified_by: 'Data team', details: 'Reviewed' },
}),
}),
).toMatchObject({
// extra wins for the certifier, while the metric's own details field wins
// for the description — the certification form writes both back into extra
// on save, so the two settle on the same source afterwards
certified_by: 'Data team',
certification_details: 'Owned by Analytics',
});
});
test('does not throw on malformed extra, falling back like an absent extra', () => {
expect(() =>
hydrateMetricExtra({
...metric,
warning_markdown: 'Handle with care',
extra: '{not valid json',
}),
).not.toThrow();
expect(
hydrateMetricExtra({
...metric,
warning_markdown: 'Handle with care',
extra: '{not valid json',
}).warning_markdown,
).toBe('Handle with care');
});
@@ -60,7 +60,7 @@ export default function DndAdhocFilterOption({
<OptionWrapper
key={index}
index={index}
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
@@ -43,7 +43,7 @@ import {
DndFilterSelectProps,
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { Clauses, ExpressionTypes } from '../FilterControl/types';
import { ExpressionTypes } from '../FilterControl/types';
import { DndItemType } from '../../DndItemType';
import { Datasource } from '../../../types';
import {
@@ -137,35 +137,6 @@ test('renders with value', async () => {
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
});
test('renders the pill using the column verbose_name when one is set', async () => {
const value = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
render(
setup({
value,
columns: [
{
id: 1,
type: 'BIGINT',
type_generic: GenericDataType.Numeric,
column_name: 'num',
verbose_name: 'total_count',
},
],
}),
{
useDndKit: true,
store,
},
);
expect(await screen.findByText('total_count > 500')).toBeInTheDocument();
});
test('renders options with saved metric', async () => {
render(
setup({
@@ -370,32 +370,4 @@ describe('AdhocFilter', () => {
});
expect(adhocFilter.getDefaultLabel()).toBe('');
});
test('uses the column verbose_name in the label when one is given', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
expect(
adhocFilter.getDefaultLabel([
{ column_name: 'num', verbose_name: 'total_count' },
]),
).toBe('total_count > 500');
});
test('falls back to the column_name when no verbose_name is set', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
expect(
adhocFilter.getDefaultLabel([{ column_name: 'num', verbose_name: '' }]),
).toBe('num > 500');
expect(adhocFilter.getDefaultLabel([])).toBe('num > 500');
expect(adhocFilter.getDefaultLabel()).toBe('num > 500');
});
});
@@ -23,7 +23,7 @@ import {
OPERATOR_ENUM_TO_OPERATOR_TYPE,
Operators,
} from 'src/explore/constants';
import { translateToSql, VerboseColumn } from '../utils/translateToSQL';
import { translateToSql } from '../utils/translateToSQL';
import { Clauses, ExpressionTypes } from '../types';
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
@@ -193,8 +193,8 @@ export default class AdhocFilter {
);
}
getDefaultLabel(columns?: VerboseColumn[]): string {
const label = this.translateToSql({ columns });
getDefaultLabel(): string {
const label = this.translateToSql();
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
}
@@ -202,8 +202,8 @@ export default class AdhocFilter {
return this.translateToSql();
}
translateToSql(params: { columns?: VerboseColumn[] } = {}): string {
return translateToSql(this as unknown as CoreAdhocFilter, params);
translateToSql(): string {
return translateToSql(this as unknown as CoreAdhocFilter);
}
}
@@ -23,7 +23,6 @@ import {
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
@@ -915,39 +914,3 @@ test('dropdown should remain open when clicked after filter is configured', asyn
expect(operatorDropdown).toHaveAttribute('aria-expanded', 'true');
});
test('filters the subject select by column verbose_name as well as column_name', async () => {
setup({
options: [
{
type: 'BIGINT',
column_name: 'num',
verbose_name: 'total_count',
id: 1,
},
{
type: 'VARCHAR(255)',
column_name: 'name',
verbose_name: 'Full Name',
id: 2,
},
],
});
const combobox = screen.getByRole('combobox', { name: 'Select subject' });
userEvent.click(combobox);
await userEvent.type(combobox, 'total');
const dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
await userEvent.clear(combobox);
await userEvent.type(combobox, 'num');
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
});
@@ -639,11 +639,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
('optionName' in column && column.optionName) ||
undefined,
label: renderSubjectOptionLabel(column),
column_name: 'column_name' in column ? column.column_name : undefined,
verbose_name:
'verbose_name' in column ? column.verbose_name : undefined,
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...subjectSelectProps}
/>
);
@@ -71,24 +71,6 @@ test('should render the control label', async () => {
expect(await screen.findByText('value > 10')).toBeInTheDocument();
});
test('should render the control label using the column verbose_name when one is set', async () => {
render(
setup({
...mockedProps,
options: [
{
type: 'DOUBLE',
column_name: 'value',
verbose_name: 'total_count',
id: 3,
},
],
}),
{ useDnd: true, useRedux: true },
);
expect(await screen.findByText('total_count > 10')).toBeInTheDocument();
});
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByTestId('remove-control-button');
@@ -65,7 +65,7 @@ export default function AdhocFilterOption({
partitionColumn={partitionColumn ?? undefined}
>
<OptionControlLabel
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
onRemove={() =>
onRemoveFilter({
@@ -63,35 +63,9 @@ export const OPERATORS_TO_SQL = {
`= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`,
};
export interface VerboseColumn {
column_name?: string;
verbose_name?: string | null;
}
// Resolves the display label for a filter's subject: the verbose_name of the
// matching column when one is supplied, falling back to the technical
// subject used for SQL generation.
const getDisplaySubject = (
subject: string | { column_name?: string } | null | undefined,
columns?: VerboseColumn[],
) => {
if (!columns) {
return subject ?? undefined;
}
const columnName =
typeof subject === 'object' ? subject?.column_name : subject;
const verboseName = columns.find(
column => column.column_name === columnName,
)?.verbose_name;
return verboseName || (subject ?? undefined);
};
export const translateToSql = (
adhocFilter: AdhocFilter,
{
useSimple,
columns,
}: { useSimple?: boolean; columns?: VerboseColumn[] } = {},
{ useSimple }: { useSimple: boolean } = { useSimple: false },
) => {
if (isSimpleAdhocFilter(adhocFilter) || useSimple) {
const { subject, operator } = adhocFilter as SimpleAdhocFilter;
@@ -107,11 +81,7 @@ export const translateToSql = (
OPERATORS_TO_SQL[operator](adhocFilter)
: // @ts-expect-error TODO: fix missing operator type `NOT LIKE` and `TEMPORAL RANGE`.
OPERATORS_TO_SQL[operator];
return getSimpleSQLExpression(
getDisplaySubject(subject, columns),
op,
comparator,
);
return getSimpleSQLExpression(subject, op, comparator);
}
if (isFreeFormAdhocFilter(adhocFilter)) {
return adhocFilter.sqlExpression;
@@ -22,11 +22,10 @@ import FixedOrMetricControl from '.';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
const createProps = () => ({
@@ -17,11 +17,9 @@
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import { sections, CustomControlItem } from '@superset-ui/chart-controls';
import { getControlStateFromControlConfig } from 'src/explore/controlUtils';
import exploreReducer, { ExploreState } from './exploreReducer';
import { setControlValue, setStashFormData } from '../actions/exploreActions';
import { setStashFormData } from '../actions/exploreActions';
import { QueryFormData } from '@superset-ui/core';
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
const initialState: ExploreState = {
@@ -54,72 +52,3 @@ test('skips updates when the field is already updated on SET_STASH_FORM_DATA', (
const newState = exploreReducer(initialState, restoreAction);
expect(newState).toBe(initialState);
});
// Regression guard for the shared Time Comparison section (used by the Table
// chart, among others): selecting "Custom date" for Time shift and then
// clearing "Shift start date" raises a required-date validation error. When the
// user then switches Time shift to a non-custom preset the error must clear.
// Because `start_date_offset` did not declare `validationDependencies` on
// `time_compare`, SET_FIELD_VALUE never re-ran its mapStateToProps and the stale
// error survived in Redux, blocking further chart updates until a page refresh.
test('SET_FIELD_VALUE clears the custom-shift date error when time_compare leaves "custom"', () => {
const REQUIRED_DATE_ERROR = 'A date is required when using custom date shift';
const timeComparisonSection = sections.timeComparisonControls({
multi: false,
showCalculationType: false,
showFullChoices: false,
});
const timeCompareConfig = (
timeComparisonSection.controlSetRows[0][0] as CustomControlItem
).config;
const startDateOffsetConfig = (
timeComparisonSection.controlSetRows[1][0] as CustomControlItem
).config;
const form_data = {
time_compare: 'custom',
start_date_offset: '2021-01-01',
} as unknown as QueryFormData;
// Build the control states the way the explore store does so they carry the
// real mapStateToProps / validationDependencies from the control config.
const controlPanelState = { controls: {}, form_data };
const initialState: ExploreState = {
form_data,
controls: {
time_compare: getControlStateFromControlConfig(
timeCompareConfig,
controlPanelState,
'custom',
)!,
start_date_offset: getControlStateFromControlConfig(
startDateOffsetConfig,
controlPanelState,
'2021-01-01',
)!,
},
};
// A valid custom date starts without a validation error.
expect(initialState.controls.start_date_offset.validationErrors).toEqual([]);
// 1) Clearing "Shift start date" raises the required-date error (expected).
const afterClear = exploreReducer(
initialState,
setControlValue('start_date_offset', '') as Parameters<
typeof exploreReducer
>[1],
);
expect(afterClear.controls.start_date_offset.validationErrors).toEqual([
REQUIRED_DATE_ERROR,
]);
// 2) Switching Time shift to a non-custom preset must clear the stale error.
const afterSwitch = exploreReducer(
afterClear,
setControlValue('time_compare', '1 week ago') as Parameters<
typeof exploreReducer
>[1],
);
expect(afterSwitch.controls.start_date_offset.validationErrors).toEqual([]);
});
@@ -18,15 +18,9 @@
*/
import { createMemoryHistory, type Update } from 'history';
import { Router } from 'react-router-dom';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import {
render,
screen,
fireEvent,
within,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled } from '@superset-ui/core';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import type Chart from 'src/types/Chart';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import ChartCard from './ChartCard';
jest.mock('@superset-ui/core', () => ({
@@ -43,18 +37,7 @@ const mockChart = {
thumbnail_url: '/thumbnail.png',
} as Chart;
// Admin qualifies as editor, so the card's delete entry is enabled.
const adminUser = {
userId: 1,
username: 'admin',
roles: { Admin: [] },
permissions: {},
} as unknown as UserWithPermissionsAndRoles;
const renderCard = (
history: ReturnType<typeof createMemoryHistory>,
props: Partial<React.ComponentProps<typeof ChartCard>> = {},
) =>
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
render(
<Router history={history}>
<ChartCard
@@ -69,7 +52,6 @@ const renderCard = (
favoriteStatus={false}
showThumbnails
handleBulkChartExport={jest.fn()}
{...props}
/>
</Router>,
);
@@ -124,44 +106,3 @@ test('clicking the card outside the thumbnail navigates to the chart', () => {
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
});
test('with soft delete on, the card delete flow shows the archive dialog', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
flag => flag === FeatureFlag.SoftDelete,
);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Archive'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Archive Sample Chart?')).toBeInTheDocument();
// The body comes from the shared soft-delete copy module; its exact
// wording evolves there (location hint, retention clause), so pin the
// stable prefix rather than a full sentence.
expect(
within(dialog).getByText(/This chart will be moved to Recently Archived/),
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: 'Archive' }),
).toBeInTheDocument();
// Recoverable deletes drop the type-DELETE friction.
expect(
within(dialog).queryByTestId('delete-modal-input'),
).not.toBeInTheDocument();
});
test('with soft delete off, the card delete dialog is the permanent-delete one', async () => {
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Delete'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
expect(
within(dialog).getByText(/Are you sure you want to delete/),
).toBeInTheDocument();
expect(within(dialog).getByTestId('delete-modal-input')).toBeInTheDocument();
});
@@ -38,10 +38,6 @@ import {
isNavigationHandledByLink,
} from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import {
archiveConfirmDescription,
deleteActionLabel,
} from 'src/utils/softDeleteCopy';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
import { TableTab } from 'src/views/CRUD/types';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
@@ -163,29 +159,15 @@ export default function ChartCard({
}
if (canDelete) {
// With soft delete on, deleting archives the chart (recoverable), so the
// confirmation drops the type-DELETE friction and uses the shared archive
// copy -- matching the list view's dialog for the same action.
const softDelete = isFeatureEnabled(FeatureFlag.SoftDelete);
menuItems.push({
key: 'delete',
label: (
<ConfirmStatusChange
recoverable={softDelete}
title={
softDelete
? t('Archive %(name)s?', { name: chart.slice_name })
: t('Please confirm')
}
title={t('Please confirm')}
description={
softDelete ? (
<p>{archiveConfirmDescription(t('chart'))}</p>
) : (
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
?
</>
)
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
</>
}
onConfirm={() =>
handleChartDelete(
@@ -222,7 +204,7 @@ export default function ChartCard({
vertical-align: text-top;
`}
/>{' '}
{deleteActionLabel()}
{t('Delete')}
</button>
</Tooltip>
)}
@@ -522,7 +522,7 @@ const ExtraOptions = ({
onChange={onInputChange}
>
{t(
'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, Snowflake and Google Sheets)',
'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, and Google Sheets)',
)}
</Checkbox>
<InfoTooltip
@@ -532,10 +532,7 @@ const ExtraOptions = ({
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
'service account, but impersonate the currently logged on user via ' +
'hive.server2.proxy.user property. If Databricks, uses OAuth2 to ' +
'authenticate as the currently logged on user. If Snowflake or Google ' +
'Sheets, and OAuth authentication is configured for the database, will ' +
'run the queries as the currently logged on user via their own OAuth ' +
'credentials.',
'authenticate as the currently logged on user.',
)}
/>
</div>
@@ -669,11 +669,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -689,11 +689,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -709,11 +709,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -38,11 +38,10 @@ import {
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -29,11 +29,10 @@ import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
@@ -72,12 +72,6 @@ export const PermissionsField = ({
.replace(/_/g, ' ')
.includes(input.toLowerCase().replace(/_/g, ' '))
}
// Permission labels are long ("all datasource access on all_datasource_access",
// "can write on DashboardFilterStateRestApi"), and the dropdown otherwise
// inherits the trigger's width inside the modal, so every option was truncated
// to the point of being indistinguishable. Let the popup size to its content
// instead. See #40430.
popupMatchSelectWidth={false}
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
data-test="permissions-select"
/>
@@ -25,10 +25,8 @@ import {
fireEvent,
userEvent,
waitFor,
within,
selectOption,
} from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { MemoryRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -90,15 +88,6 @@ const mockCharts = [
// list so `_info` requests resolve to it rather than the broader list glob.
// withToasts injects the toast callbacks as props; the harness renders no
// toast container, so the spy is the only way to pin what the user is told.
// The type label for the dataset concept is flag-aware (SEMANTIC_LAYERS →
// "Datasource"); mock the flag reader so tests can exercise both states. The
// default (false for every flag) matches the real test environment, where no
// bootstrap flags are set.
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(() => false),
}));
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
@@ -155,13 +144,6 @@ beforeEach(() => {
mockAddDangerToast.mockClear();
});
afterEach(() => {
// The flag mock is shared module state; restore the environment default so a
// flag-flipping test that dies mid-body (e.g. by Jest timeout) cannot leak
// SEMANTIC_LAYERS into whichever test runs next.
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
});
test('renders archived rows with Name and Type columns', async () => {
mockRoutes();
renderArchivedList();
@@ -591,57 +573,3 @@ test('a viewer who can read none of the types gets an empty state, not three 403
// No list fetch was ever issued.
expect(fetchMock.callHistory.calls(/chart\/\?q/)).toHaveLength(0);
});
test('labels the dataset type "Datasource" when semantic layers is enabled', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.SemanticLayers,
);
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Datasource' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Dataset' }),
).not.toBeInTheDocument();
// Selecting the renamed option still drives the dataset resource —
// the underlying type value is flag-independent.
await selectOption('Datasource', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
// Pin the Type COLUMN cell, not just the Select's own rendered value.
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Datasource'),
).toBeInTheDocument();
});
test('labels the dataset type "Dataset" when semantic layers is disabled', async () => {
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Dataset' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Datasource' }),
).not.toBeInTheDocument();
await selectOption('Dataset', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Dataset'),
).toBeInTheDocument();
});
@@ -37,7 +37,6 @@ import {
type ListViewFilters,
} from 'src/components';
import SubMenu from 'src/features/home/SubMenu';
import { datasetLabel } from 'src/features/semanticLayers/label';
import withToasts from 'src/components/MessageToasts/withToasts';
import { recoveredToast } from 'src/utils/softDeleteCopy';
import { findPermission } from 'src/utils/findPermission';
@@ -83,12 +82,10 @@ const EmptyStateRow = styled.div`
`}
`;
// Getters, not strings: the dataset label follows the SEMANTIC_LAYERS flag
// ("Dataset" / "Datasource"), read at render time via the shared naming module.
const TYPE_LABELS: Record<ArchivedType, () => string> = {
chart: () => t('Chart'),
dashboard: () => t('Dashboard'),
dataset: datasetLabel,
const TYPE_LABELS: Record<ArchivedType, string> = {
chart: t('Chart'),
dashboard: t('Dashboard'),
dataset: t('Dataset'),
};
interface ToastProps {
@@ -169,7 +166,7 @@ function ArchivedListBody({
refreshData,
} = useListViewResource<ArchivedItem>(
config.resource,
TYPE_LABELS[type](),
TYPE_LABELS[type],
addDangerToast,
true,
[],
@@ -250,7 +247,7 @@ function ArchivedListBody({
name => {
const { text, options } = recoveredToast(
name,
TYPE_LABELS[type](),
TYPE_LABELS[type],
item.url ?? item.explore_url,
);
addSuccessToast(text, options);
@@ -309,7 +306,7 @@ function ArchivedListBody({
id: config.nameField,
},
{
Cell: () => TYPE_LABELS[type](),
Cell: () => TYPE_LABELS[type],
Header: t('Type'),
id: 'type',
disableSortBy: true,
@@ -542,7 +539,7 @@ function ArchivedList({ addDangerToast, addSuccessToast }: ToastProps) {
onChange={handleTypeChange}
options={availableTypes.map(option => ({
value: option,
label: TYPE_LABELS[option](),
label: TYPE_LABELS[option],
}))}
/>
</TypeSelectRow>
@@ -182,7 +182,7 @@ test('With sql role - renders all panels on the page on page load', async () =>
test('With sql role - renders distinct recent activities', async () => {
await renderWelcome();
const recentPanel = screen.getByRole('button', { name: 'Recents' });
const recentPanel = screen.getByRole('button', { name: 'collapsed Recents' });
userEvent.click(recentPanel);
await waitFor(() =>
expect(
@@ -96,29 +96,19 @@ test('a malformed window does not leak into the copy', () => {
test('the confirm copy quotes the window when there is one', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 30 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 30 days.',
'This chart will be moved to Recently Archived. You can recover it there within 30 days.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 30 days.',
);
});
test('a one-day window is quoted in the singular', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 1 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 1 day.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 1 day.',
'These charts will be moved to Recently Archived. You can recover them there within 30 days.',
);
});
test('the confirm copy omits the clause when there is no window', () => {
withConf({});
expect(archiveConfirmDescription('dashboard')).toBe(
'This dashboard will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This dashboard will be moved to Recently Archived. You can recover it there.',
);
expect(archiveConfirmDescription('dashboards', true)).toBe(
'These dashboards will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These dashboards will be moved to Recently Archived. You can recover them there.',
);
});
+7 -14
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { escape } from 'lodash-es';
import { t, tn } from '@apache-superset/core/translation';
import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
@@ -62,32 +62,25 @@ export function archiveConfirmDescription(
// Each case is a single, complete translation unit (rather than two joined
// fragments) so translators control the whole sentence; only the noun and the
// day count are interpolated, matching Superset's existing `%(...)s` usage.
// The timed variants pluralize on the day count (`tn`) because the retention
// window accepts 1: "within 1 days" is exactly the copy defect this module
// exists to prevent.
const days = getSoftDeleteRetentionDays();
if (days) {
return plural
? tn(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s day.',
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s days.',
days,
? t(
'These %(type)s will be moved to Recently Archived. You can recover them there within %(days)s days.',
{ type: typeLabel, days },
)
: tn(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s day.',
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s days.',
days,
: t(
'This %(type)s will be moved to Recently Archived. You can recover it there within %(days)s days.',
{ type: typeLabel, days },
);
}
return plural
? t(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These %(type)s will be moved to Recently Archived. You can recover them there.',
{ type: typeLabel },
)
: t(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This %(type)s will be moved to Recently Archived. You can recover it there.',
{ type: typeLabel },
);
}
@@ -43,21 +43,6 @@ function findEndpoint(spy: jest.SpyInstance, substring: string): string {
return (match[0] as Record<string, string>).endpoint;
}
function deferredJsonResponse() {
let resolveResponse: ((value: JsonResponse) => void) | undefined;
let rejectResponse: ((reason?: unknown) => void) | undefined;
const promise = new Promise<JsonResponse>((resolve, reject) => {
resolveResponse = resolve;
rejectResponse = reject;
});
if (!resolveResponse || !rejectResponse) {
throw new Error('Deferred response handlers were not initialized');
}
return { promise, resolve: resolveResponse, reject: rejectResponse };
}
beforeEach(() => {
jest.restoreAllMocks();
});
@@ -297,147 +282,6 @@ test('useListViewResource: fetchData sets loading to true then false', async ()
});
});
test('useListViewResource: ignores an older response that resolves last', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const toISOString = jest
.spyOn(Date.prototype, 'toISOString')
.mockReturnValueOnce('newer-response-time')
.mockReturnValueOnce('older-response-time');
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
newer.resolve({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }, { id: 2 }], count: 2 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
expect(toISOString).toHaveBeenCalledTimes(1);
});
test('useListViewResource: stale completion keeps the latest request loading', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.resolve({
json: { result: [{ id: 2 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([{ id: 2 }]);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: only the latest request reports an error', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const handleErrorMsg = jest.fn();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', handleErrorMsg, false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.reject('older request failed');
});
expect(handleErrorMsg).not.toHaveBeenCalled();
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.reject('newer request failed');
});
expect(handleErrorMsg).toHaveBeenCalledTimes(1);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: refreshData re-fetches with last config', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
+10 -21
View File
@@ -148,7 +148,6 @@ export function useListViewResource<D extends object = any>(
);
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
const latestRequestIdRef = useRef(0);
const fetchData = useCallback(
({
@@ -157,9 +156,6 @@ export function useListViewResource<D extends object = any>(
sortBy,
filters: filterValues,
}: FetchDataConfig) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const isLatest = () => latestRequestIdRef.current === requestId;
const config: FetchDataConfig = {
filters: filterValues,
pageIndex,
@@ -200,31 +196,24 @@ export function useListViewResource<D extends object = any>(
})
.then(
({ json = {} }) => {
if (!isLatest()) {
return;
}
updateState({
collection: json.result,
count: json.count,
lastFetched: new Date().toISOString(),
});
},
createErrorHandler(errMsg => {
if (isLatest()) {
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
);
}
}),
createErrorHandler(errMsg =>
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
),
),
)
.finally(() => {
if (isLatest()) {
updateState({ loading: false });
}
updateState({ loading: false });
});
},
[
+10
View File
@@ -28,6 +28,16 @@ from werkzeug.local import LocalProxy
# form.
flask_appbuilder.Model.__allow_unmapped__ = True
# pandas >= 2.2 advertises a minimum SQLAlchemy of 2.0 and silently ignores
# older installations, breaking DataFrame.to_sql / read_sql with SQLAlchemy
# 1.4 engines. Its SQL layer still works with 1.4, so restore support until
# Superset itself requires SQLAlchemy >= 2. Must run before any pandas SQL IO.
from superset.utils.pandas_sqlalchemy_compat import ( # noqa: E402
restore_pandas_sqlalchemy_support,
)
restore_pandas_sqlalchemy_support()
from superset.app import create_app # noqa: E402, F401
from superset.extensions import ( # noqa: E402
appbuilder, # noqa: F401
+1 -21
View File
@@ -18,7 +18,6 @@ import logging
from functools import partial
from typing import Any
from jinja2.exceptions import TemplateError
from marshmallow import ValidationError
from superset import security_manager
@@ -111,26 +110,7 @@ class DeleteTaggedObjectCommand(DeleteMixin, BaseCommand):
elif object_type == ObjectType.chart:
security_manager.raise_for_access(chart=target_object)
elif object_type == ObjectType.query:
# Authorizing a query without blanket database access parses its
# Jinja-templated SQL, which can raise ``TemplateError`` for
# malformed templates. Convert that into a validation error
# rather than letting it surface as an opaque 500.
try:
security_manager.raise_for_access(query=target_object)
except TemplateError as ex:
logger.warning(
"Failed to render Jinja SQL while validating access "
"for %s %s: %s",
object_type,
object_id,
ex,
)
exceptions.append(
TaggedObjectDeleteFailedError(
f"Access validation failed for {object_type} "
f"{object_id}: {ex}"
)
)
security_manager.raise_for_access(query=target_object)
elif object_type == ObjectType.dataset:
security_manager.raise_for_access(datasource=target_object)
else:
+6 -19
View File
@@ -196,26 +196,13 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
# 1. 'metric_name' - name of predefined metric
# 2. { label: 'label_name' } - legacy format for a predefined metric
# 3. { expressionType: 'SIMPLE' | 'SQL', ... } - adhoc metric
# Keys that only ever appear on an ad-hoc metric definition. A dict
# carrying one of these but missing `expressionType` is a malformed
# ad-hoc metric, not a legacy predefined-metric reference, and must
# not be silently collapsed to its label, which would later be
# misread as a request for a saved metric of that name.
adhoc_metric_keys = {"sqlExpression", "aggregate", "column"}
def is_str_or_adhoc(metric: Metric) -> bool:
return isinstance(metric, str) or is_adhoc_metric(metric)
def normalize_metric(metric: Metric) -> Metric:
if isinstance(metric, str) or is_adhoc_metric(metric):
return metric
if adhoc_metric_keys & metric.keys():
raise QueryObjectValidationError(
_(
"Invalid ad-hoc metric %(label)s: `expressionType` is missing",
label=metric.get("label"),
)
)
return metric["label"] # type: ignore
self.metrics = metrics and [normalize_metric(x) for x in metrics]
self.metrics = metrics and [
x if is_str_or_adhoc(x) else x["label"] # type: ignore
for x in metrics
]
def _set_post_processing(
self, post_processing: list[dict[str, Any] | None] | None
-65
View File
@@ -99,7 +99,6 @@ from superset.models.helpers import (
AuditMixinNullable,
CertificationMixin,
ExploreMixin,
get_effective_hours_offset,
ImportExportMixin,
QueryResult,
SoftDeleteMixin,
@@ -1248,8 +1247,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
time_grain: str | None,
label: str | None = None,
template_processor: BaseTemplateProcessor | None = None,
apply_dataset_offset: bool = False,
sql_shifted_temporal_labels: set[str] | None = None,
) -> TimestampExpression | Label:
"""
Return a SQLAlchemy Core element representation of self to be used in a query.
@@ -1257,8 +1254,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
:param time_grain: Optional time grain, e.g. P1Y
:param label: alias/label that column is expected to have
:param template_processor: template processor
:param apply_dataset_offset: shift the selected axis before truncation
:param sql_shifted_temporal_labels: labels shifted before truncation
:return: A TimeExpression object wrapped in a Label if supported by db
"""
label = label or utils.DTTM_ALIAS
@@ -1296,27 +1291,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
col = literal_column(expression, type_=type_)
else:
col = column(self.column_name, type_=type_)
if (
apply_dataset_offset
and time_grain
and self.table
and self.db_engine_spec.supports_temporal_column_shift
and (offset_hours := self.table.offset or 0)
and not self.table.get_dataset_timezone()
):
effective_offset_hours = get_effective_hours_offset(
self.db_engine_spec,
self.type,
offset_hours,
db_extra=self.db_extra,
)
if effective_offset_hours:
col = self.db_engine_spec.get_temporal_column_shift_expr(
col,
effective_offset_hours,
)
if sql_shifted_temporal_labels is not None:
sql_shifted_temporal_labels.add(label)
time_expr = self.db_engine_spec.get_timestamp_expr(col, pdf, time_grain)
return self.database.make_sqla_column_compatible(time_expr, label)
@@ -2001,26 +1975,11 @@ class SqlaTable(
)
) from ex
def _shift_temporal_column_if_needed(
self,
sqla_column: ColumnClause,
effective_offset_hours: int,
) -> ColumnClause:
"""Apply a nonzero effective dataset offset to a temporal expression."""
if not effective_offset_hours:
return sqla_column
return self.db_engine_spec.get_temporal_column_shift_expr(
sqla_column,
effective_offset_hours,
)
def adhoc_column_to_sqla( # pylint: disable=too-many-locals
self,
col: AdhocColumn,
force_type_check: bool = False,
template_processor: BaseTemplateProcessor | None = None,
apply_dataset_offset: bool = False,
sql_shifted_temporal_labels: set[str] | None = None,
) -> tuple[ColumnElement, utils.GenericDataType | None]:
"""
Turn an adhoc column into a sqlalchemy column.
@@ -2030,8 +1989,6 @@ class SqlaTable(
This is needed to validate if a filter with an adhoc column
is applicable.
:param template_processor: template_processor instance
:param apply_dataset_offset: shift the selected axis before truncation
:param sql_shifted_temporal_labels: labels shifted before truncation
:returns: A tuple of (SQLAlchemy column, generic column type). The
generic type is populated when the column type is resolved
(either because the adhoc column matches a physical column, or
@@ -2048,7 +2005,6 @@ class SqlaTable(
pdf = None
is_column_reference = col.get("isColumnReference", False)
generic_type: utils.GenericDataType | None = None
native_type: str | None = None
metadata_lookup_key = self._render_adhoc_expression_for_metadata_lookup(
sql_expression, template_processor
@@ -2063,7 +2019,6 @@ class SqlaTable(
is_dttm = col_in_metadata.is_temporal
pdf = col_in_metadata.python_date_format
generic_type = col_in_metadata.type_generic
native_type = col_in_metadata.type
else:
# Column doesn't exist in metadata or is not a reference - treat as ad-hoc
# expression Note: If isColumnReference=true but column not found, we still
@@ -2126,28 +2081,8 @@ class SqlaTable(
# stay unquoted for numeric adhoc expressions like
# CAST(... AS BIGINT)).
generic_type = col_desc[0].get("type_generic")
probed_type = col_desc[0].get("type")
native_type = str(probed_type) if probed_type is not None else None
if is_dttm and has_timegrain:
if (
apply_dataset_offset
and self.db_engine_spec.supports_temporal_column_shift
and (offset_hours := self.offset or 0)
and not self.get_dataset_timezone()
):
effective_offset_hours = get_effective_hours_offset(
self.db_engine_spec,
native_type,
offset_hours,
db_extra=self.db_extra,
)
sqla_column = self._shift_temporal_column_if_needed(
sqla_column,
effective_offset_hours,
)
if sql_shifted_temporal_labels is not None:
sql_shifted_temporal_labels.add(label)
sqla_column = self.db_engine_spec.get_timestamp_expr(
col=sqla_column,
pdf=pdf,
+5 -5
View File
@@ -21,7 +21,11 @@ from flask import g
from sqlalchemy.exc import NoResultFound
from superset.commands.tag.exceptions import TagNotFoundError
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.commands.tag.utils import (
current_user_can_modify_object,
to_object_model,
to_object_type,
)
from superset.daos.base import BaseDAO
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
@@ -345,10 +349,6 @@ class TagDAO(BaseDAO[Tag]):
Returns:
None.
"""
# Imported lazily: superset.commands.utils itself imports TagDAO from
# this module, so a top-level import here would be circular.
from superset.commands.utils import current_user_can_modify_object
tagged_objects = []
if not tag:
raise TagNotFoundError()
+8 -22
View File
@@ -539,7 +539,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
# the ``array_*`` capability methods below must be implemented. Defaults to
# False so engines that have not opted in keep treating arrays as strings.
supports_multivalue_columns = False
supports_temporal_column_shift: bool = False
allows_joins = True
allows_subqueries = True
allows_alias_in_select = True
@@ -1243,19 +1242,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return TimestampExpression(time_expr, col, type_=col.type)
@classmethod
def get_temporal_column_shift_expr(
cls,
col: ColumnClause,
offset_hours: int,
) -> TimestampExpression:
"""Shift a temporal SQL expression by a bounded number of hours."""
return TimestampExpression(
f"{{col}} + INTERVAL '{offset_hours}' HOUR",
col,
type_=col.type,
)
@classmethod
def _apply_year_to_dttm(cls, time_expr: str) -> str:
"""
@@ -1392,12 +1378,12 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return cursor.fetchmany(limit)
data = cursor.fetchall()
description = cursor.description or []
# Create a mapping between column index and a mutator function to normalize
# values with. The first two items in the description row are the column
# name and type.
# Create a mapping between column name and a mutator function to normalize
# values with. The first two items in the description row are
# the column name and type.
column_mutators = {
index: func
for index, row in enumerate(description)
row[0]: func
for row in description
if (
func := cls.column_type_mutators.get(
type(cls.get_sqla_column_type(cls.get_datatype(row[1])))
@@ -1405,11 +1391,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
)
}
if column_mutators:
if not isinstance(data, list):
data = list(data)
indexes = {row[0]: idx for idx, row in enumerate(description)}
for row_idx, row in enumerate(data):
new_row = list(row)
for col_idx, func in column_mutators.items():
for col, func in column_mutators.items():
col_idx = indexes[col]
new_row[col_idx] = func(row[col_idx])
data[row_idx] = tuple(new_row)
+1 -23
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, TYPE_CHECKING, TypedDict
@@ -33,7 +32,7 @@ from marshmallow.exceptions import ValidationError
from requests import Session
from shillelagh.adapters.api.gsheets.lib import SCOPES
from shillelagh.exceptions import UnauthenticatedError
from sqlalchemy import text, types
from sqlalchemy import text
from sqlalchemy.engine import create_engine
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
@@ -156,27 +155,6 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
oauth2_token_request_uri = "https://oauth2.googleapis.com/token" # noqa: S105
oauth2_exception = (UnauthenticatedError, OAuth2TokenRefreshError)
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
) -> str | None:
"""
Convert a datetime to a SQL literal understood by shillelagh's GSheets
adapter.
``SqliteEngineSpec.convert_dttm`` (inherited via ``ShillelaghEngineSpec``)
has no case for ``types.Date`` and returns ``None``, which makes Superset
fall back to a literal that still carries a time-of-day component. The
GSheets adapter's virtual table layer parses that literal with
``datetime.date.fromisoformat``, which rejects the trailing time and
silently drops the filter value, producing an invalid query against the
Google Sheets API. A bare ``YYYY-MM-DD`` literal is required instead.
"""
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"'{dttm.date().isoformat()}'"
return super().convert_dttm(target_type, dttm, db_extra=db_extra)
@classmethod
def get_oauth2_authorization_uri(
cls,
+14 -56
View File
@@ -267,43 +267,6 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
types.VARCHAR(),
GenericDataType.STRING,
),
# wire-protocol FIELD_TYPE names emitted by `get_datatype`, seen on
# SQL Lab and virtual dataset columns instead of DDL type names
(
re.compile(r"^newdecimal", re.IGNORECASE),
DECIMAL(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^tiny$", re.IGNORECASE),
TINYINT(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^short$", re.IGNORECASE),
types.SmallInteger(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^(blob|text)$", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r"^year$", re.IGNORECASE),
types.Integer(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^enum\b", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r"^set\b", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
)
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
DECIMAL: lambda val: Decimal(val) if isinstance(val, str) else val
@@ -462,27 +425,22 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
@classmethod
def get_datatype(cls, type_code: Any) -> Optional[str]:
if not cls.type_code_map:
# only import and store if needed at least once
# pylint: disable=import-outside-toplevel
try:
import MySQLdb
mysql_module = MySQLdb
except ImportError:
mysql_module = __import__("pymysql")
ft = mysql_module.constants.FIELD_TYPE
cls.type_code_map = {
getattr(ft, k): k for k in dir(ft) if not k.startswith("_")
}
datatype = type_code
if isinstance(type_code, int):
if not cls.type_code_map:
# only import and store if needed at least once
# pylint: disable=import-outside-toplevel
try:
import MySQLdb
ft = MySQLdb.constants.FIELD_TYPE
except ImportError:
try:
import pymysql # type: ignore[import-untyped]
ft = pymysql.constants.FIELD_TYPE
except ImportError:
from mysql.connector.constants import FieldType
ft = FieldType
cls.type_code_map = {
getattr(ft, k): k for k in dir(ft) if not k.startswith("_")
}
datatype = cls.type_code_map.get(type_code)
if datatype and isinstance(datatype, str) and datatype:
return datatype
-1
View File
@@ -360,7 +360,6 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
supports_catalog = True
supports_dynamic_catalog = True
supports_grouping_sets = True
supports_temporal_column_shift = True
default_driver = "psycopg2"
parameters_schema = PostgresParametersSchema()
+2 -187
View File
@@ -20,23 +20,21 @@ import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, Callable, cast, Optional, TYPE_CHECKING, TypedDict
from typing import Any, Callable, Optional, TYPE_CHECKING, TypedDict
from urllib import parse
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from flask import current_app as app, has_request_context
from flask import current_app as app
from flask_babel import gettext as __
from marshmallow import fields, Schema
from sqlalchemy import text, types
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError
from sqlalchemy.sql.elements import ColumnElement
from superset import is_feature_enabled, security_manager
from superset.constants import TimeGrain
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import (
@@ -46,63 +44,13 @@ from superset.db_engine_specs.base import (
)
from superset.db_engine_specs.postgres import PostgresBaseEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2TokenRefreshError
from superset.models.sql_lab import Query
from superset.superset_typing import (
OAuth2ClientConfig,
OAuth2State,
)
from superset.utils import json
from superset.utils.core import get_user_agent, QuerySource
from superset.utils.oauth2 import encode_oauth2_state, generate_code_challenge
if TYPE_CHECKING:
from superset.models.core import Database
try:
from snowflake.connector.errors import DatabaseError
except ImportError:
# Use a distinct sentinel type when snowflake is not installed to avoid
# matching unrelated exception types (using `Exception` would be too broad).
class _SnowflakeDatabaseError(Exception):
"""Sentinel type to stand in for snowflake.connector.errors.DatabaseError."""
pass
DatabaseError = _SnowflakeDatabaseError
class CustomSnowflakeAuthErrorMeta(type):
"""
Metaclass whose ``__instancecheck__`` matches Snowflake's invalid/expired
OAuth access-token error, so ``CustomSnowflakeAuthError`` can be used as the
``oauth2_exception`` that triggers the OAuth2 re-auth dance.
This is only honored via ``isinstance()`` (the path used by
``BaseEngineSpec.needs_oauth2()``); ``except`` clauses do not call
``__instancecheck__``, so it must not be relied on for exception catching.
"""
def __instancecheck__(cls, instance: object) -> bool:
"""
Match Snowflake's invalid/expired OAuth token error, whether it arrives
wrapped by SQLAlchemy (e.g. ``Engine``-based execution) or as the raw
DBAPI exception ``BaseEngineSpec.execute()`` runs against a bare
cursor and never wraps it, so both shapes must be handled here.
"""
orig: object = instance
if isinstance(instance, SqlalchemyDatabaseError):
orig = cast(SqlalchemyDatabaseError, instance).orig
return isinstance(orig, DatabaseError) and "Invalid OAuth access token" in str(
orig
)
class CustomSnowflakeAuthError(DatabaseError, metaclass=CustomSnowflakeAuthErrorMeta):
"""Snowflake OAuth error type matched via the metaclass above (see note there)."""
# Regular expressions to catch custom errors
OBJECT_DOES_NOT_EXIST_REGEX = re.compile(
r"Object (?P<object>.*?) does not exist or not authorized."
@@ -212,7 +160,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
encrypted_extra_sensitive_fields = {
"$.auth_params.privatekey_body": "Private Key Body",
"$.auth_params.privatekey_pass": "Private Key Password",
"$.oauth2_client_info.secret": "OAuth2 Client Secret",
}
_time_grain_expressions = {
@@ -251,126 +198,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
),
}
# OAuth 2.0 support
supports_oauth2: bool = True
# `CustomSnowflakeAuthError` is only matched via `isinstance()` (see the
# metaclass docstring above), so it's paired with `OAuth2TokenRefreshError`
# (a real subclass) to keep `refresh_oauth2_token`'s `except` clause working.
oauth2_exception: type[Exception] | tuple[type[Exception], ...] = (
CustomSnowflakeAuthError,
OAuth2TokenRefreshError,
)
@classmethod
def is_oauth2_enabled(cls) -> bool:
"""
Return whether OAuth2 authentication is enabled.
"""
# When alerts or reports connect to the database in the background,
# OAuth2 authentication fails; therefore, OAuth2 authentication is disabled
# for background execution.
if not has_request_context():
return False
return (
cls.supports_oauth2
and cls.engine_name in app.config["DATABASE_OAUTH2_CLIENTS"]
)
@classmethod
def get_oauth2_config(cls) -> OAuth2ClientConfig | None:
"""
Build the DB engine spec level OAuth2 client config.
"""
if not cls.is_oauth2_enabled():
return None
return super().get_oauth2_config()
@classmethod
def impersonate_user(
cls,
database: Database,
username: str | None,
user_token: str | None,
url: URL,
engine_kwargs: dict[str, Any],
) -> tuple[URL, dict[str, Any]]:
"""
Modify URL and/or engine kwargs to impersonate a different user.
"""
connect_args: dict[str, Any] = engine_kwargs.setdefault("connect_args", {})
# When test_connection is executed (i.e., when validate_default_parameters is
# set to True in connect_args), authentication via OAuth is not performed.
#
# ``database.is_oauth2_enabled()`` returns True for a database-level OAuth2
# client (``encrypted_extra.oauth2_client_info``) regardless of request
# context, unlike the app-config-based check in ``is_oauth2_enabled()``
# above. Background executions (alerts/reports) have no per-user token, so
# ``has_request_context()`` must be checked explicitly here too, or OAuth
# gets switched on with no token to send.
if (
not connect_args.get("validate_default_parameters", False)
and has_request_context()
and database.is_oauth2_enabled()
):
url = url.update_query_dict({"authenticator": "oauth"})
connect_args["authenticator"] = "oauth"
if user_token:
if username is not None:
if is_feature_enabled("IMPERSONATE_WITH_EMAIL_PREFIX"):
# ``Database._get_sqla_engine()`` has already looked
# up the login and substituted the email prefix into
# ``username`` before calling this method when this
# flag is on. Looking it up again here as if it were
# still the login would fail whenever the two differ,
# leaving the default/service-account username paired
# with this user's OAuth token. Use it as given.
url = url.set(username=username)
else:
user = security_manager.find_user(username=username)
if user and user.email:
url = url.set(username=user.email)
url = url.update_query_dict({"token": user_token})
return url, engine_kwargs
@classmethod
def get_oauth2_authorization_uri(
cls,
config: OAuth2ClientConfig,
state: OAuth2State,
code_verifier: str | None = None, # pylint: disable=unused-argument
) -> str:
"""
Return URI for initial OAuth2 request.
"""
uri = config["authorization_request_uri"]
# When calling the Snowflake OAuth authorization endpoint for a custom client,
# specify only the query parameters documented in the URL below.
# Adding unsupported parameters
# (e.g., `prompt` as used in BaseEngineSpec.get_oauth2_authorization_uri)
# will cause an error.
# https://docs.snowflake.com/user-guide/oauth-custom#query-parameters
params: dict[str, str] = {
"scope": config["scope"],
"response_type": "code",
"state": encode_oauth2_state(state),
"redirect_uri": config["redirect_uri"],
"client_id": config["id"],
}
# Add PKCE parameters (RFC 7636) if code_verifier is provided
if code_verifier:
params["code_challenge"] = generate_code_challenge(code_verifier)
params["code_challenge_method"] = "S256"
return parse.urljoin(uri, "?" + parse.urlencode(params))
@staticmethod
def get_extra_params(
database: Database, source: QuerySource | None = None
@@ -621,18 +448,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
database: "Database",
params: dict[str, Any],
) -> None:
# To use OAuth authentication, a database connection must first be created using
# another authenticator (typically key-pair authentication)
# with “Impersonate logged in user” enabled.
# Key-pair authentication is used for connection tests,
# while OAuth authentication is used when executing actual queries,
# such as in SQL Lab or dashboards.
# Therefore, when using OAuth authentication, the key-pair authentication
# settings are not loaded, and the connection is established using OAuth only.
connect_args: dict[str, Any] = params.get("connect_args") or {}
if connect_args.get("authenticator") == "oauth":
return
if not database.encrypted_extra:
return
try:
+1 -21
View File
@@ -25,14 +25,9 @@ from typing import Any, TYPE_CHECKING
from flask_babel import gettext as __
from sqlalchemy import types
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql.elements import ColumnClause
from superset.constants import TimeGrain
from superset.db_engine_specs.base import (
BaseEngineSpec,
DatabaseCategory,
TimestampExpression,
)
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
from superset.errors import SupersetErrorType
if TYPE_CHECKING:
@@ -48,7 +43,6 @@ class SqliteEngineSpec(BaseEngineSpec):
disable_ssh_tunneling = True
supports_multivalues_insert = True
supports_temporal_column_shift = True
metadata = {
"description": "SQLite is a self-contained, serverless SQL database engine.",
@@ -146,20 +140,6 @@ class SqliteEngineSpec(BaseEngineSpec):
"ELSE printf('%04d-01-01', CAST({col} AS INTEGER)) END)"
)
@classmethod
def get_temporal_column_shift_expr(
cls,
col: ColumnClause,
offset_hours: int,
) -> TimestampExpression:
"""Shift a temporal expression with SQLite's datetime modifier syntax."""
modifier = f"{offset_hours:+d} hours"
return TimestampExpression(
f"DATETIME({{col}}, '{modifier}')",
col,
type_=col.type,
)
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
+9
View File
@@ -30,6 +30,7 @@ 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
@@ -110,6 +111,8 @@ 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()
@@ -183,6 +186,8 @@ 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()
@@ -234,6 +239,8 @@ 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()
@@ -286,3 +293,5 @@ 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))
+10 -1
View File
@@ -16,6 +16,8 @@
# under the License.
from typing import Optional
from jinja2.exceptions import TemplateError
from superset import security_manager
from superset.commands.chart.exceptions import (
ChartAccessDeniedError,
@@ -33,6 +35,7 @@ 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
@@ -53,7 +56,13 @@ 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:
security_manager.raise_for_access(query=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
return True
raise QueryNotFoundValidationError()
+4 -17
View File
@@ -672,34 +672,21 @@ kubectl get ingress -n superset
| Variable | Description | Default |
|----------|-------------|---------|
| `MCP_DEV_USERNAME` | Superset username for MCP authentication in dev mode. Mutually exclusive with `MCP_AUTH_ENABLED = True`: the server refuses to start if both are set. | - |
| `MCP_AUTH_ENABLED` | Enable/disable authentication | `false` |
| `MCP_DEV_USERNAME` | Superset username for MCP authentication | `admin` |
| `MCP_AUTH_ENABLED` | Enable/disable authentication | `true` |
| `MCP_JWT_PUBLIC_KEY` | JWT public key for token validation | - |
| `SUPERSET_WEBSERVER_ADDRESS` | Internal Superset URL | `http://localhost:8088` |
| `WEBDRIVER_BASEURL` | URL for screenshot generation | Same as webserver |
#### superset_config.py Options
Dev mode (`MCP_DEV_USERNAME`) and JWT authentication (`MCP_AUTH_ENABLED`) are
mutually exclusive -- the server raises at startup if both are set, since a
fixed dev-mode identity would defeat the point of requiring real auth. Pick one:
```python
# MCP Service Configuration -- development/testing (no auth)
# MCP Service Configuration
MCP_DEV_USERNAME = 'admin' # Username for development/testing
# WebDriver for chart screenshots
WEBDRIVER_BASEURL = 'http://superset:8088/'
WEBDRIVER_TYPE = 'chrome'
WEBDRIVER_OPTION_ARGS = ['--headless', '--no-sandbox']
```
```python
# MCP Service Configuration -- production with JWT authentication
MCP_AUTH_ENABLED = True # Enable authentication
MCP_JWT_PUBLIC_KEY = 'your-public-key' # For JWT token validation
# Or, for a fully custom auth setup instead of the built-in JWT verifier:
# For production with JWT authentication
MCP_AUTH_FACTORY = 'your.custom.auth_factory'
MCP_USER_RESOLVER = 'your.custom.user_resolver'
+5 -22
View File
@@ -58,7 +58,6 @@ if os.environ.get("FASTMCP_TRANSPORT", "stdio") == "stdio":
click.secho = secho_to_stderr
from superset.mcp_service.app import init_fastmcp_server, mcp
from superset.mcp_service.caching import create_response_caching_middleware
from superset.mcp_service.middleware import create_response_size_guard_middleware
from superset.mcp_service.server import build_middleware_list
@@ -68,9 +67,8 @@ def _add_default_middlewares() -> None:
Delegates to ``server.build_middleware_list()`` for the core stack so
the stdio entry point stays in sync with the HTTP server without
duplicating middleware ordering. The optional response size guard and
response caching middleware are appended separately (innermost
position, same order as in run_server()).
duplicating middleware ordering. The optional response size guard is
appended separately (innermost position, same as in run_server()).
FastMCP wraps handlers so that the FIRST-added middleware is outermost.
``build_middleware_list()`` already returns middlewares in the correct
@@ -79,16 +77,12 @@ def _add_default_middlewares() -> None:
for middleware in build_middleware_list():
mcp.add_middleware(middleware)
# Response size guard is innermost (added last), then response caching.
# Response size guard is innermost (added last)
if size_guard := create_response_size_guard_middleware():
mcp.add_middleware(size_guard)
limit = size_guard.token_limit
sys.stderr.write(f"[MCP] Response size guard enabled (token_limit={limit})\n")
if caching_middleware := create_response_caching_middleware():
mcp.add_middleware(caching_middleware)
sys.stderr.write("[MCP] Response caching enabled\n")
def main() -> None:
"""
@@ -163,19 +157,8 @@ def main() -> None:
sys.stderr.write(f"[MCP] Client disconnected: {e}\n")
sys.exit(0)
else:
# For other transports (network listeners), install the same auth
# provider as the supported entry point (`superset mcp run` ->
# server.run_server()) instead of starting with no verifier at all.
# _create_auth_provider fails closed (raises MCPAuthConfigError) when
# auth is configured but a verifier could not be built, so letting
# that propagate here refuses to start rather than silently running
# this transport unauthenticated.
from superset.mcp_service.flask_singleton import get_flask_app
from superset.mcp_service.server import _create_auth_provider
flask_app = get_flask_app()
auth_provider = _create_auth_provider(flask_app)
init_fastmcp_server(auth=auth_provider)
# For other transports, use normal initialization
init_fastmcp_server()
_add_default_middlewares()
# Run with specified transport
+2 -10
View File
@@ -575,8 +575,7 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | None: # noqa: C901
the corresponding ``GuestUser`` built from the token's resources/RLS.
Raises:
ValueError: If JWT resolves a username that doesn't exist in the DB,
or a guest-marked token is presented while guest auth is disabled
ValueError: If JWT resolves a username that doesn't exist in the DB
(fail closed do NOT fall through to weaker auth sources).
MCPAuthConfigError: If more than one JWT issuer is trusted
(``MCP_JWT_ISSUER`` is a list/tuple/set) and no issuer-aware
@@ -619,14 +618,7 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | None: # noqa: C901
"Guest-marked token presented but embedded guest auth is not "
"enabled; rejecting"
)
# Fail closed, matching the sibling failure branches below: a
# guest-marked token is an explicit (rejected) authentication
# attempt, not an absent one. Returning None here would let the
# request degrade to weaker auth sources (API key,
# MCP_DEV_USERNAME, or a middleware-set g.user).
raise ValueError(
"Guest-marked token presented but embedded guest auth is not enabled"
)
return None
logger.debug("Resolving MCP request as embedded guest user")
# Drop the internal marker so it does not leak into GuestUser.guest_token.
guest_claims: dict[str, Any] = {
-21
View File
@@ -123,27 +123,6 @@ def create_response_caching_middleware() -> Any | None:
logger.debug("MCP response caching disabled")
return None
# ResponseCachingMiddleware keys cache entries on the method/tool
# name + arguments only and runs ahead of the per-request auth/RBAC
# checks, so a cache hit returns a response computed for a different
# caller. Only appropriate when every request is guaranteed to come
# from the same principal.
# that sends byte-identical arguments within the TTL, skipping every
# authorization check. Fail closed unless the operator explicitly
# accepts a cache shared across principals -- only safe when every
# request is guaranteed to come from the same principal (e.g. a
# single-user development deployment).
if not cache_config.get("dangerously_share_cache_across_principals", False):
logger.warning(
"MCP_CACHE_CONFIG['enabled'] is set, but response caching "
"stays disabled: cache keys do not include the requesting "
"principal, so cached responses would be served across users "
"without any authorization checks. Set "
"'dangerously_share_cache_across_principals': True only when "
"all requests share a single principal."
)
return None
try:
from fastmcp.server.middleware.caching import ResponseCachingMiddleware
except ImportError:
@@ -116,53 +116,6 @@ async def restore_chart(
return RestoreChartResponse(success=False, error=msg, error_type="NotFound")
chart_id = chart.id
# The lookup above deliberately bypasses the RBAC base filter (see
# _find_chart_for_restore), so enforce the restore audience *before*
# composing any response that embeds the chart's name: without this gate,
# iterating identifiers would disclose the existence and exact title of
# charts the caller cannot see (the web API answers 404 for those).
from superset import security_manager
from superset.exceptions import SupersetSecurityException
try:
try:
security_manager.raise_for_editorship(chart)
except SupersetSecurityException:
from superset.daos.chart import ChartDAO
# Distinguish "visible but not an editor" from "outside the
# caller's RBAC scope": the latter must be indistinguishable
# from a chart that does not exist.
visible = ChartDAO.find_by_id_or_uuid(
str(request.identifier), skip_visibility_filter=True
)
if visible is None:
display_id = str(request.identifier)[:200]
return RestoreChartResponse(
success=False,
error=f"No chart found with identifier: {display_id}.",
error_type="NotFound",
)
await ctx.warning("Permission denied restoring chart id=%s" % (chart_id,))
return RestoreChartResponse(
success=False,
permission_denied=True,
error=(
f"You do not have permission to restore chart id={chart_id}. "
"Ask the user to restore it or grant access; do not retry."
),
error_type="Forbidden",
)
except SQLAlchemyError:
_rollback()
logger.exception("Editorship check failed during restore_chart")
return RestoreChartResponse(
success=False,
error="Chart lookup failed due to a database error.",
error_type="LookupFailed",
)
# Chart names are user-controlled and must remain exact in response text.
chart_name = chart.slice_name
@@ -118,56 +118,6 @@ async def restore_dashboard(
return RestoreDashboardResponse(success=False, error=msg, error_type="NotFound")
dashboard_id = dashboard.id
# The lookup above deliberately bypasses the RBAC base filter (see
# _find_dashboard_for_restore), so enforce the restore audience *before*
# composing any response that embeds the dashboard's title: without this
# gate, iterating identifiers would disclose the existence and exact title
# of dashboards the caller cannot see (the web API answers 404 for those).
from superset import security_manager
from superset.exceptions import SupersetSecurityException
try:
try:
security_manager.raise_for_editorship(dashboard)
except SupersetSecurityException:
from superset.daos.dashboard import DashboardDAO
# Distinguish "visible but not an editor" from "outside the
# caller's RBAC scope": the latter must be indistinguishable
# from a dashboard that does not exist.
visible = DashboardDAO.find_by_id_or_uuid(
str(request.identifier), skip_visibility_filter=True
)
if visible is None:
display_id = str(request.identifier)[:200]
return RestoreDashboardResponse(
success=False,
error=f"No dashboard found with identifier: {display_id}.",
error_type="NotFound",
)
await ctx.warning(
"Permission denied restoring dashboard id=%s" % (dashboard_id,)
)
return RestoreDashboardResponse(
success=False,
permission_denied=True,
error=(
f"You do not have permission to restore dashboard "
f"id={dashboard_id}. Ask the user to restore it or grant "
"access; do not retry."
),
error_type="Forbidden",
)
except SQLAlchemyError:
_rollback()
logger.exception("Editorship check failed during restore_dashboard")
return RestoreDashboardResponse(
success=False,
error="Dashboard lookup failed due to a database error.",
error_type="LookupFailed",
)
# Dashboard titles are user-controlled and must remain exact in response text.
dashboard_name = dashboard.dashboard_title
+24 -123
View File
@@ -259,9 +259,7 @@ MCP_FACTORY_CONFIG = {
#
# Configuration Flow:
# -------------------
# - MCP_CACHE_CONFIG controls whether caching is enabled and its TTL settings.
# Note "enabled" alone is not sufficient -- see
# "dangerously_share_cache_across_principals" below.
# - MCP_CACHE_CONFIG controls whether caching is enabled and its TTL settings
# - MCP_STORE_CONFIG controls the Redis store (optional)
#
# Scenarios:
@@ -272,13 +270,11 @@ MCP_FACTORY_CONFIG = {
#
# 2. Caching with in-memory store:
# MCP_CACHE_CONFIG["enabled"] = True
# MCP_CACHE_CONFIG["dangerously_share_cache_across_principals"] = True
# MCP_STORE_CONFIG["enabled"] = False (or not configured)
# → Caching uses FastMCP's default in-memory store, no Prefix wrapper used
#
# 3. Caching with Redis store:
# MCP_CACHE_CONFIG["enabled"] = True
# MCP_CACHE_CONFIG["dangerously_share_cache_across_principals"] = True
# MCP_STORE_CONFIG["enabled"] = True
# MCP_STORE_CONFIG["CACHE_REDIS_URL"] = "redis://..."
# → Caching uses Redis with PrefixKeysWrapper
@@ -326,13 +322,6 @@ MCP_STORE_CONFIG: dict[str, Any] = {
# When enabled with MCP_STORE_CONFIG, uses Redis store.
MCP_CACHE_CONFIG: dict[str, Any] = {
"enabled": False, # Disabled by default
# Cache keys are method/tool + arguments only and cache hits are served
# ahead of per-request auth/RBAC, so a shared cache can return one
# caller's response to another. Response caching refuses to start
# unless this is explicitly set -- only appropriate when every request
# is guaranteed to come from the same principal (e.g. a single-user
# development deployment).
"dangerously_share_cache_across_principals": False,
# Base prefix for the shared store. Superset appends an internal response-
# contract namespace so incompatible cached values are not reused.
"CACHE_KEY_PREFIX": None, # Only needed when using the store
@@ -343,38 +332,12 @@ MCP_CACHE_CONFIG: dict[str, Any] = {
"get_prompt_ttl": 60 * 60, # 1 hour
"call_tool_ttl": 60 * 60, # 1 hour
"max_item_size": 1024 * 1024, # 1MB
# Every tool whose ToolAnnotations set readOnlyHint=False, i.e. every tool
# with a side effect. A cache hit is served ahead of per-request
# auth/RBAC, so caching a mutating tool can replay a stale create/update/
# delete result -- including to a caller who repeats an identical call
# expecting it to run again. This list is enforced complete by
# test_mcp_caching.py::test_excluded_tools_covers_every_mutating_tool,
# which fails with the specific missing tool name(s) if a new
# non-read-only tool is added without also being added here.
"excluded_tools": [
"add_chart_to_existing_dashboard",
"create_dataset",
"create_theme",
"create_virtual_dataset",
"delete_chart",
"delete_dashboard",
"duplicate_dashboard",
"excluded_tools": [ # Tools that should never be cached (side effects, dynamic)
"execute_sql",
"generate_chart",
"generate_dashboard",
"generate_explore_link",
"manage_dashboard_certification",
"manage_dashboard_owners",
"manage_dashboard_roles",
"manage_native_filters",
"remove_chart_from_dashboard",
"restore_chart",
"restore_dashboard",
"save_sql_query",
"duplicate_dashboard",
"generate_chart",
"update_chart",
"update_chart_preview",
"update_dashboard",
"update_dataset_metric",
],
}
@@ -532,16 +495,6 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
if not (auth_enabled or api_key_enabled or guest_enabled):
return None
# MCP_DEV_USERNAME makes user resolution fall back to a fixed user for
# requests that carry no resolvable identity, which defeats the point of
# having transport auth enabled. Refuse the combination outright.
if auth_enabled and app.config.get("MCP_DEV_USERNAME"):
raise MCPAuthConfigError(
"MCP_DEV_USERNAME must not be set when MCP_AUTH_ENABLED is True: "
"it would execute callers without a resolvable identity as that "
"user. Unset MCP_DEV_USERNAME (a development-only convenience)."
)
# When JWT auth is enabled, an audience must be configured so issued tokens
# are bound to this service. Without it the verifier accepts any otherwise
# valid same-issuer token, regardless of which service it was minted for.
@@ -566,40 +519,22 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
secret = app.config.get("MCP_JWT_SECRET")
if not (jwks_uri or public_key or secret):
# Fail closed regardless of API-key/guest fallbacks: JWT auth was
# explicitly enabled, so silently starting without it would leave
# the operator's chosen JWT mode disabled without warning them
# via anything louder than a log line.
raise MCPAuthConfigError(
"MCP_AUTH_ENABLED is True but no JWT verification key is "
"configured; refusing to start an unauthenticated MCP "
"server. Set MCP_JWKS_URI, MCP_JWT_PUBLIC_KEY, or "
"MCP_JWT_SECRET (with MCP_JWT_ALGORITHM='HS256')."
)
try:
jwt_verifier = _build_jwt_verifier(
app=app,
jwks_uri=jwks_uri,
public_key=public_key,
secret=secret,
)
except MCPAuthConfigError:
raise
except Exception:
# Do not log or chain the exception — it may contain secrets
# (e.g., key material)
logger.error("Failed to create MCP JWT verifier")
# Fail closed regardless of API-key/guest fallbacks: JWT auth
# was explicitly enabled, so silently starting without it is
# a permissive state the operator did not choose.
raise MCPAuthConfigError(
"Failed to construct the MCP JWT verifier from the "
"configured key material; refusing to start with JWT "
"auth silently disabled. Verify MCP_JWT_ALGORITHM "
"matches the configured key (HS256 for MCP_JWT_SECRET; "
"RS256 needs MCP_JWKS_URI or MCP_JWT_PUBLIC_KEY)."
) from None
logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret configured")
if not (api_key_enabled or guest_enabled):
return None
else:
try:
jwt_verifier = _build_jwt_verifier(
app=app,
jwks_uri=jwks_uri,
public_key=public_key,
secret=secret,
)
except Exception:
# Do not log the exception — it may contain secrets (e.g., key material)
logger.error("Failed to create MCP JWT verifier")
if not (api_key_enabled or guest_enabled):
return None
# A composite verifier is needed whenever API-key OR guest auth is on, so
# those token types are recognized before (or instead of) the JWT verifier.
@@ -773,49 +708,15 @@ def _build_jwt_verifier(
"required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
}
algorithm = app.config.get("MCP_JWT_ALGORITHM", "RS256")
if algorithm in ("HS256", "HS384", "HS512"):
# HMAC algorithms are symmetric: verification MUST be keyed on an
# explicit shared secret, never on public-key material (PEM or
# JWKS), which isn't confidential. Refuse the contradictory
# configuration outright instead of honoring it.
if not secret:
raise MCPAuthConfigError(
f"MCP_JWT_ALGORITHM is '{algorithm}' but MCP_JWT_SECRET is "
"not set. Refusing to build an HMAC verifier keyed on "
"public-key material. Set MCP_JWT_SECRET, or switch to an "
"asymmetric algorithm (e.g. RS256) with MCP_JWT_PUBLIC_KEY "
"or MCP_JWKS_URI."
)
if public_key or jwks_uri:
raise MCPAuthConfigError(
"MCP_JWT_PUBLIC_KEY/MCP_JWKS_URI are configured alongside "
f"MCP_JWT_ALGORITHM='{algorithm}'. This usually indicates "
"leftover asymmetric-key configuration; remove the public "
"key/JWKS settings, or switch back to an asymmetric "
"algorithm."
)
# For HMAC (symmetric), use the secret as the public_key parameter
# For HS256 (symmetric), use the secret as the public_key parameter
if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
common_kwargs["public_key"] = secret
common_kwargs["algorithm"] = algorithm
common_kwargs["algorithm"] = "HS256"
else:
# For RS256 (asymmetric), use public key or JWKS
if not (jwks_uri or public_key):
# Only a secret is configured but the algorithm is asymmetric: a
# keyless verifier cannot validate anything. Name the fix rather
# than letting the verifier constructor raise opaquely (it would
# still fail closed via the caller's fail-closed exception
# handling, but with a less actionable message).
raise MCPAuthConfigError(
"MCP_JWT_SECRET is set but MCP_JWT_ALGORITHM is not 'HS256' "
"and no MCP_JWKS_URI/MCP_JWT_PUBLIC_KEY is configured. Set "
"MCP_JWT_ALGORITHM='HS256' to use the secret, or configure "
"an asymmetric key."
)
common_kwargs["jwks_uri"] = jwks_uri
common_kwargs["public_key"] = public_key
common_kwargs["algorithm"] = algorithm
common_kwargs["algorithm"] = app.config.get("MCP_JWT_ALGORITHM", "RS256")
if debug_errors:
# DetailedJWTVerifier: detailed server-side logging of JWT
+6 -21
View File
@@ -218,30 +218,18 @@ _SENSITIVE_PARAM_KEYS = frozenset(
)
def _sanitize_value(value: Any) -> Any:
"""Apply ``_sanitize_params`` recursively to any dict/list container."""
if isinstance(value, dict):
return _sanitize_params(value)
if isinstance(value, list):
return [_sanitize_value(item) for item in value]
return value
def _sanitize_params(params: dict[str, Any]) -> dict[str, Any]:
"""Remove sensitive fields from params before logging.
Recurses into nested containers, including lists of lists, so sensitive
keys are redacted no matter which wrapper they arrive under
(``arguments``, ``request``, etc.).
"""
"""Remove sensitive fields from params before logging."""
if not isinstance(params, dict):
return params
result: dict[str, Any] = {}
for k, v in params.items():
if k.lower() in _SENSITIVE_PARAM_KEYS:
result[k] = "[REDACTED]"
elif k == "arguments" and isinstance(v, dict):
result[k] = _sanitize_params(v)
else:
result[k] = _sanitize_value(v)
result[k] = v
return result
@@ -1017,9 +1005,7 @@ class GlobalErrorHandlerMiddleware(Middleware):
) from error
elif isinstance(error, HTTPException):
# HTTP errors from screenshot endpoints or API calls
raise ToolError(
f"Service error in {tool_name}: {_sanitize_error_for_logging(error)}"
) from error
raise ToolError(f"Service error in {tool_name}: {error.detail}") from error
elif isinstance(error, MCPPermissionDeniedError):
# MCP RBAC permission denied — convert to structured ToolError.
# Must come before the generic PermissionError branch because
@@ -1034,8 +1020,7 @@ class GlobalErrorHandlerMiddleware(Middleware):
elif isinstance(error, ValueError):
# Value/parameter errors from tool code
raise ToolError(
f"Invalid parameter in {tool_name}: "
f"{_sanitize_error_for_logging(error)}"
f"Invalid parameter in {tool_name}: {str(error)}"
) from error
elif isinstance(error, (ObjectNotFoundError, CommandInvalidError)):
# Superset command: not found (404) or validation (422)
+8 -28
View File
@@ -808,19 +808,11 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
when either ``MCP_AUTH_ENABLED`` (JWT auth), ``MCP_API_KEY_ENABLED``, or
``FAB_API_KEY_ENABLED`` (API key auth) is True. The default factory builds a
``CompositeTokenVerifier`` that handles either or both auth modes.
Fail-closed: when auth has been explicitly configured, any error while
building the provider (or a configured factory yielding no provider)
raises ``MCPAuthConfigError`` so the service refuses to start rather
than coming up as an unauthenticated server.
"""
from superset.mcp_service.mcp_config import (
create_default_mcp_auth_factory,
MCPAuthConfigError,
)
auth_provider = None
if auth_factory := flask_app.config.get("MCP_AUTH_FACTORY"):
from superset.mcp_service.mcp_config import MCPAuthConfigError
try:
auth_provider = auth_factory(flask_app)
logger.info(
@@ -846,18 +838,17 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
"refusing to start the MCP service without authentication. "
"Fix the factory or unset MCP_AUTH_FACTORY."
) from None
if auth_provider is None:
raise MCPAuthConfigError(
"MCP_AUTH_FACTORY returned no auth provider; refusing to "
"start an unauthenticated MCP server. Return a token "
"verifier or unset MCP_AUTH_FACTORY."
)
elif (
flask_app.config.get("MCP_AUTH_ENABLED", False)
or flask_app.config.get("MCP_API_KEY_ENABLED", False)
or flask_app.config.get("FAB_API_KEY_ENABLED", False)
or flask_app.config.get("MCP_EMBEDDED_GUEST_AUTH_ENABLED", False)
):
from superset.mcp_service.mcp_config import (
create_default_mcp_auth_factory,
MCPAuthConfigError,
)
try:
auth_provider = create_default_mcp_auth_factory(flask_app)
logger.info(
@@ -871,19 +862,8 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
# no secret material.
raise
except Exception:
# Do not log or chain the exception — it may contain secrets.
# Auth was explicitly enabled, so a provider that cannot be built
# must also fail closed instead of starting unauthenticated.
# Do not log the exception — it may contain secrets
logger.error("Failed to create auth provider from default factory")
raise MCPAuthConfigError(
"Failed to build the MCP auth provider from the configured "
"auth settings; refusing to start an unauthenticated MCP "
"server. Check the MCP auth configuration."
) from None
# ``None`` here is deliberate only when the factory itself resolved
# every auth mode to disabled (e.g. MCP_API_KEY_ENABLED=False
# explicitly overriding FAB_API_KEY_ENABLED); misconfigurations of an
# enabled mode raise MCPAuthConfigError inside the factory instead.
return auth_provider
@@ -65,12 +65,7 @@ async def _validate_non_destructive_sql(
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
try:
sql_to_check: str = request.sql
# Render whenever template_params is not None, mirroring the
# executor (SQLExecutor._render_sql_template), which also renders
# for an empty dict. A truthiness check would let destructive SQL
# that only appears after rendering slip past the guard when
# template_params={}.
if request.template_params is not None:
if request.template_params:
from superset.jinja_context import get_template_processor
tp = get_template_processor(database=database)
+3 -25
View File
@@ -24,7 +24,6 @@ system-level info.
from __future__ import annotations
import re
from datetime import datetime
from typing import Annotated, Any, List
@@ -33,12 +32,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from superset.subjects.types import SubjectType
# Shape-only check, not RFC validation: just enough to catch "local@domain.tld"
# so an email-shaped query can be rejected before it reaches the username
# column, since usernames are frequently email addresses under OAuth
# provisioning.
_EMAIL_SHAPE_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class HealthCheckResponse(BaseModel):
"""Response model for health check.
@@ -183,7 +176,7 @@ def serialize_user_object(user: Any) -> UserInfo | None:
class FindUsersRequest(BaseModel):
"""Request schema for find_users tool.
Resolves a person's name (or partial name or username) to user IDs
Resolves a person's name (or partial name, username, or email) to user IDs
so they can be passed to listing tools as filter values for created_by_fk
or changed_by_fk. This is the only sanctioned path for "show me what
<person> is working on" queries.
@@ -198,9 +191,8 @@ class FindUsersRequest(BaseModel):
max_length=200,
description=(
"Substring to match (case-insensitive) against username, "
"first_name, and last_name (never email; email-shaped "
"queries are rejected). Required and non-empty: this tool "
"does not enumerate the full user directory."
"first_name, last_name, and email. Required and non-empty: "
"this tool does not enumerate the full user directory."
),
),
]
@@ -225,20 +217,6 @@ class FindUsersRequest(BaseModel):
raise ValueError("query must contain at least one non-whitespace character")
return stripped
@field_validator("query")
@classmethod
def _reject_email_shaped_query(cls, value: str) -> str:
# Email isn't a searchable column here, but usernames are commonly
# email addresses under OAuth provisioning, so an email-shaped query
# would still confirm an account's existence via the username column.
# Reject the shape outright rather than relying on the column
# exclusion alone.
if _EMAIL_SHAPE_RE.match(value):
raise ValueError(
"query must not be an email address; search by name or username instead"
)
return value
class UserMatch(BaseModel):
"""Minimal user projection returned by find_users.
@@ -50,14 +50,9 @@ async def find_users(request: FindUsersRequest, ctx: Context) -> FindUsersRespon
the value for a created_by_fk or changed_by_fk filter on list_dashboards,
list_charts, or list_datasets.
Matches case-insensitively against username, first_name, and last_name.
Email is deliberately not matched, and an email-shaped query is rejected
outright rather than falling through to the username column: since
usernames are frequently email addresses under OAuth provisioning, a
plain column exclusion would still let an email lookup confirm whether
an address has an account (and resolve it to a person), a directory
capability the web API reserves for admins. The query is required and
non-empty; this tool does not enumerate the full user directory.
Matches case-insensitively against username, first_name, last_name, and
email. The query is required and non-empty; this tool does not enumerate
the full user directory.
Privacy: returning a user's identity here is sanctioned only for resolving
filter values. Do not use the response to answer "who owns X", "who can
@@ -80,6 +75,7 @@ async def find_users(request: FindUsersRequest, ctx: Context) -> FindUsersRespon
user_model.username.ilike(needle, escape="\\"),
user_model.first_name.ilike(needle, escape="\\"),
user_model.last_name.ilike(needle, escape="\\"),
user_model.email.ilike(needle, escape="\\"),
)
)
.order_by(user_model.username.asc())
+8 -72
View File
@@ -179,21 +179,6 @@ SERIES_LIMIT_SUBQ_ALIAS = "series_limit"
# Offset join column suffix used for joining offset results
OFFSET_JOIN_COLUMN_SUFFIX = "__offset_join_column_"
def get_effective_hours_offset(
db_engine_spec: type["BaseEngineSpec"],
column_type: str | None,
offset_hours: int,
db_extra: dict[str, Any] | None = None,
) -> int:
"""Return the dataset offset representable by a temporal column's type."""
sqla_type = db_engine_spec.get_sqla_column_type(column_type, db_extra=db_extra)
if isinstance(sqla_type, sa.Date):
# int() deliberately truncates toward zero; // would turn -1h into -24h.
return int(offset_hours / 24) * 24
return offset_hours
# Right suffix used for joining offset results
R_SUFFIX = "__right_suffix"
@@ -1558,7 +1543,6 @@ class QueryResult: # pylint: disable=too-few-public-methods
errors: Optional[list[dict[str, Any]]] = None,
from_dttm: Optional[datetime] = None,
to_dttm: Optional[datetime] = None,
sql_shifted_temporal_labels: set[str] | None = None,
) -> None:
self.df = df
self.query = query
@@ -1571,7 +1555,6 @@ class QueryResult: # pylint: disable=too-few-public-methods
self.errors = errors or []
self.from_dttm = from_dttm
self.to_dttm = to_dttm
self.sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
self.sql_rowcount = len(self.df.index) if not self.df.empty else 0
@@ -1671,7 +1654,6 @@ class QueryStringExtended(NamedTuple):
labels_expected: list[str]
prequeries: list[str]
sql: str
sql_shifted_temporal_labels: set[str]
class SqlaQuery(NamedTuple):
@@ -1683,7 +1665,6 @@ class SqlaQuery(NamedTuple):
labels_expected: list[str]
prequeries: list[str]
sqla_query: Select
sql_shifted_temporal_labels: set[str]
class ExploreMixin: # pylint: disable=too-many-public-methods
@@ -2038,7 +2019,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
labels_expected=sqlaq.labels_expected,
prequeries=sqlaq.prequeries,
sql=sql,
sql_shifted_temporal_labels=sqlaq.sql_shifted_temporal_labels,
)
def _normalize_prequery_result_type(
@@ -2243,7 +2223,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
query=sql,
errors=errors,
error_message=error_message,
sql_shifted_temporal_labels=query_str_ext.sql_shifted_temporal_labels,
)
def exc_query(self, qry: Any) -> QueryResult:
@@ -2313,7 +2292,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
df: pd.DataFrame,
query_object: QueryObject,
already_collected: set[str],
sql_shifted_temporal_labels: set[str] | None = None,
) -> list[DateColumn]:
"""``DateColumn`` entries that only need the dataset HOURS OFFSET (and any
time shift) applied, for temporal columns the database already returns as
@@ -2333,7 +2311,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
):
return []
sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
extra: list[DateColumn] = []
for label in df.columns:
if label in already_collected or label == DTTM_ALIAS:
@@ -2352,9 +2329,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
extra.append(
DateColumn(
timestamp_format=None,
offset=(
0 if label in sql_shifted_temporal_labels else self.offset
),
offset=self.offset,
time_shift=query_object.time_shift,
col_label=label,
)
@@ -2362,22 +2337,15 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
already_collected.add(label)
return extra
def normalize_df(
self,
df: pd.DataFrame,
query_object: QueryObject,
sql_shifted_temporal_labels: set[str] | None = None,
) -> pd.DataFrame:
def normalize_df(self, df: pd.DataFrame, query_object: QueryObject) -> pd.DataFrame:
"""
Normalize the dataframe by converting datetime columns and ensuring
numerical metrics.
:param df: The dataframe to normalize
:param query_object: The query object with metadata about columns
:param sql_shifted_temporal_labels: labels already shifted in generated SQL
:return: Normalized dataframe
"""
sql_shifted_temporal_labels = sql_shifted_temporal_labels or set()
labels = self._collect_dttm_labels(query_object)
# ``get_dataset_timezone`` lives on ``ExploreMixin``; datasource doubles
@@ -2389,7 +2357,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
dttm_cols = [
DateColumn(
timestamp_format=fmt,
offset=0 if label in sql_shifted_temporal_labels else self.offset,
offset=self.offset,
time_shift=query_object.time_shift,
timezone=dataset_timezone,
col_label=label,
@@ -2401,9 +2369,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
dttm_cols.append(
DateColumn.get_legacy_time_column(
timestamp_format=self._python_date_format(query_object.granularity),
offset=(
0 if DTTM_ALIAS in sql_shifted_temporal_labels else self.offset
),
offset=self.offset,
time_shift=query_object.time_shift,
timezone=dataset_timezone,
)
@@ -2411,10 +2377,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
dttm_cols.extend(
self._offset_only_dttm_cols(
df,
query_object,
{col.col_label for col in dttm_cols},
sql_shifted_temporal_labels,
df, query_object, {col.col_label for col in dttm_cols}
)
)
@@ -2460,11 +2423,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
df = result.df
if not df.empty:
# Normalize datetime columns and metrics
df = self.normalize_df(
df,
query_object,
result.sql_shifted_temporal_labels,
)
df = self.normalize_df(df, query_object)
# Process time offsets if requested
if query_object.time_offsets:
@@ -2774,9 +2733,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
else:
# 1. normalize df, set dttm column
offset_metrics_df = self.normalize_df(
offset_metrics_df,
query_object_clone,
result.sql_shifted_temporal_labels,
offset_metrics_df, query_object_clone
)
# 2. rename extra query columns
@@ -3788,8 +3745,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
col: AdhocColumn,
force_type_check: bool = False,
template_processor: Optional[BaseTemplateProcessor] = None,
apply_dataset_offset: bool = False,
sql_shifted_temporal_labels: set[str] | None = None,
) -> tuple[ColumnElement, Optional[GenericDataType]]:
raise NotImplementedError()
@@ -3974,12 +3929,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
dataset_timezone = None
if not dataset_timezone and (offset_hours := getattr(self, "offset", 0) or 0):
offset_hours = get_effective_hours_offset(
self.db_engine_spec,
time_col.type,
offset_hours,
db_extra=self.db_extra,
)
if start_dttm is not None:
start_dttm = start_dttm - timedelta(hours=offset_hours)
if end_dttm is not None:
@@ -4349,7 +4298,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
template_kwargs["applied_filters"] = applied_template_filters
template_processor = self.get_template_processor(**template_kwargs)
prequeries: list[str] = []
sql_shifted_temporal_labels: set[str] = set()
orderby = orderby or []
need_groupby = bool(metrics is not None or groupby)
metrics = metrics or []
@@ -4458,8 +4406,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
col, _unused = self.adhoc_column_to_sqla(
col=adhoc_columns_by_label[col],
template_processor=template_processor,
apply_dataset_offset=True,
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
)
elif col in metrics_by_name:
col = metrics_by_name[col].get_sqla_col(
@@ -4499,8 +4445,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
time_grain=time_grain,
label=selected,
template_processor=template_processor,
apply_dataset_offset=True,
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
)
# if groupby field equals a selected column
elif selected in columns_by_name:
@@ -4522,8 +4466,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
outer, _unused = self.adhoc_column_to_sqla(
col=selected,
template_processor=template_processor,
apply_dataset_offset=True,
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
)
groupby_all_columns[outer.name] = outer
if (
@@ -4564,8 +4506,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
outer, _unused = self.adhoc_column_to_sqla(
col=selected,
template_processor=template_processor,
apply_dataset_offset=True,
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
)
select_exprs.append(outer)
continue
@@ -4601,10 +4541,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if is_timeseries:
timestamp = dttm_col.get_timestamp_expression(
time_grain=time_grain,
template_processor=template_processor,
apply_dataset_offset=True,
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
time_grain=time_grain, template_processor=template_processor
)
# always put timestamp as the first column
select_exprs.insert(0, timestamp)
@@ -5413,5 +5350,4 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
labels_expected=labels_expected,
sqla_query=qry,
prequeries=prequeries,
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
)
-2
View File
@@ -437,8 +437,6 @@ class Query(
col: "AdhocColumn", # type: ignore # noqa: F821
force_type_check: bool = False,
template_processor: Optional[BaseTemplateProcessor] = None,
apply_dataset_offset: bool = False,
sql_shifted_temporal_labels: set[str] | None = None,
) -> tuple[ColumnElement, Optional[GenericDataType]]:
"""
Turn an adhoc column into a sqlalchemy column.
-12
View File
@@ -5618,18 +5618,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
editor_subject_ids = set(get_extra_editor_subject_ids(resource))
if hasattr(resource, "editors"):
editor_subject_ids.update(s.id for s in resource.editors)
# Fallback ONLY for Query and SavedQuery models that use 'user_id'
from superset.models.sql_lab import Query, SavedQuery
from superset.subjects.utils import get_user_subject
if (
isinstance(resource, (Query, SavedQuery))
and getattr(resource, "user_id", None) is not None
):
if subject := get_user_subject(resource.user_id):
editor_subject_ids.add(subject.id)
return bool(subject_ids & editor_subject_ids)
def is_viewer(self, resource: Model) -> bool:
+10 -18
View File
@@ -13831,18 +13831,14 @@ msgstr ""
#, python-format
msgid ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there within %(days)s day."
msgid_plural ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there within %(days)s days."
msgstr[0] ""
msgstr[1] ""
"These %(type)s will be moved to Recently Archived. You can recover them "
"there within %(days)s days."
msgstr ""
#, python-format
msgid ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there."
"These %(type)s will be moved to Recently Archived. You can recover them "
"there."
msgstr ""
msgid "These are the datasets this filter will be applied to."
@@ -13850,18 +13846,14 @@ msgstr ""
#, python-format
msgid ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there within %(days)s day."
msgid_plural ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there within %(days)s days."
msgstr[0] ""
msgstr[1] ""
"This %(type)s will be moved to Recently Archived. You can recover it "
"there within %(days)s days."
msgstr ""
#, python-format
msgid ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there."
"This %(type)s will be moved to Recently Archived. You can recover it "
"there."
msgstr ""
msgid ""
@@ -379,37 +379,37 @@ msgstr "虛擬"
msgid "%s aggregates(s)"
msgstr "%s 聚合"
#, python-format
#, fuzzy, python-format
msgid "%s column"
msgid_plural "%s columns"
msgstr[0] "%s 個欄位"
msgstr[0] "%s "
#, python-format
msgid "%s column(s)"
msgstr "%s 個欄位"
msgstr "%s "
#, python-format
#, fuzzy, python-format
msgid "%s day ago"
msgid_plural "%s days ago"
msgstr[0] "%s 天前"
msgstr[0] "1前"
#, python-format
#, fuzzy, python-format
msgid "%s hr ago"
msgid_plural "%s hr ago"
msgstr[0] "%s 小時前"
msgstr[0] "%s "
#, python-format
#, fuzzy, python-format
msgid "%s imported"
msgstr "已匯入 %s"
msgstr "數據集已導入"
#, python-format
#, fuzzy, python-format
msgid "%s item"
msgid_plural "%s items"
msgstr[0] "%s 個項"
msgstr[0] "%s 個項"
#, python-format
#, fuzzy, python-format
msgid "%s item(s)"
msgstr "%s 個項"
msgstr "%s 個項"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, cs, de,
# es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, sr, sr_Latn, tr, uk]
@@ -419,15 +419,15 @@ msgid ""
"all selected objects."
msgstr "%s 個項目無法標記,因為您對所有選取的物件沒有編輯權限。"
#, python-format
#, fuzzy, python-format
msgid "%s metric"
msgid_plural "%s metrics"
msgstr[0] "%s 個指標"
msgstr[0] "排序指標"
#, python-format
#, fuzzy, python-format
msgid "%s min ago"
msgid_plural "%s min ago"
msgstr[0] "%s 分鐘前"
msgstr[0] ""
#, python-format
msgid ""
@@ -451,47 +451,47 @@ msgstr[0] "%s 個選項"
msgid "%s option(s)"
msgstr "%s 個選項"
#, python-format
#, fuzzy, python-format
msgid "%s out of %s column"
msgid_plural "%s out of %s columns"
msgstr[0] "已選取 %s%s 個欄位"
msgstr[0] "自定義列"
#, python-format
#, fuzzy, python-format
msgid "%s out of %s metric"
msgid_plural "%s out of %s metrics"
msgstr[0] "已選取 %s%s 個指標"
msgstr[0] "排序指標"
#, python-format
#, fuzzy, python-format
msgid "%s out of %s selected"
msgstr "已選取 %s%s 個項目"
msgstr "%s 已選定"
#, python-format
#, fuzzy, python-format
msgid "%s recipients"
msgstr "%s 收件者"
msgstr "%s 最近"
#, python-format
#, fuzzy, python-format
msgid "%s record..."
msgid_plural "%s records..."
msgstr[0] "%s 筆記錄..."
msgstr[0] "%s 異常"
#, python-format
#, fuzzy, python-format
msgid "%s row"
msgid_plural "%s rows"
msgstr[0] "%s "
msgstr[0] "%s "
#, python-format
#, fuzzy, python-format
msgid "%s s ago"
msgid_plural "%s s ago"
msgstr[0] "%s 秒前"
msgstr[0] "30 天之前"
#, python-format
msgid "%s saved metric(s)"
msgstr "%s 保存的指標"
#, python-format
#, fuzzy, python-format
msgid "%s second"
msgid_plural "%s seconds"
msgstr[0] "%s 秒"
msgstr[0] "5 秒"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, es, sr,
# sr_Latn]
@@ -511,13 +511,13 @@ msgstr "%s 個語意檢視新增失敗"
msgid "%s semantic view(s) failed to add: %s"
msgstr "%s 個語意檢視新增失敗:%s"
#, python-format
#, fuzzy, python-format
msgid "%s tab selected"
msgstr "已選取「%s」分頁"
msgstr "%s 已選定"
#, python-format
#, fuzzy, python-format
msgid "%s updated"
msgstr "更新 %s"
msgstr "上次更新 %s"
#, python-format
msgid "%s%s"
@@ -677,11 +677,13 @@ msgstr "每年年初的頻率"
msgid "10 minute"
msgstr "10 分鐘"
#, fuzzy
msgid "10 seconds"
msgstr "10 秒"
msgstr "30 秒"
#, fuzzy
msgid "10/90 percentiles"
msgstr "10/90 百分位"
msgstr "9/91 百分位"
#. do-not-translate
msgid "10000"
@@ -694,8 +696,9 @@ msgstr "週"
msgid "104 weeks ago"
msgstr "104 週之前"
#, fuzzy
msgid "12 hours"
msgstr "12 小時"
msgstr "1 小時"
msgid "15 minute"
msgstr "15 分鐘"
@@ -758,8 +761,9 @@ msgstr "2/98 百分位"
msgid "22"
msgstr "22"
#, fuzzy
msgid "24 hours"
msgstr "24 小時"
msgstr "6 小時"
#, fuzzy
msgid "28 days"
@@ -827,8 +831,9 @@ msgstr "5 秒"
msgid "5 seconds"
msgstr "5 秒"
#, fuzzy
msgid "5/95 percentiles"
msgstr "5/95 百分位"
msgstr "9/91 百分位"
#, fuzzy
msgid "52 weeks"
@@ -0,0 +1,80 @@
# 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.
"""Compatibility shim letting pandas >= 2.2 use SQLAlchemy 1.4 engines.
pandas 2.2 raised its advertised minimum SQLAlchemy version to 2.0 as a
support-policy change. When an older SQLAlchemy is installed, pandas does not
fail loudly: ``pandas.io.sql`` silently pretends SQLAlchemy is absent, treats
Engine/Connection arguments as raw DBAPI connections, and falls back to its
sqlite-only code path, breaking every ``DataFrame.to_sql`` / ``read_sql``
call site (dataset uploads, example data loading, annotation queries, filter
values).
The pandas SQL layer itself still works with SQLAlchemy 1.4 because it only
uses the API subset common to SQLAlchemy 1.4 and 2.x. Lowering the advertised
minimum back to the pandas 2.1 value restores the working behavior.
This module is obsolete once Superset requires SQLAlchemy >= 2; at that point
the patch becomes a no-op and the module (and its call site in
``superset/__init__.py``) can be deleted.
"""
import logging
import sqlalchemy
from packaging.version import Version
logger = logging.getLogger(__name__)
# The last pandas release line to support SQLAlchemy 1.4 (pandas 2.1)
# required at least this version.
_SQLALCHEMY_MINIMUM = "1.4.16"
def restore_pandas_sqlalchemy_support() -> None:
"""Lower pandas' advertised SQLAlchemy minimum so 1.4 engines work.
Only applies when the installed SQLAlchemy predates 2.0 and pandas
advertises a 2.x minimum; in every other combination this is a no-op.
Safe to call multiple times.
"""
if Version(sqlalchemy.__version__) >= Version("2.0.0"):
# pandas supports SQLAlchemy 2.x natively; nothing to patch.
return
try:
from pandas.compat import _optional
except ImportError:
# The private module moved in a newer pandas; SQL IO with a pre-2.0
# SQLAlchemy will misbehave, so make the situation diagnosable.
logger.warning(
"Could not adjust pandas' minimum SQLAlchemy version; "
"DataFrame.to_sql/read_sql may not accept SQLAlchemy %s engines",
sqlalchemy.__version__,
)
return
advertised = _optional.VERSIONS.get("sqlalchemy")
if advertised and Version(advertised) > Version(_SQLALCHEMY_MINIMUM):
_optional.VERSIONS["sqlalchemy"] = _SQLALCHEMY_MINIMUM
logger.debug(
"Lowered pandas' minimum SQLAlchemy version from %s to %s so "
"pandas SQL IO keeps working with the installed SQLAlchemy %s",
advertised,
_SQLALCHEMY_MINIMUM,
sqlalchemy.__version__,
)
@@ -605,6 +605,9 @@ class TestSavedQueryApi(SupersetTestCase):
db.session.query(SavedQuery).filter(SavedQuery.label == "label1").all()[0]
)
self.login(ADMIN_USERNAME)
# Freeze relative to the persisted timestamp so database-specific
# timestamp precision cannot make the humanized value age into the
# next bucket while the request is being handled.
with freeze_time(saved_query.changed_on):
uri = f"api/v1/saved_query/{saved_query.id}"
rv = self.get_assert_metric(uri, "get")
@@ -1,217 +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.
# pylint: disable=import-outside-toplevel
from __future__ import annotations
from contextlib import contextmanager
from typing import Any, TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import create_engine
from sqlalchemy.orm.session import Session
from sqlalchemy.pool import StaticPool
if TYPE_CHECKING:
from superset.models.core import Database
# A Custom SQL ad-hoc metric exactly as Explore serializes it into a
# ``/api/v1/chart/data`` payload. Its auto-derived ``label`` is the SQL text
# itself, which is what makes the downstream failure mode so confusing.
CUSTOM_SQL_METRIC: dict[str, Any] = {
"expressionType": "SQL",
"sqlExpression": "count(DISTINCT product_line)",
"label": "count(DISTINCT product_line)",
"hasCustomLabel": False,
"optionName": "metric_abc123",
}
# The same metric with ``expressionType`` absent. Every other key still marks it
# unambiguously as an ad-hoc definition rather than a reference to a metric
# saved on the dataset.
MALFORMED_ADHOC_METRIC: dict[str, Any] = {
key: value for key, value in CUSTOM_SQL_METRIC.items() if key != "expressionType"
}
def _chart_data_payload(metric: Any) -> dict[str, Any]:
return {
"datasource": {"id": 1, "type": "table"},
"queries": [
{
"columns": ["source", "target"],
"metrics": [metric],
"row_limit": 100,
}
],
"result_format": "json",
"result_type": "full",
}
def _load_metrics(payload: dict[str, Any]) -> Any:
"""Deserialize a chart data payload the way ``/api/v1/chart/data`` does."""
from superset.charts.schemas import ChartDataQueryContextSchema
with patch(
"superset.common.query_context_factory.DatasourceDAO.get_datasource",
return_value=MagicMock(),
):
query_context = ChartDataQueryContextSchema().load(payload)
return query_context.queries[0].metrics
@pytest.fixture
def database(mocker: MockerFixture, session: Session) -> Database:
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(session.get_bind())
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
connection = engine.raw_connection()
connection.execute("CREATE TABLE t (product_line TEXT, source TEXT, target TEXT)")
connection.commit()
# since we're using an in-memory SQLite database, make sure we always
# return the same engine where the table was created
@contextmanager
def mock_get_sqla_engine(catalog=None, schema=None, **kwargs):
yield engine
mocker.patch.object(database, "get_sqla_engine", new=mock_get_sqla_engine)
return database
def _table(database: Database) -> Any:
from superset.connectors.sqla.models import SqlaTable, TableColumn
return SqlaTable(
database=database,
schema=None,
table_name="t",
columns=[
TableColumn(column_name="product_line"),
TableColumn(column_name="source"),
TableColumn(column_name="target"),
],
)
def test_adhoc_metric_without_expression_type_is_not_read_as_a_saved_metric(
app_context: Any,
) -> None:
"""
An ad-hoc metric that is missing ``expressionType`` must not be silently
reinterpreted as a reference to a metric saved on the dataset.
``QueryObject._set_metrics`` used to rewrite any metric ``dict`` that was
not recognized as ad-hoc down to its ``label``, to support the legacy
``{"label": "saved_metric_name"}`` reference format. ``is_adhoc_metric``
recognizes a metric solely by the presence of ``expressionType``, so an
ad-hoc definition that lacks that one key used to be collapsed into a bare
string. For a Custom SQL metric the label is the SQL text, so the request
was then resolved as if the user had asked for a saved metric literally
named ``count(DISTINCT product_line)``.
``ChartDataAdhocMetricSchema`` declares ``expressionType`` as required, but
``ChartDataQueryObjectSchema.metrics`` is a list of ``fields.Raw``, so that
contract is never enforced at the API boundary. ``_set_metrics`` is the
last point that can tell an ad-hoc-shaped dict apart from a legacy
reference, so it must reject the malformed shape outright rather than
guess.
"""
from superset.exceptions import QueryObjectValidationError
with pytest.raises(
QueryObjectValidationError,
match=r"Invalid ad-hoc metric count\(DISTINCT product_line\): "
r"`expressionType` is missing",
):
_load_metrics(_chart_data_payload(MALFORMED_ADHOC_METRIC))
def test_malformed_adhoc_metric_surfaces_as_a_missing_saved_metric(
database: Database,
) -> None:
"""
Downstream symptom of the coercion above: once the ad-hoc definition has
been reduced to its label, metric resolution looks the label up among the
dataset's saved metrics, fails, and reports the SQL text as a metric name.
"""
from superset.exceptions import QueryObjectValidationError
with pytest.raises(
QueryObjectValidationError,
match=r"Metric 'count\(DISTINCT product_line\)' does not exist",
):
_table(database).get_sqla_query(
columns=["source", "target"],
metrics=["count(DISTINCT product_line)"],
extras={},
filter=[],
granularity=None,
is_timeseries=False,
)
def test_legacy_label_only_metric_still_resolves_to_a_saved_metric_name(
app_context: Any,
) -> None:
"""
Guards the fix from over-correcting: a ``dict`` carrying only ``label`` is
the documented legacy way to reference a metric saved on the dataset, and
must keep collapsing to that name.
"""
metrics = _load_metrics(_chart_data_payload({"label": "sum__num"}))
assert metrics == ["sum__num"]
def test_well_formed_custom_sql_metric_is_preserved(
app_context: Any,
database: Database,
) -> None:
"""
Guards the fix from over-correcting: with ``expressionType`` present the
metric stays an ad-hoc definition and builds SQL without consulting the
dataset's saved metrics.
"""
metrics = _load_metrics(_chart_data_payload(CUSTOM_SQL_METRIC))
assert metrics == [CUSTOM_SQL_METRIC]
assert (
_table(database).get_sqla_query(
columns=["source", "target"],
metrics=metrics,
extras={},
filter=[],
granularity=None,
is_timeseries=False,
)
is not None
)
@@ -1162,7 +1162,7 @@ def test_processing_time_offsets_quarter_offset_shifts_query_window(
datasource.query = fake_query
datasource.normalize_df = MagicMock(
side_effect=lambda offset_df, _query_object, _labels=None: offset_df
side_effect=lambda offset_df, _query_object: offset_df
)
with (
@@ -1256,7 +1256,7 @@ def test_processing_time_offsets_accepts_zero_shift_offset(
datasource.query = fake_query
datasource.normalize_df = MagicMock(
side_effect=lambda offset_df, _query_object, _labels=None: offset_df
side_effect=lambda offset_df, _query_object: offset_df
)
with (
@@ -2364,7 +2364,7 @@ def test_relative_offset_preserves_inner_bounds(
datasource.query = fake_query
datasource.normalize_df = MagicMock(
side_effect=lambda offset_df, _query_object, _labels=None: offset_df
side_effect=lambda offset_df, _query_object: offset_df
)
with (
@@ -715,33 +715,12 @@ def _patch_bq_fetch_deps(
mocker: MockerFixture, max_mb: int = 200
) -> tuple[mock.MagicMock, mock.MagicMock]:
"""Helper to patch Flask g and current_app for BigQuery fetch_data tests."""
# `new_callable=mock.MagicMock` is pinned explicitly rather than relying on
# ``mocker.patch``'s auto-detection of the mock class. That detection
# inspects whatever object currently sits at the patched attribute, so if
# an earlier test in the same session ever leaves an ``AsyncMock`` there
# (e.g. an improperly torn-down patch), every subsequent patch of the same
# attribute -- even ones created fresh here -- would also become an
# ``AsyncMock``, since ``AsyncMock`` classifies its own non-dunder child
# attributes as ``AsyncMock`` too. Pinning the callable sidesteps that
# self-perpetuating class inference entirely.
flask_g = mocker.patch(
"superset.db_engine_specs.bigquery.g", new_callable=mock.MagicMock
)
app = mocker.patch(
"superset.db_engine_specs.bigquery.current_app", new_callable=mock.MagicMock
)
flask_g = mocker.patch("superset.db_engine_specs.bigquery.g")
app = mocker.patch("superset.db_engine_specs.bigquery.current_app")
# Make current_app truthy and .config.get() return a plain int
app.__bool__ = mock.Mock(return_value=True)
app.config = mock.MagicMock()
app.config.get = mock.Mock(return_value=max_mb)
# ``fetch_data`` only records ``g.bq_memory_limited*`` when
# ``has_request_context()`` is true. Outside of a real Flask request
# (as in these unit tests) that's always false, so without patching it
# the assignments never happen and the mocked ``g`` attributes stay
# unset child mocks instead of the expected booleans/counts.
mocker.patch(
"superset.db_engine_specs.bigquery.has_request_context", return_value=True
)
return flask_g, app
@@ -17,9 +17,6 @@
# pylint: disable=import-outside-toplevel, invalid-name, line-too-long
from __future__ import annotations
from datetime import datetime
from typing import Any, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
@@ -36,8 +33,6 @@ from superset.sql.parse import Table
from superset.superset_typing import OAuth2ClientConfig
from superset.utils import json
from superset.utils.oauth2 import decode_oauth2_state
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
from tests.unit_tests.fixtures.common import dttm # noqa: F401
if TYPE_CHECKING:
from superset.db_engine_specs.base import OAuth2State
@@ -1073,33 +1068,3 @@ def test_validate_parameters_skips_oauth2_connections_with_masked_encrypted_extr
assert errors == []
conn.execute.assert_not_called()
@pytest.mark.parametrize(
"target_type,expected_result",
[
("Date", "'2019-01-02'"),
("DateTime", "'2019-01-02 03:04:05'"),
("UnknownType", None),
],
)
def test_convert_dttm(
target_type: str,
expected_result: str | None,
dttm: datetime, # noqa: F811
) -> None:
"""
A Date-typed column must produce a plain ISO date literal ('YYYY-MM-DD').
Without this, ``SqliteEngineSpec.convert_dttm`` (inherited via
``ShillelaghEngineSpec``) returns ``None`` for ``types.Date``, and Superset falls
back to a full ``'YYYY-MM-DD HH:MM:SS.ffffff'`` literal. shillelagh's virtual
table layer parses that bound value with ``datetime.date.fromisoformat``, which
rejects the trailing time-of-day and silently coerces the constraint to ``None``,
which the GSheets adapter renders as the SQL literal ``null`` -- an unquoted
bareword that Google's Chart API parses as a missing column reference, raising
"Invalid query: NO_COLUMN: null".
"""
from superset.db_engine_specs.gsheets import GSheetsEngineSpec
assert_convert_dttm(GSheetsEngineSpec, target_type, expected_result, dttm)
+11 -84
View File
@@ -18,7 +18,7 @@
import builtins
from datetime import datetime
from decimal import Decimal
from types import SimpleNamespace
from types import ModuleType
from typing import Any, Optional
from unittest.mock import Mock, patch
@@ -73,16 +73,6 @@ from tests.unit_tests.fixtures.common import dttm # noqa: F401
("DATETIME", types.DateTime, None, GenericDataType.TEMPORAL, True),
("TIMESTAMP", types.TIMESTAMP, None, GenericDataType.TEMPORAL, True),
("TIME", types.Time, None, GenericDataType.TEMPORAL, True),
# Wire-protocol names
("VAR_STRING", types.VARCHAR, None, GenericDataType.STRING, False),
("NEWDECIMAL", DECIMAL, None, GenericDataType.NUMERIC, False),
("TINY", TINYINT, None, GenericDataType.NUMERIC, False),
("SHORT", types.SmallInteger, None, GenericDataType.NUMERIC, False),
("BLOB", types.String, None, GenericDataType.STRING, False),
("TEXT", types.String, None, GenericDataType.STRING, False),
("YEAR", types.Integer, None, GenericDataType.NUMERIC, False),
("ENUM", types.String, None, GenericDataType.STRING, False),
("SET", types.String, None, GenericDataType.STRING, False),
],
)
def test_get_column_spec(
@@ -97,50 +87,6 @@ def test_get_column_spec(
assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm)
def test_fetch_data_mutates_decimal_rows_in_tuple_results() -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec as spec # noqa: N813
newdecimal, var_string = 246, 253
cursor = Mock()
cursor.description = [("amount", newdecimal), ("label", var_string)]
cursor.fetchall.return_value = (("10.50", "Ships"), ("22.30", "Planes"))
# Stub the type_code_map so this test doesn't depend on MySQLdb or
# pymysql being importable in the test environment.
original_type_code_map = spec.type_code_map
spec.type_code_map = {newdecimal: "NEWDECIMAL", var_string: "VAR_STRING"}
try:
data = spec.fetch_data(cursor)
finally:
spec.type_code_map = original_type_code_map
assert data == [(Decimal("10.50"), "Ships"), (Decimal("22.30"), "Planes")]
def test_fetch_data_mutates_duplicate_decimal_column_names() -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec as spec # noqa: N813
newdecimal, var_string = 246, 253
cursor = Mock()
cursor.description = [
("amount", newdecimal),
("amount", var_string),
("amount", newdecimal),
]
cursor.fetchall.return_value = [("10.50", "not a decimal", "22.30")]
original_type_code_map = spec.type_code_map
spec.type_code_map = {newdecimal: "NEWDECIMAL", var_string: "VAR_STRING"}
try:
data = spec.fetch_data(cursor)
finally:
spec.type_code_map = original_type_code_map
assert data == [(Decimal("10.50"), "not a decimal", Decimal("22.30"))]
@pytest.mark.parametrize(
"target_type,expected_result",
[
@@ -323,7 +269,7 @@ def test_column_type_mutator(
assert spec.fetch_data(mock_cursor) == expected_result
def test_get_datatype_pymysql_fallback() -> None:
def test_get_datatype_pymysql_fallback():
"""get_datatype() falls back to pymysql when MySQLdb is not installed."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
@@ -333,9 +279,15 @@ def test_get_datatype_pymysql_fallback() -> None:
try:
# Build a fake pymysql module with constants.FIELD_TYPE
fake_field_type = SimpleNamespace(TINY=1, VARCHAR=15)
fake_constants = SimpleNamespace(FIELD_TYPE=fake_field_type)
fake_pymysql = SimpleNamespace(constants=fake_constants)
fake_field_type = ModuleType("pymysql.constants.FIELD_TYPE")
fake_field_type.TINY = 1
fake_field_type.VARCHAR = 15
fake_constants = ModuleType("pymysql.constants")
fake_constants.FIELD_TYPE = fake_field_type
fake_pymysql = ModuleType("pymysql")
fake_pymysql.constants = fake_constants
original_import = builtins.__import__
@@ -356,31 +308,6 @@ def test_get_datatype_pymysql_fallback() -> None:
MySQLEngineSpec.type_code_map = original_type_code_map
def test_get_datatype_mysqlconnector_fallback() -> None:
"""get_datatype() supports mysql-connector-python without PyMySQL."""
from superset.db_engine_specs.mysql import MySQLEngineSpec
original_type_code_map = MySQLEngineSpec.type_code_map
MySQLEngineSpec.type_code_map = {}
try:
fake_field_type = SimpleNamespace(NEWDECIMAL=246)
fake_constants = SimpleNamespace(FieldType=fake_field_type)
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name in {"MySQLdb", "pymysql"}:
raise ImportError(f"No module named '{name}'")
if name == "mysql.connector.constants":
return fake_constants
return original_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=mock_import):
assert MySQLEngineSpec.get_datatype(246) == "NEWDECIMAL"
finally:
MySQLEngineSpec.type_code_map = original_type_code_map
@pytest.mark.parametrize(
("grain", "expected_expression"),
[

Some files were not shown because too many files have changed in this diff Show More