mirror of
https://github.com/apache/superset.git
synced 2026-08-25 09:31:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b43a955f9 | ||
|
|
68e369797c | ||
|
|
bd7b739212 | ||
|
|
f83fb7c0e0 | ||
|
|
a9d54a0037 | ||
|
|
8b792ab660 | ||
|
|
107204a1fc | ||
|
|
7e3d092ac4 | ||
|
|
dfd057b920 | ||
|
|
52571a5b8b | ||
|
|
ca3d7670b7 | ||
|
|
94855e9626 | ||
|
|
7dbf71a379 | ||
|
|
24b95f9ca7 | ||
|
|
c635d0754f | ||
|
|
31f06c0ee6 | ||
|
|
27ec80c07b | ||
|
|
a8a8b51afb | ||
|
|
6ab21b381a | ||
|
|
b0962ba5ed | ||
|
|
bf5f3a9e6b | ||
|
|
c980b3a361 | ||
|
|
dc436c76f7 | ||
|
|
6eced8e919 | ||
|
|
1bde62f997 | ||
|
|
f1f6347885 | ||
|
|
8591f52ced | ||
|
|
ceb75b9350 | ||
|
|
db539288ac | ||
|
|
a392e8b102 | ||
|
|
e450acf1c7 | ||
|
|
2c02965f2b | ||
|
|
f3142e7b15 | ||
|
|
649c062825 |
@@ -105,6 +105,7 @@ 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
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ 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.
|
||||
|
||||
@@ -782,13 +782,18 @@ 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"}
|
||||
```
|
||||
|
||||
Mutating tools (`generate_chart`, `update_chart`, `execute_sql`, `generate_dashboard`) are always excluded from caching regardless of this setting.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ 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** — from the Type selector. The view shows one type at a time; each
|
||||
**Dataset** (shown as **Datasource** when semantic layers are enabled) — 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.
|
||||
|
||||
|
||||
+2
-2
@@ -60,9 +60,9 @@
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/core": "^1.16.0",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"baseline-browser-mapping": "^2.11.15",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
|
||||
+73
-73
@@ -4855,86 +4855,86 @@
|
||||
dependencies:
|
||||
apg-lite "^1.0.4"
|
||||
|
||||
"@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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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-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@^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==
|
||||
"@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==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
"@swc/types" "^0.1.27"
|
||||
"@swc/types" "^0.1.28"
|
||||
optionalDependencies:
|
||||
"@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/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/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.27":
|
||||
version "0.1.27"
|
||||
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.27.tgz#12080b0c426dea450634f202d9a3c82ac396e793"
|
||||
integrity sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==
|
||||
"@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==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
|
||||
@@ -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.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==
|
||||
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==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
|
||||
+8
-8
@@ -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>=3.0, <5",
|
||||
"marshmallow>=4.3.1, <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>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"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
|
||||
"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.0, <2.1",
|
||||
"sqlalchemy>=2.0.52, <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.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
"sqlglot>=30.17.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.6.0, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.7.1, <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.7.0"]
|
||||
gevent = ["gevent>=26.8.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.3.0, <2"]
|
||||
prophet = ["prophet>=1.4.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.64"]
|
||||
teradata = ["teradatasql>=20.0.0.65"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
|
||||
@@ -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>=24.0.0,<26.0.0
|
||||
pyarrow>=25.0.1,<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
|
||||
|
||||
@@ -222,7 +222,7 @@ markupsafe==3.0.2
|
||||
# mako
|
||||
# werkzeug
|
||||
# wtforms
|
||||
marshmallow==4.3.0
|
||||
marshmallow==4.3.1
|
||||
# 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.0
|
||||
pyarrow==25.0.1
|
||||
# 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.51
|
||||
sqlalchemy==2.0.52
|
||||
# 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.16.0
|
||||
sqlglot==30.17.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -337,7 +337,7 @@ geopy==2.4.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
gevent==26.7.0
|
||||
gevent==26.8.0
|
||||
# via apache-superset
|
||||
google-api-core==2.33.0
|
||||
# via
|
||||
@@ -434,8 +434,6 @@ 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
|
||||
@@ -530,7 +528,7 @@ markupsafe==3.0.2
|
||||
# mako
|
||||
# werkzeug
|
||||
# wtforms
|
||||
marshmallow==4.3.0
|
||||
marshmallow==4.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -693,7 +691,7 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# click-repl
|
||||
prophet==1.3.0
|
||||
prophet==1.4.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via google-api-core
|
||||
@@ -711,7 +709,7 @@ psycopg2-binary==2.9.12
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.4.4
|
||||
# via fastmcp-slim
|
||||
pyarrow==25.0.0
|
||||
pyarrow==25.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -950,7 +948,7 @@ slack-sdk==3.43.0
|
||||
# apache-superset
|
||||
sniffio==1.3.1
|
||||
# via anyio
|
||||
sqlalchemy==2.0.51
|
||||
sqlalchemy==2.0.52
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -976,7 +974,7 @@ sqlalchemy-utils==0.42.1
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# flask-appbuilder
|
||||
sqlglot==30.16.0
|
||||
sqlglot==30.17.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -42,6 +42,7 @@ 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/",
|
||||
|
||||
Generated
+96
-78
@@ -45,9 +45,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -185,9 +185,9 @@
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@swc/core": "^1.16.0",
|
||||
"@swc/plugin-emotion": "^15.0.0",
|
||||
"@swc/plugin-transform-imports": "^13.0.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
@@ -10340,9 +10340,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rjsf/core": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.1.tgz",
|
||||
"integrity": "sha512-/CQfIGUzcXceBNRhEH3wsTvxcT8dMrjPLXhYSfcJUTftKxOCdisqi2wFwt7LOyIvFvzHUxag0r0xXs/MXS9jmA==",
|
||||
"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==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1",
|
||||
@@ -10354,19 +10354,19 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.7.1",
|
||||
"@rjsf/utils": "^6.8.0",
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/utils": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.7.1.tgz",
|
||||
"integrity": "sha512-6goBapMwyHcXvjLkCnFs4S3P1oKUi1H083BdPk4pDZALFWn5ZdG50ECNfHSddBmL3O0pyx1/WFq1C/MuR7Y54A==",
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.8.0.tgz",
|
||||
"integrity": "sha512-gHcqPFSHdOz29tZiLlzDvD+Gfq21zVIFBufprSYTiHpUdcVNJEK6+V5aw++FtQncHdDWnTf2YCky/e1Bol2NEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.3",
|
||||
"fast-equals": "^6.0.0",
|
||||
"fast-uri": "^4.1.1",
|
||||
"fast-uri": "^4.1.2",
|
||||
"jsonpointer": "^5.0.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
@@ -10380,9 +10380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.1.tgz",
|
||||
"integrity": "sha512-oG9reR8VgUUTxfsO8WybZWTjKs6SLUdhmUCp55SXmJvwVbeKZ+Mz4SI+y+T1Mdpbm1kLZWQPRyF2Md97soWXkw==",
|
||||
"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==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0",
|
||||
@@ -10394,7 +10394,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rjsf/utils": "^6.7.1"
|
||||
"@rjsf/utils": "^6.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
@@ -11550,15 +11550,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.15.47",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
|
||||
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.0.tgz",
|
||||
"integrity": "sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.27"
|
||||
"@swc/types": "^0.1.28"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -11568,18 +11568,18 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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/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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
@@ -11591,9 +11591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11607,9 +11607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11623,9 +11623,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -11639,12 +11639,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11655,12 +11658,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11671,12 +11677,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11687,12 +11696,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11703,12 +11715,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11719,12 +11734,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11735,9 +11753,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -11751,9 +11769,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -11767,9 +11785,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -11808,9 +11826,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
|
||||
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-15.0.0.tgz",
|
||||
"integrity": "sha512-B0L0KuItii5XatOskjeFW4kNPXYEDo5JYm+k5Lze3LEY46q4L7foVkXiUFbNn0GjbKJCOv+nU2nM57k4LYLbHw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -11818,9 +11836,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-transform-imports": {
|
||||
"version": "12.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-12.5.0.tgz",
|
||||
"integrity": "sha512-b9ReG4NY9OwIIqXLlTuOb7k4N2yRBl501iNiBEKaiTazpxXxg6nR2XKOPojlyu1yb5YnK3s3EjTZX3DGSIDKNg==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-13.0.0.tgz",
|
||||
"integrity": "sha512-G8Wp8zX92O5F2YQ8OSqoAbNqPiU7VTLKFBtmN4W0y29SaNUDi8rLwvos5P5J1qdrQP3BmnQnS1wdZioMZXlJmw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -11828,9 +11846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.27",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
|
||||
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
|
||||
"version": "0.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
|
||||
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -122,9 +122,9 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^6.7.1",
|
||||
"@rjsf/core": "^6.8.0",
|
||||
"@rjsf/utils": "^6.6.2",
|
||||
"@rjsf/validator-ajv8": "^6.7.1",
|
||||
"@rjsf/validator-ajv8": "^6.8.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -262,9 +262,9 @@
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@swc/core": "^1.16.0",
|
||||
"@swc/plugin-emotion": "^15.0.0",
|
||||
"@swc/plugin-transform-imports": "^13.0.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
|
||||
@@ -122,6 +122,12 @@ 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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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 { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@superset-ui/core/spec';
|
||||
import { Input } from '../Input';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
const drag = (
|
||||
target: Element,
|
||||
from: [number, number],
|
||||
to: [number, number],
|
||||
) => {
|
||||
fireEvent.mouseDown(target, { clientX: from[0], clientY: from[1] });
|
||||
fireEvent.mouseMove(document, { clientX: to[0], clientY: to[1] });
|
||||
fireEvent.mouseUp(document);
|
||||
};
|
||||
|
||||
const isDragged = () => !!document.querySelector('.react-draggable-dragged');
|
||||
|
||||
describe('Modal draggable', () => {
|
||||
test('dragging from the title bar moves the modal', () => {
|
||||
render(
|
||||
<Modal show onHide={() => {}} title="Edit Dataset" draggable name="test">
|
||||
<Input data-test="field" defaultValue="value" />
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
const trigger = document.querySelector('.draggable-trigger') as HTMLElement;
|
||||
drag(trigger, [100, 50], [150, 90]);
|
||||
|
||||
expect(isDragged()).toBe(true);
|
||||
});
|
||||
|
||||
test('dragging inside modal content does not move the modal', () => {
|
||||
render(
|
||||
<Modal show onHide={() => {}} title="Edit Dataset" draggable name="test">
|
||||
<Input data-test="field" defaultValue="first_view_event" />
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('field');
|
||||
drag(input, [200, 400], [260, 430]);
|
||||
|
||||
expect(isDragged()).toBe(false);
|
||||
});
|
||||
|
||||
test('dragging inside modal content does not move the modal, even after an unrelated re-render while the title was hovered', () => {
|
||||
// Regression test: the title bar used to gate dragging with a
|
||||
// hover-tracked boolean (mouseover/mouseout on `.draggable-trigger`)
|
||||
// instead of react-draggable's own `handle` prop. Because the title
|
||||
// element was defined as an inline component recreated on every
|
||||
// render, any unrelated state change while the cursor was over the
|
||||
// title (e.g. typing in any field) force-remounted it without a real
|
||||
// mouseout ever firing, leaving dragging permanently enabled -- so
|
||||
// selecting text anywhere in the modal dragged the whole modal
|
||||
// instead.
|
||||
function Harness() {
|
||||
const [tick, setTick] = useState(0);
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
onHide={() => {}}
|
||||
title="Edit Dataset"
|
||||
draggable
|
||||
name="test"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-test="rerender"
|
||||
onClick={() => setTick(tick + 1)}
|
||||
>
|
||||
rerender
|
||||
</button>
|
||||
<Input data-test="field" defaultValue="first_view_event" />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
const trigger = document.querySelector('.draggable-trigger') as HTMLElement;
|
||||
fireEvent.mouseOver(trigger);
|
||||
fireEvent.click(screen.getByTestId('rerender'));
|
||||
|
||||
const input = screen.getByTestId('field');
|
||||
drag(input, [200, 400], [260, 430]);
|
||||
|
||||
expect(isDragged()).toBe(false);
|
||||
});
|
||||
|
||||
test('dragging is disabled entirely when draggable is not set', () => {
|
||||
render(
|
||||
<Modal show onHide={() => {}} title="Edit Dataset" name="test">
|
||||
<Input data-test="field" defaultValue="value" />
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.draggable-trigger')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -269,7 +269,6 @@ const CustomModal = ({
|
||||
);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [bounds, setBounds] = useState<DraggableBounds>({});
|
||||
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
|
||||
const theme = useTheme();
|
||||
|
||||
const handleOnHide = () => {
|
||||
@@ -339,19 +338,7 @@ const CustomModal = ({
|
||||
}, [hideFooter, resizableConfig]);
|
||||
|
||||
const ModalTitle = () =>
|
||||
draggable ? (
|
||||
<div
|
||||
className="draggable-trigger"
|
||||
onMouseOver={() => dragDisabled && setDragDisabled(false)}
|
||||
onMouseOut={() => !dragDisabled && setDragDisabled(true)}
|
||||
onFocus={() => dragDisabled && setDragDisabled(false)}
|
||||
onBlur={() => !dragDisabled && setDragDisabled(true)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : (
|
||||
<>{title}</>
|
||||
);
|
||||
draggable ? <div className="draggable-trigger">{title}</div> : <>{title}</>;
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
@@ -378,13 +365,17 @@ const CustomModal = ({
|
||||
modalRender={modal =>
|
||||
resizable || draggable ? (
|
||||
<Draggable
|
||||
disabled={!draggable || dragDisabled}
|
||||
bounds={bounds ?? false}
|
||||
onStart={(event, uiData) => onDragStart(event, uiData)}
|
||||
{...draggableConfig}
|
||||
// These two props are derived from `draggable` and must stay
|
||||
// authoritative, so they're applied after the spread to
|
||||
// prevent callers from overriding them via `draggableConfig`.
|
||||
disabled={!draggable}
|
||||
handle={draggable ? '.draggable-trigger' : undefined}
|
||||
// Pass nodeRef so react-draggable does not fall back to
|
||||
// ReactDOM.findDOMNode (deprecated in React 18+ Strict Mode).
|
||||
nodeRef={draggableRef}
|
||||
{...draggableConfig}
|
||||
>
|
||||
{resizable ? (
|
||||
<Resizable className="resizable" {...getResizableConfig}>
|
||||
|
||||
@@ -71,6 +71,9 @@ 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>;
|
||||
|
||||
@@ -96,16 +96,57 @@ 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, itemText, timeout);
|
||||
let popup = await this.openSubmenuWithHover(
|
||||
submenuText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Fallback to keyboard navigation
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithKeyboard(
|
||||
submenuText,
|
||||
itemText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
@@ -114,7 +155,7 @@ export class Menu {
|
||||
if (!popup) {
|
||||
popup = await this.openSubmenuWithDispatchEvent(
|
||||
submenuText,
|
||||
itemText,
|
||||
matchPopup,
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
@@ -125,10 +166,7 @@ export class Menu {
|
||||
);
|
||||
}
|
||||
|
||||
// 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');
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,17 +175,14 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithHover(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
const submenuTitle = this.getSubmenuTitle(submenuText);
|
||||
await submenuTitle.hover();
|
||||
|
||||
// Find the popup that contains the expected item (scopes to correct popup)
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
// Allow Ant Design's slide-in animation to complete before clicking.
|
||||
@@ -166,7 +201,7 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithKeyboard(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
@@ -174,9 +209,7 @@ export class Menu {
|
||||
await submenuTitle.focus();
|
||||
await this.page.keyboard.press('ArrowRight');
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
@@ -191,7 +224,7 @@ export class Menu {
|
||||
*/
|
||||
private async openSubmenuWithDispatchEvent(
|
||||
submenuText: string,
|
||||
itemText: string,
|
||||
matchPopup: () => Locator,
|
||||
timeout: number,
|
||||
): Promise<Locator | null> {
|
||||
try {
|
||||
@@ -214,9 +247,7 @@ export class Menu {
|
||||
);
|
||||
});
|
||||
|
||||
const popup = this.page
|
||||
.locator(Menu.SELECTORS.SUBMENU_POPUP)
|
||||
.filter({ hasText: itemText });
|
||||
const popup = matchPopup();
|
||||
await popup.waitFor({ state: 'visible', timeout });
|
||||
|
||||
return popup;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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,6 +21,7 @@
|
||||
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,6 +20,7 @@
|
||||
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';
|
||||
@@ -454,4 +455,124 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
/**
|
||||
* 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 @@ 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,
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -20,6 +20,7 @@ import '@testing-library/jest-dom';
|
||||
import {
|
||||
getTextColorForBackground,
|
||||
ObjectFormattingEnum,
|
||||
ColorSchemeEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
@@ -2075,6 +2076,59 @@ 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,
|
||||
|
||||
+49
-19
@@ -196,6 +196,54 @@ 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;
|
||||
@@ -852,25 +900,7 @@ function DatasourceEditor({
|
||||
const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
|
||||
...propsDatasource,
|
||||
editors: normalizeSubjectsToPickerValues(propsDatasource.editors || []),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
metrics: propsDatasource.metrics?.map(hydrateMetricExtra),
|
||||
}));
|
||||
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
+1
-1
@@ -60,7 +60,7 @@ export default function DndAdhocFilterOption({
|
||||
<OptionWrapper
|
||||
key={index}
|
||||
index={index}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
clickClose={onClickClose}
|
||||
onShiftOptions={onShiftOptions}
|
||||
|
||||
+30
-1
@@ -43,7 +43,7 @@ import {
|
||||
DndFilterSelectProps,
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
|
||||
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
|
||||
import { ExpressionTypes } from '../FilterControl/types';
|
||||
import { Clauses, ExpressionTypes } from '../FilterControl/types';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import { Datasource } from '../../../types';
|
||||
import {
|
||||
@@ -137,6 +137,35 @@ 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({
|
||||
|
||||
+28
@@ -370,4 +370,32 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ import {
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
Operators,
|
||||
} from 'src/explore/constants';
|
||||
import { translateToSql } from '../utils/translateToSQL';
|
||||
import { translateToSql, VerboseColumn } from '../utils/translateToSQL';
|
||||
import { Clauses, ExpressionTypes } from '../types';
|
||||
|
||||
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
@@ -193,8 +193,8 @@ export default class AdhocFilter {
|
||||
);
|
||||
}
|
||||
|
||||
getDefaultLabel(): string {
|
||||
const label = this.translateToSql();
|
||||
getDefaultLabel(columns?: VerboseColumn[]): string {
|
||||
const label = this.translateToSql({ columns });
|
||||
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ export default class AdhocFilter {
|
||||
return this.translateToSql();
|
||||
}
|
||||
|
||||
translateToSql(): string {
|
||||
return translateToSql(this as unknown as CoreAdhocFilter);
|
||||
translateToSql(params: { columns?: VerboseColumn[] } = {}): string {
|
||||
return translateToSql(this as unknown as CoreAdhocFilter, params);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -23,6 +23,7 @@ import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
@@ -914,3 +915,39 @@ 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();
|
||||
});
|
||||
|
||||
+4
@@ -639,7 +639,11 @@ 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}
|
||||
/>
|
||||
);
|
||||
|
||||
+18
@@ -71,6 +71,24 @@ 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');
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export default function AdhocFilterOption({
|
||||
partitionColumn={partitionColumn ?? undefined}
|
||||
>
|
||||
<OptionControlLabel
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
|
||||
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
|
||||
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
|
||||
onRemove={() =>
|
||||
onRemoveFilter({
|
||||
|
||||
+32
-2
@@ -63,9 +63,35 @@ 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 }: { useSimple: boolean } = { useSimple: false },
|
||||
{
|
||||
useSimple,
|
||||
columns,
|
||||
}: { useSimple?: boolean; columns?: VerboseColumn[] } = {},
|
||||
) => {
|
||||
if (isSimpleAdhocFilter(adhocFilter) || useSimple) {
|
||||
const { subject, operator } = adhocFilter as SimpleAdhocFilter;
|
||||
@@ -81,7 +107,11 @@ 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(subject, op, comparator);
|
||||
return getSimpleSQLExpression(
|
||||
getDisplaySubject(subject, columns),
|
||||
op,
|
||||
comparator,
|
||||
);
|
||||
}
|
||||
if (isFreeFormAdhocFilter(adhocFilter)) {
|
||||
return adhocFilter.sqlExpression;
|
||||
|
||||
+5
-4
@@ -22,10 +22,11 @@ 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,9 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import exploreReducer, { ExploreState } from './exploreReducer';
|
||||
import { setStashFormData } from '../actions/exploreActions';
|
||||
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';
|
||||
|
||||
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
|
||||
const initialState: ExploreState = {
|
||||
@@ -52,3 +54,72 @@ 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,9 +18,15 @@
|
||||
*/
|
||||
import { createMemoryHistory, type Update } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
within,
|
||||
} 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', () => ({
|
||||
@@ -37,7 +43,18 @@ const mockChart = {
|
||||
thumbnail_url: '/thumbnail.png',
|
||||
} as Chart;
|
||||
|
||||
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
|
||||
// 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>> = {},
|
||||
) =>
|
||||
render(
|
||||
<Router history={history}>
|
||||
<ChartCard
|
||||
@@ -52,6 +69,7 @@ const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
|
||||
favoriteStatus={false}
|
||||
showThumbnails
|
||||
handleBulkChartExport={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
@@ -106,3 +124,44 @@ 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,6 +38,10 @@ 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';
|
||||
@@ -159,15 +163,29 @@ 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
|
||||
title={t('Please confirm')}
|
||||
recoverable={softDelete}
|
||||
title={
|
||||
softDelete
|
||||
? t('Archive %(name)s?', { name: chart.slice_name })
|
||||
: t('Please confirm')
|
||||
}
|
||||
description={
|
||||
<>
|
||||
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
|
||||
</>
|
||||
softDelete ? (
|
||||
<p>{archiveConfirmDescription(t('chart'))}</p>
|
||||
) : (
|
||||
<>
|
||||
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
|
||||
?
|
||||
</>
|
||||
)
|
||||
}
|
||||
onConfirm={() =>
|
||||
handleChartDelete(
|
||||
@@ -204,7 +222,7 @@ export default function ChartCard({
|
||||
vertical-align: text-top;
|
||||
`}
|
||||
/>{' '}
|
||||
{t('Delete')}
|
||||
{deleteActionLabel()}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
+5
-4
@@ -38,10 +38,11 @@ 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
|
||||
|
||||
+5
-4
@@ -29,10 +29,11 @@ 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,6 +72,12 @@ 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,8 +25,10 @@ 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';
|
||||
@@ -88,6 +90,15 @@ 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,
|
||||
@@ -144,6 +155,13 @@ 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();
|
||||
@@ -573,3 +591,57 @@ 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,6 +37,7 @@ 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';
|
||||
@@ -82,10 +83,12 @@ const EmptyStateRow = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const TYPE_LABELS: Record<ArchivedType, string> = {
|
||||
chart: t('Chart'),
|
||||
dashboard: t('Dashboard'),
|
||||
dataset: t('Dataset'),
|
||||
// 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,
|
||||
};
|
||||
|
||||
interface ToastProps {
|
||||
@@ -166,7 +169,7 @@ function ArchivedListBody({
|
||||
refreshData,
|
||||
} = useListViewResource<ArchivedItem>(
|
||||
config.resource,
|
||||
TYPE_LABELS[type],
|
||||
TYPE_LABELS[type](),
|
||||
addDangerToast,
|
||||
true,
|
||||
[],
|
||||
@@ -247,7 +250,7 @@ function ArchivedListBody({
|
||||
name => {
|
||||
const { text, options } = recoveredToast(
|
||||
name,
|
||||
TYPE_LABELS[type],
|
||||
TYPE_LABELS[type](),
|
||||
item.url ?? item.explore_url,
|
||||
);
|
||||
addSuccessToast(text, options);
|
||||
@@ -306,7 +309,7 @@ function ArchivedListBody({
|
||||
id: config.nameField,
|
||||
},
|
||||
{
|
||||
Cell: () => TYPE_LABELS[type],
|
||||
Cell: () => TYPE_LABELS[type](),
|
||||
Header: t('Type'),
|
||||
id: 'type',
|
||||
disableSortBy: true,
|
||||
@@ -539,7 +542,7 @@ function ArchivedList({ addDangerToast, addSuccessToast }: ToastProps) {
|
||||
onChange={handleTypeChange}
|
||||
options={availableTypes.map(option => ({
|
||||
value: option,
|
||||
label: TYPE_LABELS[option],
|
||||
label: TYPE_LABELS[option](),
|
||||
}))}
|
||||
/>
|
||||
</TypeSelectRow>
|
||||
|
||||
@@ -96,19 +96,29 @@ 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. You can recover it there within 30 days.',
|
||||
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 30 days.',
|
||||
);
|
||||
expect(archiveConfirmDescription('charts', true)).toBe(
|
||||
'These charts will be moved to Recently Archived. You can recover them there within 30 days.',
|
||||
'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.',
|
||||
);
|
||||
});
|
||||
|
||||
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. You can recover it there.',
|
||||
'This dashboard will be moved to Recently Archived in the Settings menu. You can recover it there.',
|
||||
);
|
||||
expect(archiveConfirmDescription('dashboards', true)).toBe(
|
||||
'These dashboards will be moved to Recently Archived. You can recover them there.',
|
||||
'These dashboards will be moved to Recently Archived in the Settings menu. You can recover them there.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { escape } from 'lodash-es';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { t, tn } from '@apache-superset/core/translation';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
@@ -62,25 +62,32 @@ 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
|
||||
? t(
|
||||
'These %(type)s will be moved to Recently Archived. You can recover them there within %(days)s days.',
|
||||
? 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,
|
||||
{ type: typeLabel, days },
|
||||
)
|
||||
: t(
|
||||
'This %(type)s will be moved to Recently Archived. You can recover it there within %(days)s 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,
|
||||
{ type: typeLabel, days },
|
||||
);
|
||||
}
|
||||
return plural
|
||||
? t(
|
||||
'These %(type)s will be moved to Recently Archived. You can recover them there.',
|
||||
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there.',
|
||||
{ type: typeLabel },
|
||||
)
|
||||
: t(
|
||||
'This %(type)s will be moved to Recently Archived. You can recover it there.',
|
||||
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there.',
|
||||
{ type: typeLabel },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,21 @@ 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();
|
||||
});
|
||||
@@ -282,6 +297,147 @@ 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 },
|
||||
|
||||
@@ -148,6 +148,7 @@ export function useListViewResource<D extends object = any>(
|
||||
);
|
||||
|
||||
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
const fetchData = useCallback(
|
||||
({
|
||||
@@ -156,6 +157,9 @@ 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,
|
||||
@@ -196,24 +200,31 @@ 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 =>
|
||||
handleErrorMsg(
|
||||
t(
|
||||
'An error occurred while fetching %ss: %s',
|
||||
resourceLabel,
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
createErrorHandler(errMsg => {
|
||||
if (isLatest()) {
|
||||
handleErrorMsg(
|
||||
t(
|
||||
'An error occurred while fetching %ss: %s',
|
||||
resourceLabel,
|
||||
errMsg,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
updateState({ loading: false });
|
||||
if (isLatest()) {
|
||||
updateState({ loading: false });
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
|
||||
@@ -28,16 +28,6 @@ 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
|
||||
|
||||
@@ -196,13 +196,26 @@ 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
|
||||
def is_str_or_adhoc(metric: Metric) -> bool:
|
||||
return isinstance(metric, str) or is_adhoc_metric(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"}
|
||||
|
||||
self.metrics = metrics and [
|
||||
x if is_str_or_adhoc(x) else x["label"] # type: ignore
|
||||
for x in metrics
|
||||
]
|
||||
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]
|
||||
|
||||
def _set_post_processing(
|
||||
self, post_processing: list[dict[str, Any] | None] | None
|
||||
|
||||
@@ -99,6 +99,7 @@ from superset.models.helpers import (
|
||||
AuditMixinNullable,
|
||||
CertificationMixin,
|
||||
ExploreMixin,
|
||||
get_effective_hours_offset,
|
||||
ImportExportMixin,
|
||||
QueryResult,
|
||||
SoftDeleteMixin,
|
||||
@@ -1247,6 +1248,8 @@ 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.
|
||||
@@ -1254,6 +1257,8 @@ 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
|
||||
@@ -1291,6 +1296,27 @@ 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)
|
||||
|
||||
@@ -1975,11 +2001,26 @@ 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.
|
||||
@@ -1989,6 +2030,8 @@ 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
|
||||
@@ -2005,6 +2048,7 @@ 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
|
||||
@@ -2019,6 +2063,7 @@ 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
|
||||
@@ -2081,8 +2126,28 @@ 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,
|
||||
|
||||
@@ -21,11 +21,7 @@ from flask import g
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
|
||||
from superset.commands.tag.exceptions import TagNotFoundError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
@@ -349,6 +345,10 @@ 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()
|
||||
|
||||
@@ -539,6 +539,7 @@ 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
|
||||
@@ -1242,6 +1243,19 @@ 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:
|
||||
"""
|
||||
@@ -1378,12 +1392,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 name 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 index and a mutator function to normalize
|
||||
# values with. The first two items in the description row are the column
|
||||
# name and type.
|
||||
column_mutators = {
|
||||
row[0]: func
|
||||
for row in description
|
||||
index: func
|
||||
for index, row in enumerate(description)
|
||||
if (
|
||||
func := cls.column_type_mutators.get(
|
||||
type(cls.get_sqla_column_type(cls.get_datatype(row[1])))
|
||||
@@ -1391,11 +1405,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
)
|
||||
}
|
||||
if column_mutators:
|
||||
indexes = {row[0]: idx for idx, row in enumerate(description)}
|
||||
if not isinstance(data, list):
|
||||
data = list(data)
|
||||
for row_idx, row in enumerate(data):
|
||||
new_row = list(row)
|
||||
for col, func in column_mutators.items():
|
||||
col_idx = indexes[col]
|
||||
for col_idx, func in column_mutators.items():
|
||||
new_row[col_idx] = func(row[col_idx])
|
||||
data[row_idx] = tuple(new_row)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from typing import Any, TYPE_CHECKING, TypedDict
|
||||
|
||||
@@ -32,7 +33,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
|
||||
from sqlalchemy import text, types
|
||||
from sqlalchemy.engine import create_engine
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
@@ -155,6 +156,27 @@ 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,
|
||||
|
||||
@@ -267,6 +267,43 @@ 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
|
||||
@@ -425,22 +462,27 @@ 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
|
||||
|
||||
@@ -360,6 +360,7 @@ 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()
|
||||
|
||||
@@ -25,9 +25,14 @@ 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
|
||||
from superset.db_engine_specs.base import (
|
||||
BaseEngineSpec,
|
||||
DatabaseCategory,
|
||||
TimestampExpression,
|
||||
)
|
||||
from superset.errors import SupersetErrorType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,6 +48,7 @@ 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.",
|
||||
@@ -140,6 +146,20 @@ 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
|
||||
|
||||
@@ -30,7 +30,6 @@ from superset.commands.temporary_cache.exceptions import (
|
||||
TemporaryCacheResourceNotFoundError,
|
||||
)
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.exceptions import SupersetTemplateException
|
||||
from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema
|
||||
from superset.extensions import event_logger
|
||||
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
|
||||
@@ -111,8 +110,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("PUT",))
|
||||
@protect()
|
||||
@@ -186,8 +183,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("GET",))
|
||||
@protect()
|
||||
@@ -239,8 +234,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("DELETE",))
|
||||
@protect()
|
||||
@@ -293,5 +286,3 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# under the License.
|
||||
from typing import Optional
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartAccessDeniedError,
|
||||
@@ -35,7 +33,6 @@ from superset.commands.exceptions import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.daos.query import QueryDAO
|
||||
from superset.exceptions import SupersetTemplateException
|
||||
from superset.utils.core import DatasourceType
|
||||
|
||||
|
||||
@@ -56,13 +53,7 @@ def check_query_access(query_id: int) -> Optional[bool]:
|
||||
# Access checks below, no need to validate them twice as they can be expensive.
|
||||
query = QueryDAO.find_by_id(query_id, skip_base_filter=True)
|
||||
if query:
|
||||
try:
|
||||
security_manager.raise_for_access(query=query)
|
||||
except TemplateError as ex:
|
||||
# raise_for_access() Jinja-renders the query's SQL to resolve
|
||||
# the tables it touches; a malformed template surfaces here as
|
||||
# a raw jinja2 exception rather than a Superset one.
|
||||
raise SupersetTemplateException(str(ex)) from ex
|
||||
security_manager.raise_for_access(query=query)
|
||||
return True
|
||||
raise QueryNotFoundValidationError()
|
||||
|
||||
|
||||
@@ -672,21 +672,34 @@ kubectl get ingress -n superset
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `MCP_DEV_USERNAME` | Superset username for MCP authentication | `admin` |
|
||||
| `MCP_AUTH_ENABLED` | Enable/disable authentication | `true` |
|
||||
| `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_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
|
||||
# MCP Service Configuration -- development/testing (no auth)
|
||||
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
|
||||
|
||||
# For production with JWT authentication
|
||||
# Or, for a fully custom auth setup instead of the built-in JWT verifier:
|
||||
MCP_AUTH_FACTORY = 'your.custom.auth_factory'
|
||||
MCP_USER_RESOLVER = 'your.custom.user_resolver'
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ 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
|
||||
|
||||
@@ -67,8 +68,9 @@ 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 is
|
||||
appended separately (innermost position, same as in run_server()).
|
||||
duplicating middleware ordering. The optional response size guard and
|
||||
response caching middleware are appended separately (innermost
|
||||
position, same order as in run_server()).
|
||||
|
||||
FastMCP wraps handlers so that the FIRST-added middleware is outermost.
|
||||
``build_middleware_list()`` already returns middlewares in the correct
|
||||
@@ -77,12 +79,16 @@ def _add_default_middlewares() -> None:
|
||||
for middleware in build_middleware_list():
|
||||
mcp.add_middleware(middleware)
|
||||
|
||||
# Response size guard is innermost (added last)
|
||||
# Response size guard is innermost (added last), then response caching.
|
||||
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:
|
||||
"""
|
||||
@@ -157,8 +163,19 @@ def main() -> None:
|
||||
sys.stderr.write(f"[MCP] Client disconnected: {e}\n")
|
||||
sys.exit(0)
|
||||
else:
|
||||
# For other transports, use normal initialization
|
||||
init_fastmcp_server()
|
||||
# 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)
|
||||
_add_default_middlewares()
|
||||
|
||||
# Run with specified transport
|
||||
|
||||
@@ -575,7 +575,8 @@ 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
|
||||
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
|
||||
(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
|
||||
@@ -618,7 +619,14 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | None: # noqa: C901
|
||||
"Guest-marked token presented but embedded guest auth is not "
|
||||
"enabled; rejecting"
|
||||
)
|
||||
return None
|
||||
# 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"
|
||||
)
|
||||
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] = {
|
||||
|
||||
@@ -123,6 +123,27 @@ 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,6 +116,53 @@ 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,6 +118,56 @@ 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
|
||||
|
||||
|
||||
@@ -259,7 +259,9 @@ MCP_FACTORY_CONFIG = {
|
||||
#
|
||||
# Configuration Flow:
|
||||
# -------------------
|
||||
# - MCP_CACHE_CONFIG controls whether caching is enabled and its TTL settings
|
||||
# - 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_STORE_CONFIG controls the Redis store (optional)
|
||||
#
|
||||
# Scenarios:
|
||||
@@ -270,11 +272,13 @@ 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
|
||||
@@ -322,6 +326,13 @@ 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
|
||||
@@ -332,12 +343,38 @@ 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
|
||||
"excluded_tools": [ # Tools that should never be cached (side effects, dynamic)
|
||||
"execute_sql",
|
||||
"generate_dashboard",
|
||||
# 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",
|
||||
"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",
|
||||
"update_chart",
|
||||
"update_chart_preview",
|
||||
"update_dashboard",
|
||||
"update_dataset_metric",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -495,6 +532,16 @@ 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.
|
||||
@@ -519,22 +566,40 @@ 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):
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
@@ -708,15 +773,49 @@ def _build_jwt_verifier(
|
||||
"required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
|
||||
}
|
||||
|
||||
# For HS256 (symmetric), use the secret as the public_key parameter
|
||||
if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
|
||||
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
|
||||
common_kwargs["public_key"] = secret
|
||||
common_kwargs["algorithm"] = "HS256"
|
||||
common_kwargs["algorithm"] = algorithm
|
||||
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"] = app.config.get("MCP_JWT_ALGORITHM", "RS256")
|
||||
common_kwargs["algorithm"] = algorithm
|
||||
|
||||
if debug_errors:
|
||||
# DetailedJWTVerifier: detailed server-side logging of JWT
|
||||
|
||||
@@ -218,18 +218,30 @@ _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."""
|
||||
"""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.).
|
||||
"""
|
||||
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] = v
|
||||
result[k] = _sanitize_value(v)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1005,7 +1017,9 @@ 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}: {error.detail}") from error
|
||||
raise ToolError(
|
||||
f"Service error in {tool_name}: {_sanitize_error_for_logging(error)}"
|
||||
) from error
|
||||
elif isinstance(error, MCPPermissionDeniedError):
|
||||
# MCP RBAC permission denied — convert to structured ToolError.
|
||||
# Must come before the generic PermissionError branch because
|
||||
@@ -1020,7 +1034,8 @@ class GlobalErrorHandlerMiddleware(Middleware):
|
||||
elif isinstance(error, ValueError):
|
||||
# Value/parameter errors from tool code
|
||||
raise ToolError(
|
||||
f"Invalid parameter in {tool_name}: {str(error)}"
|
||||
f"Invalid parameter in {tool_name}: "
|
||||
f"{_sanitize_error_for_logging(error)}"
|
||||
) from error
|
||||
elif isinstance(error, (ObjectNotFoundError, CommandInvalidError)):
|
||||
# Superset command: not found (404) or validation (422)
|
||||
|
||||
@@ -808,11 +808,19 @@ 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(
|
||||
@@ -838,17 +846,18 @@ 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(
|
||||
@@ -862,8 +871,19 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
# no secret material.
|
||||
raise
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain secrets
|
||||
# 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.
|
||||
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,7 +65,12 @@ async def _validate_non_destructive_sql(
|
||||
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
|
||||
try:
|
||||
sql_to_check: str = request.sql
|
||||
if request.template_params:
|
||||
# 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:
|
||||
from superset.jinja_context import get_template_processor
|
||||
|
||||
tp = get_template_processor(database=database)
|
||||
|
||||
@@ -24,6 +24,7 @@ system-level info.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, List
|
||||
|
||||
@@ -32,6 +33,12 @@ 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.
|
||||
@@ -176,7 +183,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, username, or email) to user IDs
|
||||
Resolves a person's name (or partial name or username) 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.
|
||||
@@ -191,8 +198,9 @@ class FindUsersRequest(BaseModel):
|
||||
max_length=200,
|
||||
description=(
|
||||
"Substring to match (case-insensitive) against username, "
|
||||
"first_name, last_name, and email. Required and non-empty: "
|
||||
"this tool does not enumerate the full user directory."
|
||||
"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."
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -217,6 +225,20 @@ 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,9 +50,14 @@ 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, last_name, and
|
||||
email. The query is required and non-empty; this tool does not enumerate
|
||||
the full user directory.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -75,7 +80,6 @@ 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())
|
||||
|
||||
@@ -179,6 +179,21 @@ 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"
|
||||
|
||||
@@ -1543,6 +1558,7 @@ 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
|
||||
@@ -1555,6 +1571,7 @@ 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
|
||||
|
||||
|
||||
@@ -1654,6 +1671,7 @@ class QueryStringExtended(NamedTuple):
|
||||
labels_expected: list[str]
|
||||
prequeries: list[str]
|
||||
sql: str
|
||||
sql_shifted_temporal_labels: set[str]
|
||||
|
||||
|
||||
class SqlaQuery(NamedTuple):
|
||||
@@ -1665,6 +1683,7 @@ 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
|
||||
@@ -2019,6 +2038,7 @@ 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(
|
||||
@@ -2223,6 +2243,7 @@ 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:
|
||||
@@ -2292,6 +2313,7 @@ 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
|
||||
@@ -2311,6 +2333,7 @@ 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:
|
||||
@@ -2329,7 +2352,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
extra.append(
|
||||
DateColumn(
|
||||
timestamp_format=None,
|
||||
offset=self.offset,
|
||||
offset=(
|
||||
0 if label in sql_shifted_temporal_labels else self.offset
|
||||
),
|
||||
time_shift=query_object.time_shift,
|
||||
col_label=label,
|
||||
)
|
||||
@@ -2337,15 +2362,22 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
already_collected.add(label)
|
||||
return extra
|
||||
|
||||
def normalize_df(self, df: pd.DataFrame, query_object: QueryObject) -> pd.DataFrame:
|
||||
def normalize_df(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
query_object: QueryObject,
|
||||
sql_shifted_temporal_labels: set[str] | None = None,
|
||||
) -> 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
|
||||
@@ -2357,7 +2389,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
dttm_cols = [
|
||||
DateColumn(
|
||||
timestamp_format=fmt,
|
||||
offset=self.offset,
|
||||
offset=0 if label in sql_shifted_temporal_labels else self.offset,
|
||||
time_shift=query_object.time_shift,
|
||||
timezone=dataset_timezone,
|
||||
col_label=label,
|
||||
@@ -2369,7 +2401,9 @@ 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=self.offset,
|
||||
offset=(
|
||||
0 if DTTM_ALIAS in sql_shifted_temporal_labels else self.offset
|
||||
),
|
||||
time_shift=query_object.time_shift,
|
||||
timezone=dataset_timezone,
|
||||
)
|
||||
@@ -2377,7 +2411,10 @@ 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}
|
||||
df,
|
||||
query_object,
|
||||
{col.col_label for col in dttm_cols},
|
||||
sql_shifted_temporal_labels,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2423,7 +2460,11 @@ 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)
|
||||
df = self.normalize_df(
|
||||
df,
|
||||
query_object,
|
||||
result.sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
# Process time offsets if requested
|
||||
if query_object.time_offsets:
|
||||
@@ -2733,7 +2774,9 @@ 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
|
||||
offset_metrics_df,
|
||||
query_object_clone,
|
||||
result.sql_shifted_temporal_labels,
|
||||
)
|
||||
|
||||
# 2. rename extra query columns
|
||||
@@ -3745,6 +3788,8 @@ 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()
|
||||
|
||||
@@ -3929,6 +3974,12 @@ 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:
|
||||
@@ -4298,6 +4349,7 @@ 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 []
|
||||
@@ -4406,6 +4458,8 @@ 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(
|
||||
@@ -4445,6 +4499,8 @@ 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:
|
||||
@@ -4466,6 +4522,8 @@ 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 (
|
||||
@@ -4506,6 +4564,8 @@ 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
|
||||
@@ -4541,7 +4601,10 @@ 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
|
||||
time_grain=time_grain,
|
||||
template_processor=template_processor,
|
||||
apply_dataset_offset=True,
|
||||
sql_shifted_temporal_labels=sql_shifted_temporal_labels,
|
||||
)
|
||||
# always put timestamp as the first column
|
||||
select_exprs.insert(0, timestamp)
|
||||
@@ -5350,4 +5413,5 @@ 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,
|
||||
)
|
||||
|
||||
@@ -437,6 +437,8 @@ 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.
|
||||
|
||||
@@ -5618,6 +5618,18 @@ 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:
|
||||
|
||||
@@ -13831,14 +13831,18 @@ msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"These %(type)s will be moved to Recently Archived. You can recover them "
|
||||
"there within %(days)s days."
|
||||
msgstr ""
|
||||
"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] ""
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"These %(type)s will be moved to Recently Archived. You can recover them "
|
||||
"there."
|
||||
"These %(type)s will be moved to Recently Archived in the Settings menu. "
|
||||
"You can recover them there."
|
||||
msgstr ""
|
||||
|
||||
msgid "These are the datasets this filter will be applied to."
|
||||
@@ -13846,14 +13850,18 @@ msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This %(type)s will be moved to Recently Archived. You can recover it "
|
||||
"there within %(days)s days."
|
||||
msgstr ""
|
||||
"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] ""
|
||||
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This %(type)s will be moved to Recently Archived. You can recover it "
|
||||
"there."
|
||||
"This %(type)s will be moved to Recently Archived in the Settings menu. "
|
||||
"You can recover it there."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""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,9 +605,6 @@ 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")
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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: offset_df
|
||||
side_effect=lambda offset_df, _query_object, _labels=None: 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: offset_df
|
||||
side_effect=lambda offset_df, _query_object, _labels=None: 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: offset_df
|
||||
side_effect=lambda offset_df, _query_object, _labels=None: offset_df
|
||||
)
|
||||
|
||||
with (
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
|
||||
# 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
|
||||
|
||||
@@ -33,6 +36,8 @@ 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
|
||||
@@ -1068,3 +1073,33 @@ 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)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import builtins
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from types import ModuleType
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -73,6 +73,16 @@ 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(
|
||||
@@ -87,6 +97,50 @@ 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",
|
||||
[
|
||||
@@ -269,7 +323,7 @@ def test_column_type_mutator(
|
||||
assert spec.fetch_data(mock_cursor) == expected_result
|
||||
|
||||
|
||||
def test_get_datatype_pymysql_fallback():
|
||||
def test_get_datatype_pymysql_fallback() -> None:
|
||||
"""get_datatype() falls back to pymysql when MySQLdb is not installed."""
|
||||
from superset.db_engine_specs.mysql import MySQLEngineSpec
|
||||
|
||||
@@ -279,15 +333,9 @@ def test_get_datatype_pymysql_fallback():
|
||||
|
||||
try:
|
||||
# Build a fake pymysql module with constants.FIELD_TYPE
|
||||
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
|
||||
fake_field_type = SimpleNamespace(TINY=1, VARCHAR=15)
|
||||
fake_constants = SimpleNamespace(FIELD_TYPE=fake_field_type)
|
||||
fake_pymysql = SimpleNamespace(constants=fake_constants)
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
@@ -308,6 +356,31 @@ def test_get_datatype_pymysql_fallback():
|
||||
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"),
|
||||
[
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
from pytest import raises # noqa: PT013
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -31,7 +30,7 @@ from superset.commands.exceptions import (
|
||||
DatasourceNotFoundValidationError,
|
||||
QueryNotFoundValidationError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException, SupersetTemplateException
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils.core import DatasourceType, override_user
|
||||
|
||||
dataset_find_by_id = "superset.daos.dataset.DatasetDAO.find_by_id"
|
||||
@@ -341,28 +340,6 @@ def test_query_has_access(mocker: MockerFixture) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_query_malformed_jinja_template(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
``raise_for_access(query=...)`` Jinja-renders the query's SQL to resolve
|
||||
the tables it touches. A malformed template must surface as a
|
||||
``SupersetTemplateException``, not the raw ``jinja2`` exception.
|
||||
"""
|
||||
from superset.explore.utils import check_datasource_access
|
||||
from superset.models.sql_lab import Query
|
||||
|
||||
mocker.patch(query_find_by_id, return_value=Query())
|
||||
mocker.patch(
|
||||
raise_for_access,
|
||||
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
|
||||
)
|
||||
|
||||
with raises(SupersetTemplateException): # noqa: PT012
|
||||
check_datasource_access(
|
||||
datasource_id=1,
|
||||
datasource_type=DatasourceType.QUERY,
|
||||
)
|
||||
|
||||
|
||||
def test_query_no_access(mocker: MockerFixture, client) -> None:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.explore.utils import check_datasource_access
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from scripts import change_detector
|
||||
|
||||
WORKFLOW_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ ".github/workflows/frontend-bundle-size-nightly.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_workflow() -> dict[str, Any]:
|
||||
return yaml.safe_load(WORKFLOW_PATH.read_text())
|
||||
|
||||
|
||||
def test_scheduled_bundle_size_action_uses_read_only_token() -> None:
|
||||
workflow = load_workflow()
|
||||
job = workflow["jobs"]["refresh-baseline"]
|
||||
steps = {step["name"]: step for step in job["steps"]}
|
||||
|
||||
benchmark_step = steps["Update bundle size baseline"]
|
||||
assert benchmark_step["with"]["github-token"] == "${{ secrets.GITHUB_TOKEN }}"
|
||||
assert workflow["permissions"]["contents"] == "read"
|
||||
effective_permissions = job.get("permissions", workflow["permissions"])
|
||||
assert effective_permissions.get("contents") in {None, "read"}
|
||||
|
||||
|
||||
def test_scheduled_bundle_size_changes_trigger_python_tests() -> None:
|
||||
assert change_detector.detect_changes(
|
||||
[".github/workflows/frontend-bundle-size-nightly.yml"],
|
||||
change_detector.PATTERNS["python"],
|
||||
)
|
||||
@@ -23,6 +23,7 @@ autouse mock_auth fixture, matching the other chart tool test files.
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import UUID
|
||||
|
||||
@@ -45,11 +46,15 @@ def mcp_server() -> object:
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_auth() -> Iterator[Mock]:
|
||||
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
|
||||
mock_user = Mock()
|
||||
mock_user.id = 1
|
||||
mock_user.username = "admin"
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_get_user
|
||||
# The tool's editorship gate calls the real security manager; default
|
||||
# it to a no-op (caller is an editor) so unrelated tests keep passing.
|
||||
# The disclosure regression tests below re-patch it to raise.
|
||||
with patch("superset.security_manager.raise_for_editorship"):
|
||||
mock_user = Mock()
|
||||
mock_user.id = 1
|
||||
mock_user.username = "admin"
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_get_user
|
||||
|
||||
|
||||
def _mock_chart(
|
||||
@@ -232,6 +237,33 @@ async def test_restore_chart_lookup_db_error_is_structured(
|
||||
assert "down" not in (content["error"] or "")
|
||||
|
||||
|
||||
@patch(_FIND)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_chart_editorship_check_db_error_is_structured(
|
||||
mock_find: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""DB failures during the editorship check (not just the initial lookup)
|
||||
must return the structured LookupFailed response instead of escaping the
|
||||
tool as an unhandled error."""
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
mock_find.return_value = _mock_chart(chart_id=10, slice_name="Sales")
|
||||
|
||||
with patch(
|
||||
"superset.security_manager.raise_for_editorship",
|
||||
side_effect=OperationalError("SELECT ...", {}, Exception("down")),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"restore_chart", {"request": {"identifier": 10}}
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["success"] is False
|
||||
assert content["error_type"] == "LookupFailed"
|
||||
assert "down" not in (content["error"] or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_chart_rejects_boolean_identifier(mcp_server: object) -> None:
|
||||
"""bool subclasses int; identifier=true must not coerce to chart ID 1."""
|
||||
@@ -240,3 +272,81 @@ async def test_restore_chart_rejects_boolean_identifier(mcp_server: object) -> N
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool("restore_chart", {"request": {"identifier": True}})
|
||||
|
||||
|
||||
@patch(_FIND)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_chart_inaccessible_chart_reads_as_not_found(
|
||||
mock_find: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""A chart outside the caller's RBAC scope must not leak its existence or
|
||||
title: the unfiltered restore lookup finds it, the base-filtered re-lookup
|
||||
does not, so the tool must answer exactly as if it does not exist.
|
||||
|
||||
The mock's return value is keyed on the actual ``skip_base_filter`` kwarg
|
||||
of each call rather than call order, so a regression that accidentally
|
||||
keeps ``skip_base_filter=True`` on the re-lookup (turning the intended
|
||||
NotFound response into a disclosure) is caught by a call-order-based
|
||||
mock returning the chart on both calls, not masked by it."""
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
def _find_side_effect(*args: Any, **kwargs: Any) -> Any | None:
|
||||
# Mirrors the real DAO contract: only an explicit skip_base_filter=True
|
||||
# (the initial unfiltered restore lookup) sees the chart; the
|
||||
# re-lookup omits it, so it defaults to False and must see nothing.
|
||||
if kwargs.get("skip_base_filter"):
|
||||
return _mock_chart(chart_id=10, slice_name="Secret KPI")
|
||||
return None
|
||||
|
||||
mock_find.side_effect = _find_side_effect
|
||||
forbidden = SupersetSecurityException(
|
||||
SupersetError(
|
||||
message="forbidden",
|
||||
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
with patch("superset.security_manager.raise_for_editorship", side_effect=forbidden):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"restore_chart", {"request": {"identifier": 10}}
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["success"] is False
|
||||
assert content["error_type"] == "NotFound"
|
||||
assert "Secret KPI" not in (content["error"] or "")
|
||||
|
||||
|
||||
@patch(_FIND)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_chart_visible_non_editor_gets_nameless_forbidden(
|
||||
mock_find: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""A caller who can see the chart but cannot edit it gets a permission
|
||||
error naming the id only, never the title."""
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
chart = _mock_chart(chart_id=10, slice_name="Secret KPI")
|
||||
mock_find.side_effect = [chart, chart]
|
||||
forbidden = SupersetSecurityException(
|
||||
SupersetError(
|
||||
message="forbidden",
|
||||
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
with patch("superset.security_manager.raise_for_editorship", side_effect=forbidden):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"restore_chart", {"request": {"identifier": 10}}
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["success"] is False
|
||||
assert content["permission_denied"] is True
|
||||
assert content["error_type"] == "Forbidden"
|
||||
assert "Secret KPI" not in (content["error"] or "")
|
||||
assert "10" in (content["error"] or "")
|
||||
|
||||
@@ -23,6 +23,7 @@ autouse mock_auth fixture, matching the other dashboard tool test files.
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import UUID
|
||||
|
||||
@@ -45,11 +46,15 @@ def mcp_server() -> object:
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_auth() -> Iterator[Mock]:
|
||||
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
|
||||
mock_user = Mock()
|
||||
mock_user.id = 1
|
||||
mock_user.username = "admin"
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_get_user
|
||||
# The tool's editorship gate calls the real security manager; default
|
||||
# it to a no-op (caller is an editor) so unrelated tests keep passing.
|
||||
# The disclosure regression tests below re-patch it to raise.
|
||||
with patch("superset.security_manager.raise_for_editorship"):
|
||||
mock_user = Mock()
|
||||
mock_user.id = 1
|
||||
mock_user.username = "admin"
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_get_user
|
||||
|
||||
|
||||
def _mock_dashboard(
|
||||
@@ -236,6 +241,33 @@ async def test_restore_dashboard_slug_conflict(
|
||||
assert "slug" in (content["error"] or "").lower()
|
||||
|
||||
|
||||
@patch(_FIND)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_dashboard_editorship_check_db_error_is_structured(
|
||||
mock_find: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""DB failures during the editorship check (not just the initial lookup)
|
||||
must return the structured LookupFailed response instead of escaping the
|
||||
tool as an unhandled error."""
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
mock_find.return_value = _mock_dashboard(10, "Sales Dashboard")
|
||||
|
||||
with patch(
|
||||
"superset.security_manager.raise_for_editorship",
|
||||
side_effect=OperationalError("SELECT ...", {}, Exception("down")),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"restore_dashboard", {"request": {"identifier": 10}}
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["success"] is False
|
||||
assert content["error_type"] == "LookupFailed"
|
||||
assert "down" not in (content["error"] or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_dashboard_rejects_boolean_identifier(
|
||||
mcp_server: object,
|
||||
@@ -248,3 +280,82 @@ async def test_restore_dashboard_rejects_boolean_identifier(
|
||||
await client.call_tool(
|
||||
"restore_dashboard", {"request": {"identifier": True}}
|
||||
)
|
||||
|
||||
|
||||
@patch(_FIND)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_dashboard_inaccessible_dashboard_reads_as_not_found(
|
||||
mock_find: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""A dashboard outside the caller's RBAC scope must not leak its
|
||||
existence or title: the unfiltered restore lookup finds it, the
|
||||
base-filtered re-lookup does not, so the tool must answer exactly as if it
|
||||
does not exist.
|
||||
|
||||
The mock's return value is keyed on the actual ``skip_base_filter`` kwarg
|
||||
of each call rather than call order, so a regression that accidentally
|
||||
keeps ``skip_base_filter=True`` on the re-lookup (turning the intended
|
||||
NotFound response into a disclosure) is caught by a call-order-based
|
||||
mock returning the dashboard on both calls, not masked by it."""
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
def _find_side_effect(*args: Any, **kwargs: Any) -> Any | None:
|
||||
# Mirrors the real DAO contract: only an explicit skip_base_filter=True
|
||||
# (the initial unfiltered restore lookup) sees the dashboard; the
|
||||
# re-lookup omits it, so it defaults to False and must see nothing.
|
||||
if kwargs.get("skip_base_filter"):
|
||||
return _mock_dashboard(dashboard_id=10, title="Secret Board")
|
||||
return None
|
||||
|
||||
mock_find.side_effect = _find_side_effect
|
||||
forbidden = SupersetSecurityException(
|
||||
SupersetError(
|
||||
message="forbidden",
|
||||
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
with patch("superset.security_manager.raise_for_editorship", side_effect=forbidden):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"restore_dashboard", {"request": {"identifier": 10}}
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["success"] is False
|
||||
assert content["error_type"] == "NotFound"
|
||||
assert "Secret Board" not in (content["error"] or "")
|
||||
|
||||
|
||||
@patch(_FIND)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_dashboard_visible_non_editor_gets_nameless_forbidden(
|
||||
mock_find: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""A caller who can see the dashboard but cannot edit it gets a
|
||||
permission error naming the id only, never the title."""
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
dashboard = _mock_dashboard(dashboard_id=10, title="Secret Board")
|
||||
mock_find.side_effect = [dashboard, dashboard]
|
||||
forbidden = SupersetSecurityException(
|
||||
SupersetError(
|
||||
message="forbidden",
|
||||
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
with patch("superset.security_manager.raise_for_editorship", side_effect=forbidden):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"restore_dashboard", {"request": {"identifier": 10}}
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["success"] is False
|
||||
assert content["permission_denied"] is True
|
||||
assert content["error_type"] == "Forbidden"
|
||||
assert "Secret Board" not in (content["error"] or "")
|
||||
assert "10" in (content["error"] or "")
|
||||
|
||||
@@ -1399,6 +1399,52 @@ class TestDestructiveDDLBlocking:
|
||||
assert "Destructive DDL" in data["error"]
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_template_params_rendered_before_ddl_check(
|
||||
self, ddl_mocks, mcp_server
|
||||
):
|
||||
"""template_params={} must not skip Jinja rendering in the DDL guard.
|
||||
|
||||
The executor renders templates whenever template_params is not None
|
||||
(including {}), so the guard must parse the same rendered SQL. With a
|
||||
truthiness check, SQL whose destructive statement is hidden inside a
|
||||
Jinja expression in a comment passes the guard unrendered and then
|
||||
renders and executes.
|
||||
"""
|
||||
ddl_mocks.execute.return_value = _create_select_result(
|
||||
rows=[{"x": 1}], columns=["x"], original_sql="SELECT 1"
|
||||
)
|
||||
mock_tp = MagicMock()
|
||||
# The raw (unrendered) SQL below parses as a single, non-destructive
|
||||
# SELECT -- the Jinja expression sits inside a `--` comment. Only
|
||||
# after rendering does it become a second, destructive statement.
|
||||
mock_tp.process_template.return_value = (
|
||||
"SELECT 1;\n-- \nDROP TABLE important_table;"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"superset.jinja_context.get_template_processor",
|
||||
return_value=mock_tp,
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"execute_sql",
|
||||
{
|
||||
"request": {
|
||||
"database_id": 1,
|
||||
"sql": (
|
||||
'SELECT 1 -- {{ "\\nDROP TABLE important_table; --" }}'
|
||||
),
|
||||
"template_params": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
data = result.structured_content
|
||||
assert data["success"] is False
|
||||
assert "Destructive DDL" in data["error"]
|
||||
mock_tp.process_template.assert_called_once()
|
||||
ddl_mocks.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_allowed(self, ddl_mocks, mcp_server):
|
||||
"""SELECT queries pass through the DDL check."""
|
||||
|
||||
@@ -141,9 +141,9 @@ async def test_find_users_returns_matches(mcp_server):
|
||||
# required for filter resolution. Catch regressions on the response shape.
|
||||
for forbidden in ("email", "active", "roles"):
|
||||
assert forbidden not in data["users"][0]
|
||||
# or_ should have been built across the four matched columns
|
||||
# or_ should have been built across the three matched columns (no email)
|
||||
assert mock_or.called
|
||||
assert len(mock_or.call_args.args) == 4
|
||||
assert len(mock_or.call_args.args) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -201,6 +201,19 @@ def test_find_users_request_strips_query_whitespace():
|
||||
assert request.query == "maxime"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
["victim@example.com", "Victim@Example.COM", "a.b+tag@sub.example.co"],
|
||||
)
|
||||
def test_find_users_request_rejects_email_shaped_query(email):
|
||||
# Usernames are frequently email addresses under OAuth provisioning, so
|
||||
# an email-shaped query must be rejected outright rather than relying on
|
||||
# the email-column exclusion alone -- otherwise it still confirms an
|
||||
# account's existence via the username column.
|
||||
with pytest.raises(ValidationError):
|
||||
FindUsersRequest(query=email)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter contract: created_by_fk / changed_by_fk filtering on list tools
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -336,3 +349,51 @@ async def test_find_users_escapes_literal_backslash(mcp_server):
|
||||
assert ilike_call is not None
|
||||
assert ilike_call.args[0] == "%\\\\%"
|
||||
assert ilike_call.kwargs.get("escape") == "\\"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_users_does_not_match_on_email(mcp_server):
|
||||
"""Email must not be a searchable column: substring or exact email
|
||||
matching would let any MCP credential confirm which addresses have
|
||||
accounts (an email-disclosure oracle the web API reserves for admins).
|
||||
Uses a non-email-shaped query so this exercises the OR-clause contract
|
||||
itself, independent of the email-shape rejection covered below."""
|
||||
session, _ = _patch_user_query([])
|
||||
|
||||
with (
|
||||
patch.object(find_users_module, "db") as mock_db,
|
||||
patch.object(find_users_module, "security_manager") as mock_sm,
|
||||
patch.object(find_users_module, "or_") as mock_or,
|
||||
):
|
||||
mock_db.session = session
|
||||
user_model = MagicMock()
|
||||
mock_sm.user_model = user_model
|
||||
mock_or.return_value = MagicMock()
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool("find_users", {"request": {"query": "victim"}})
|
||||
|
||||
assert user_model.username.ilike.called
|
||||
assert user_model.first_name.ilike.called
|
||||
assert user_model.last_name.ilike.called
|
||||
assert not user_model.email.ilike.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_users_rejects_email_shaped_query_via_client(mcp_server):
|
||||
"""An email-shaped query is rejected before it ever reaches the DB
|
||||
filter: since usernames are frequently email addresses under OAuth
|
||||
provisioning, letting it fall through to the username column would
|
||||
still let an email lookup confirm whether an address has an account,
|
||||
defeating the documented non-enumeration guarantee."""
|
||||
with (
|
||||
patch.object(find_users_module, "db") as mock_db,
|
||||
patch.object(find_users_module, "security_manager"),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool(
|
||||
"find_users", {"request": {"query": "victim@example.com"}}
|
||||
)
|
||||
|
||||
mock_db.session.query.assert_not_called()
|
||||
|
||||
@@ -233,6 +233,36 @@ def test_raises_when_no_auth_source(app) -> None:
|
||||
get_user_from_request()
|
||||
|
||||
|
||||
def test_rejected_guest_token_does_not_fall_through_to_dev_username(app) -> None:
|
||||
"""A guest-marked token rejected for disabled guest auth must not degrade
|
||||
to a weaker auth source (MCP_DEV_USERNAME here) via get_user_from_request.
|
||||
|
||||
Before the fix, _resolve_user_from_jwt_context returned None for this
|
||||
case, which get_user_from_request treats identically to "no token
|
||||
present" and falls through to the next priority source -- silently
|
||||
executing the caller as MCP_DEV_USERNAME in a JWT-only deployment with a
|
||||
dev username configured.
|
||||
"""
|
||||
from superset.mcp_service.guest_token_verifier import GUEST_TOKEN_CLAIM
|
||||
|
||||
token = MagicMock()
|
||||
token.claims = {GUEST_TOKEN_CLAIM: True, "sub": "attacker"}
|
||||
token.client_id = "guest"
|
||||
|
||||
with app.app_context():
|
||||
app.config["MCP_DEV_USERNAME"] = "dev_admin"
|
||||
app.config["MCP_EMBEDDED_GUEST_AUTH_ENABLED"] = False
|
||||
try:
|
||||
with patch(
|
||||
"fastmcp.server.dependencies.get_access_token", return_value=token
|
||||
):
|
||||
with pytest.raises(ValueError, match="Guest-marked token"):
|
||||
get_user_from_request()
|
||||
finally:
|
||||
app.config.pop("MCP_DEV_USERNAME", None)
|
||||
app.config.pop("MCP_EMBEDDED_GUEST_AUTH_ENABLED", None)
|
||||
|
||||
|
||||
def test_no_auth_source_error_message_has_no_config_details(app) -> None:
|
||||
"""Client-facing auth error must be generic — no server config disclosed.
|
||||
|
||||
|
||||
@@ -575,7 +575,13 @@ def test_resolve_rejects_guest_marker_when_guest_auth_disabled(
|
||||
app: SupersetApp,
|
||||
) -> None:
|
||||
"""Even with the marker + client_id, a token is not treated as a guest when
|
||||
embedded guest auth is disabled (defense against marker forgery)."""
|
||||
embedded guest auth is disabled (defense against marker forgery).
|
||||
|
||||
A guest-marked token is an explicit, rejected authentication attempt, not
|
||||
an absent one, so it must fail closed (raise) rather than return None and
|
||||
let the caller fall through to a weaker auth source (API key,
|
||||
MCP_DEV_USERNAME, or a middleware-set g.user).
|
||||
"""
|
||||
token = MagicMock()
|
||||
token.claims = {GUEST_TOKEN_CLAIM: True, **_parsed_guest_claims()}
|
||||
token.client_id = "guest"
|
||||
@@ -586,15 +592,15 @@ def test_resolve_rejects_guest_marker_when_guest_auth_disabled(
|
||||
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
||||
patch("superset.mcp_service.auth.is_feature_enabled", return_value=True),
|
||||
):
|
||||
result = _resolve_user_from_jwt_context(app)
|
||||
|
||||
assert result is None
|
||||
with pytest.raises(ValueError, match="Guest-marked token"):
|
||||
_resolve_user_from_jwt_context(app)
|
||||
|
||||
|
||||
def test_resolve_rejects_guest_marker_when_embedded_flag_off(app: SupersetApp) -> None:
|
||||
"""The other half of the gate: with the marker + client_id + the MCP guest
|
||||
flag on, a token is still not treated as a guest when the EMBEDDED_SUPERSET
|
||||
feature flag is off (both gates are required)."""
|
||||
feature flag is off (both gates are required). Same fail-closed
|
||||
requirement as the sibling case above."""
|
||||
token = MagicMock()
|
||||
token.claims = {GUEST_TOKEN_CLAIM: True, **_parsed_guest_claims()}
|
||||
token.client_id = "guest"
|
||||
@@ -605,9 +611,8 @@ def test_resolve_rejects_guest_marker_when_embedded_flag_off(app: SupersetApp) -
|
||||
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
||||
patch("superset.mcp_service.auth.is_feature_enabled", return_value=False),
|
||||
):
|
||||
result = _resolve_user_from_jwt_context(app)
|
||||
|
||||
assert result is None
|
||||
with pytest.raises(ValueError, match="Guest-marked token"):
|
||||
_resolve_user_from_jwt_context(app)
|
||||
|
||||
|
||||
def test_resolve_ignores_guest_marker_without_guest_client_id(app: SupersetApp) -> None:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
"""Tests for the ``python -m superset.mcp_service`` entrypoint's non-stdio path.
|
||||
|
||||
Network transports (streamable-http, sse, ...) must install the same auth
|
||||
provider as the supported CLI path (``superset mcp run`` -> server.run_server()
|
||||
-> _create_auth_provider()) instead of starting with no verifier at all.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_main_installs_auth_provider_for_network_transport(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The non-stdio branch must call _create_auth_provider and pass its
|
||||
result into init_fastmcp_server, mirroring run_server()."""
|
||||
monkeypatch.setenv("FASTMCP_TRANSPORT", "sse")
|
||||
|
||||
from superset.mcp_service import __main__ as main_module
|
||||
|
||||
flask_app = MagicMock()
|
||||
auth_provider = object()
|
||||
mcp_instance = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.mcp_service.flask_singleton.get_flask_app",
|
||||
return_value=flask_app,
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.server._create_auth_provider",
|
||||
return_value=auth_provider,
|
||||
) as mock_create_auth_provider,
|
||||
patch.object(
|
||||
main_module, "init_fastmcp_server", return_value=mcp_instance
|
||||
) as mock_init,
|
||||
patch.object(main_module, "_add_default_middlewares"),
|
||||
patch.object(main_module.mcp, "run") as mock_run,
|
||||
):
|
||||
main_module.main()
|
||||
|
||||
mock_create_auth_provider.assert_called_once_with(flask_app)
|
||||
mock_init.assert_called_once_with(auth=auth_provider)
|
||||
mock_run.assert_called_once_with(transport="sse")
|
||||
|
||||
|
||||
def test_main_propagates_auth_config_error_for_network_transport(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A verifier-construction failure must abort startup, not fall through to
|
||||
an unauthenticated server.
|
||||
|
||||
Before the fix, the non-stdio branch never called _create_auth_provider
|
||||
at all, so MCP_AUTH_ENABLED was silently discarded by this entrypoint.
|
||||
"""
|
||||
monkeypatch.setenv("FASTMCP_TRANSPORT", "streamable-http")
|
||||
|
||||
from superset.mcp_service import __main__ as main_module
|
||||
from superset.mcp_service.mcp_config import MCPAuthConfigError
|
||||
|
||||
flask_app = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.mcp_service.flask_singleton.get_flask_app",
|
||||
return_value=flask_app,
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.server._create_auth_provider",
|
||||
side_effect=MCPAuthConfigError("bad auth config"),
|
||||
),
|
||||
patch.object(main_module, "init_fastmcp_server") as mock_init,
|
||||
patch.object(main_module, "_add_default_middlewares"),
|
||||
patch.object(main_module.mcp, "run") as mock_run,
|
||||
):
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
main_module.main()
|
||||
|
||||
mock_init.assert_not_called()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
def test_add_default_middlewares_installs_response_caching() -> None:
|
||||
"""``_add_default_middlewares`` must install response caching when
|
||||
configured, matching ``run_server()``'s default (non-factory) path --
|
||||
otherwise MCP_CACHE_CONFIG has no effect for this entrypoint's transports.
|
||||
"""
|
||||
from superset.mcp_service import __main__ as main_module
|
||||
|
||||
caching_middleware = object()
|
||||
|
||||
with (
|
||||
patch.object(main_module, "build_middleware_list", return_value=[]),
|
||||
patch.object(
|
||||
main_module, "create_response_size_guard_middleware", return_value=None
|
||||
),
|
||||
patch.object(
|
||||
main_module,
|
||||
"create_response_caching_middleware",
|
||||
return_value=caching_middleware,
|
||||
),
|
||||
patch.object(main_module.mcp, "add_middleware") as mock_add_middleware,
|
||||
):
|
||||
main_module._add_default_middlewares()
|
||||
|
||||
mock_add_middleware.assert_called_once_with(caching_middleware)
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.caching import (
|
||||
_build_caching_settings,
|
||||
_version_cache_prefix,
|
||||
@@ -106,7 +108,11 @@ def test_create_response_caching_middleware_falls_back_to_memory_when_no_prefix(
|
||||
"""Caching middleware uses in-memory store when CACHE_KEY_PREFIX is not set."""
|
||||
mock_flask_app = MagicMock()
|
||||
mock_configs = {
|
||||
"MCP_CACHE_CONFIG": {"enabled": True, "list_tools_ttl": 300},
|
||||
"MCP_CACHE_CONFIG": {
|
||||
"enabled": True,
|
||||
"dangerously_share_cache_across_principals": True,
|
||||
"list_tools_ttl": 300,
|
||||
},
|
||||
"MCP_STORE_CONFIG": {"enabled": True}, # Store enabled but no CACHE_KEY_PREFIX
|
||||
}
|
||||
mock_flask_app.config.get.side_effect = lambda key, default=None: mock_configs.get(
|
||||
@@ -141,7 +147,11 @@ def test_create_response_caching_middleware_uses_memory_store_when_store_disable
|
||||
"""Caching middleware uses in-memory store when MCP_STORE_CONFIG is disabled."""
|
||||
mock_flask_app = MagicMock()
|
||||
mock_configs = {
|
||||
"MCP_CACHE_CONFIG": {"enabled": True, "list_tools_ttl": 300},
|
||||
"MCP_CACHE_CONFIG": {
|
||||
"enabled": True,
|
||||
"dangerously_share_cache_across_principals": True,
|
||||
"list_tools_ttl": 300,
|
||||
},
|
||||
"MCP_STORE_CONFIG": {"enabled": False},
|
||||
}
|
||||
mock_flask_app.config.get.side_effect = lambda key, default=None: mock_configs.get(
|
||||
@@ -177,6 +187,7 @@ def test_create_response_caching_middleware_creates_middleware():
|
||||
mock_flask_app = MagicMock()
|
||||
mock_flask_app.config.get.return_value = {
|
||||
"enabled": True,
|
||||
"dangerously_share_cache_across_principals": True,
|
||||
"CACHE_KEY_PREFIX": "mcp_cache_v1_",
|
||||
"list_tools_ttl": 300,
|
||||
}
|
||||
@@ -211,3 +222,62 @@ def test_create_response_caching_middleware_creates_middleware():
|
||||
call_kwargs = mock_middleware_class.call_args[1]
|
||||
assert call_kwargs["cache_storage"] is mock_store
|
||||
assert call_kwargs["list_tools_settings"] == {"ttl": 300}
|
||||
|
||||
|
||||
def test_create_response_caching_middleware_fails_closed_without_principal_optin():
|
||||
"""Enabling the cache without the explicit cross-principal opt-in is refused.
|
||||
|
||||
The cache key contains no principal and hits are served before any
|
||||
authorization runs, so a shared cache would replay one principal's
|
||||
responses to another (see create_response_caching_middleware).
|
||||
"""
|
||||
mock_flask_app = MagicMock()
|
||||
mock_configs = {
|
||||
"MCP_CACHE_CONFIG": {"enabled": True, "call_tool_ttl": 3600},
|
||||
"MCP_STORE_CONFIG": {"enabled": False},
|
||||
}
|
||||
mock_flask_app.config.get.side_effect = lambda key, default=None: mock_configs.get(
|
||||
key, default
|
||||
)
|
||||
|
||||
with patch(
|
||||
"superset.mcp_service.flask_singleton.get_flask_app",
|
||||
return_value=mock_flask_app,
|
||||
):
|
||||
with patch("flask.has_app_context", return_value=True):
|
||||
from superset.mcp_service.caching import (
|
||||
create_response_caching_middleware,
|
||||
)
|
||||
|
||||
assert create_response_caching_middleware() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_excluded_tools_covers_every_mutating_tool():
|
||||
"""Every registered tool without readOnlyHint=True must be listed in
|
||||
MCP_CACHE_CONFIG["excluded_tools"].
|
||||
|
||||
Caching is keyed on tool name + arguments and served ahead of
|
||||
per-request auth/RBAC, so a mutating tool left off this list can
|
||||
silently replay a stale create/update/delete result to a caller who
|
||||
repeats an identical call expecting it to actually run again. This
|
||||
list is maintained by hand (FastMCP's caching middleware only accepts
|
||||
a static exclusion list); this test is what keeps it complete as new
|
||||
tools are added, by failing with the specific tool name(s) missing.
|
||||
"""
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.mcp_config import MCP_CACHE_CONFIG
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
mutating_tool_names = {
|
||||
tool.name
|
||||
for tool in tools
|
||||
if tool.annotations is None or tool.annotations.readOnlyHint is not True
|
||||
}
|
||||
|
||||
excluded = set(MCP_CACHE_CONFIG["excluded_tools"])
|
||||
missing = mutating_tool_names - excluded
|
||||
assert not missing, (
|
||||
f"These mutating tools are cacheable because they're missing from "
|
||||
f"MCP_CACHE_CONFIG['excluded_tools']: {sorted(missing)}"
|
||||
)
|
||||
|
||||
@@ -421,9 +421,12 @@ def test_create_default_mcp_auth_factory_jwt_with_keys():
|
||||
mock_build.assert_called_once()
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_jwt_enabled_without_keys_returns_none():
|
||||
"""MCP_AUTH_ENABLED=True with no keys/secret and no API key auth returns None."""
|
||||
from superset.mcp_service.mcp_config import create_default_mcp_auth_factory
|
||||
def test_create_default_mcp_auth_factory_jwt_enabled_without_keys_fails_closed():
|
||||
"""MCP_AUTH_ENABLED=True with no keys/secret and no fallback must abort."""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
@@ -433,16 +436,40 @@ def test_create_default_mcp_auth_factory_jwt_enabled_without_keys_returns_none()
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}.get(key, default)
|
||||
|
||||
with patch("superset.mcp_service.mcp_config.logger") as mock_logger:
|
||||
result = create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
assert result is None
|
||||
mock_logger.warning.assert_called_once()
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_jwt_build_failure_returns_none():
|
||||
"""A JWT verifier build failure with no API key fallback returns None."""
|
||||
from superset.mcp_service.mcp_config import create_default_mcp_auth_factory
|
||||
def test_create_default_mcp_auth_factory_jwt_missing_keys_fails_closed_with_api_key():
|
||||
"""A missing JWT key must abort startup even when API-key auth is also
|
||||
enabled: silently starting without the operator's requested JWT mode
|
||||
would leave JWT clients unable to authenticate with only a log line to
|
||||
show for it.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": True,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_PREFIXES": ["sst_"],
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}.get(key, default)
|
||||
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_jwt_build_failure_fails_closed():
|
||||
"""A JWT verifier build failure must abort startup, not disable auth."""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
@@ -460,12 +487,34 @@ def test_create_default_mcp_auth_factory_jwt_build_failure_returns_none():
|
||||
),
|
||||
patch("superset.mcp_service.mcp_config.logger") as mock_logger,
|
||||
):
|
||||
result = create_default_mcp_auth_factory(mock_app)
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_once()
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_refuses_dev_username_with_auth():
|
||||
"""MCP_DEV_USERNAME + MCP_AUTH_ENABLED is a standing auth bypass."""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
"MCP_JWT_ALGORITHM": "HS256",
|
||||
"MCP_DEV_USERNAME": "admin",
|
||||
}.get(key, default)
|
||||
|
||||
with pytest.raises(MCPAuthConfigError, match="MCP_DEV_USERNAME"):
|
||||
create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_requires_audience_when_jwt_enabled():
|
||||
"""MCP_AUTH_ENABLED=True without MCP_JWT_AUDIENCE fails closed.
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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.
|
||||
"""HS256 key-confusion guard: never key an HMAC verifier on public material."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
PEM_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMFkw...\n-----END PUBLIC KEY-----"
|
||||
|
||||
|
||||
def _mock_app(config: dict[str, Any]) -> MagicMock:
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: config.get(key, default)
|
||||
return mock_app
|
||||
|
||||
|
||||
def test_build_jwt_verifier_refuses_hs256_without_secret():
|
||||
"""HS256 pinned but only public-key material configured must hard-error.
|
||||
|
||||
Before the fix this silently built an HS256 verifier keyed on the PEM
|
||||
public key, letting anyone holding the (public) key forge admin tokens.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
_build_jwt_verifier,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
app = _mock_app(
|
||||
{
|
||||
"MCP_JWT_ALGORITHM": "HS256",
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}
|
||||
)
|
||||
with pytest.raises(MCPAuthConfigError, match="MCP_JWT_SECRET"):
|
||||
_build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=None,
|
||||
public_key=PEM_PUBLIC_KEY,
|
||||
secret=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("algorithm", ["HS256", "HS384", "HS512"])
|
||||
def test_build_jwt_verifier_refuses_hmac_alongside_public_key_material(
|
||||
algorithm: str,
|
||||
):
|
||||
"""HMAC algorithm plus leftover public key / JWKS config is refused."""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
_build_jwt_verifier,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
app = _mock_app(
|
||||
{
|
||||
"MCP_JWT_ALGORITHM": algorithm,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}
|
||||
)
|
||||
with pytest.raises(MCPAuthConfigError, match="MCP_JWT_PUBLIC_KEY"):
|
||||
_build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=None,
|
||||
public_key=PEM_PUBLIC_KEY,
|
||||
secret="shhh", # noqa: S106
|
||||
)
|
||||
|
||||
|
||||
def test_build_jwt_verifier_hs256_with_explicit_secret_still_works():
|
||||
"""A correct HS256 config (secret only) builds an HS256 verifier."""
|
||||
from superset.mcp_service import mcp_config
|
||||
|
||||
app = _mock_app(
|
||||
{
|
||||
"MCP_JWT_ALGORITHM": "HS256",
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}
|
||||
)
|
||||
with patch.object(mcp_config, "MCPJWTVerifier") as mock_verifier:
|
||||
mcp_config._build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=None,
|
||||
public_key=None,
|
||||
secret="shhh", # noqa: S106
|
||||
)
|
||||
kwargs = mock_verifier.call_args.kwargs
|
||||
assert kwargs["algorithm"] == "HS256"
|
||||
assert kwargs["public_key"] == "shhh"
|
||||
|
||||
|
||||
def test_build_jwt_verifier_refuses_rs256_secret_only():
|
||||
"""RS256 (asymmetric) pinned but only a secret configured must hard-error.
|
||||
|
||||
A keyless RS256 verifier cannot validate anything -- name the fix rather
|
||||
than letting the underlying verifier constructor raise opaquely.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
_build_jwt_verifier,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
app = _mock_app(
|
||||
{
|
||||
"MCP_JWT_ALGORITHM": "RS256",
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}
|
||||
)
|
||||
with pytest.raises(MCPAuthConfigError, match="MCP_JWT_ALGORITHM"):
|
||||
_build_jwt_verifier(
|
||||
app=app,
|
||||
jwks_uri=None,
|
||||
public_key=None,
|
||||
secret="shhh", # noqa: S106
|
||||
)
|
||||
|
||||
|
||||
def test_auth_factory_propagates_hs256_config_error():
|
||||
"""The factory must not swallow the misconfiguration into a None provider."""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
app = _mock_app(
|
||||
{
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
"MCP_JWT_ALGORITHM": "HS256",
|
||||
"MCP_JWT_PUBLIC_KEY": PEM_PUBLIC_KEY,
|
||||
}
|
||||
)
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
create_default_mcp_auth_factory(app)
|
||||
@@ -383,6 +383,55 @@ def test_create_auth_provider_fails_closed_on_insecure_guest_secret() -> None:
|
||||
_create_auth_provider(flask_app)
|
||||
|
||||
|
||||
def test_create_auth_provider_fails_closed_on_default_factory_error() -> None:
|
||||
"""A generic error while building the enabled auth provider must abort.
|
||||
|
||||
Verifier-construction failures (bad key material, config typos) used to be
|
||||
swallowed, silently starting an unauthenticated server.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import MCPAuthConfigError
|
||||
from superset.mcp_service.server import _create_auth_provider
|
||||
|
||||
flask_app = MagicMock()
|
||||
flask_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_FACTORY": None,
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
}.get(key, default)
|
||||
|
||||
with patch(
|
||||
"superset.mcp_service.mcp_config.create_default_mcp_auth_factory",
|
||||
side_effect=ValueError("bad PEM"),
|
||||
):
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
_create_auth_provider(flask_app)
|
||||
|
||||
|
||||
def test_create_auth_provider_fails_closed_on_custom_factory_error() -> None:
|
||||
"""MCP_AUTH_FACTORY raising (or yielding None) must abort startup."""
|
||||
from superset.mcp_service.mcp_config import MCPAuthConfigError
|
||||
from superset.mcp_service.server import _create_auth_provider
|
||||
|
||||
def broken_factory(app: Any) -> Any:
|
||||
raise ValueError("bad key material")
|
||||
|
||||
flask_app = MagicMock()
|
||||
flask_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_FACTORY": broken_factory,
|
||||
}.get(key, default)
|
||||
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
_create_auth_provider(flask_app)
|
||||
|
||||
flask_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_FACTORY": lambda app: None,
|
||||
}.get(key, default)
|
||||
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
_create_auth_provider(flask_app)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _run_server_dependencies(
|
||||
flask_config: dict[str, Any],
|
||||
|
||||
@@ -39,6 +39,7 @@ from superset.mcp_service.constants import DEFAULT_MAX_LIST_ITEMS
|
||||
from superset.mcp_service.mcp_config import MCP_RESPONSE_SIZE_CONFIG
|
||||
from superset.mcp_service.middleware import (
|
||||
_is_user_error,
|
||||
_sanitize_params,
|
||||
create_response_size_guard_middleware,
|
||||
GlobalErrorHandlerMiddleware,
|
||||
RBACToolVisibilityMiddleware,
|
||||
@@ -1381,6 +1382,106 @@ class TestGlobalErrorHandlerLogLevels:
|
||||
# Should log at ERROR (both the classification log and the error_id log)
|
||||
assert mock_logger.error.call_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_value_error_message_is_sanitized(self) -> None:
|
||||
"""A ValueError's text reaches the client through _sanitize_error_for_logging.
|
||||
|
||||
ValueError is deliberately not in that sanitizer's generic-message
|
||||
list (so LLM callers still get parameter feedback), but a connection
|
||||
string embedded in the message must still be redacted, not returned
|
||||
verbatim.
|
||||
"""
|
||||
middleware = GlobalErrorHandlerMiddleware()
|
||||
|
||||
context = MagicMock()
|
||||
context.message.name = "execute_sql"
|
||||
context.method = "tools/call"
|
||||
|
||||
call_next = AsyncMock(
|
||||
side_effect=ValueError(
|
||||
"Invalid config: postgresql://admin:hunter2@db.internal/prod"
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
|
||||
patch("superset.mcp_service.middleware.event_logger"),
|
||||
patch("superset.mcp_service.middleware.logger"),
|
||||
):
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await middleware.on_message(context, call_next)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "hunter2" not in message
|
||||
assert "db.internal" not in message
|
||||
assert "Invalid config" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_exception_detail_is_sanitized(self) -> None:
|
||||
"""HTTPException.detail reaches the client through
|
||||
_sanitize_error_for_logging instead of being interpolated raw."""
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
middleware = GlobalErrorHandlerMiddleware()
|
||||
|
||||
context = MagicMock()
|
||||
context.message.name = "get_chart_preview"
|
||||
context.method = "tools/call"
|
||||
|
||||
call_next = AsyncMock(
|
||||
side_effect=HTTPException(
|
||||
status_code=502,
|
||||
detail="Upstream failed: postgresql://admin:hunter2@db.internal/prod",
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
|
||||
patch("superset.mcp_service.middleware.event_logger"),
|
||||
patch("superset.mcp_service.middleware.logger"),
|
||||
):
|
||||
with pytest.raises(ToolError) as exc_info:
|
||||
await middleware.on_message(context, call_next)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "hunter2" not in message
|
||||
assert "db.internal" not in message
|
||||
assert "Upstream failed" in message
|
||||
|
||||
|
||||
class TestSanitizeParams:
|
||||
"""Tests for _sanitize_params's recursion into nested containers."""
|
||||
|
||||
def test_redacts_top_level_sensitive_key(self) -> None:
|
||||
result = _sanitize_params({"password": "hunter2", "name": "alice"})
|
||||
assert result["password"] == "[REDACTED]" # noqa: S105
|
||||
assert result["name"] == "alice"
|
||||
|
||||
def test_redacts_sensitive_key_nested_under_arguments(self) -> None:
|
||||
result = _sanitize_params({"arguments": {"password": "hunter2"}})
|
||||
assert result["arguments"]["password"] == "[REDACTED]" # noqa: S105
|
||||
|
||||
def test_redacts_sensitive_key_nested_under_request(self) -> None:
|
||||
"""Any nested dict wrapper is redacted, not just the literal
|
||||
'arguments' key -- Pydantic-request tools wrap params under 'request'."""
|
||||
result = _sanitize_params({"request": {"password": "hunter2"}})
|
||||
assert result["request"]["password"] == "[REDACTED]" # noqa: S105
|
||||
|
||||
def test_redacts_sensitive_key_inside_list_of_dicts(self) -> None:
|
||||
result = _sanitize_params({"items": [{"token": "abc123"}, {"name": "x"}]})
|
||||
assert result["items"][0]["token"] == "[REDACTED]" # noqa: S105
|
||||
assert result["items"][1]["name"] == "x"
|
||||
|
||||
def test_redacts_sensitive_key_inside_nested_list_of_lists(self) -> None:
|
||||
"""A list nested inside another list must still be recursed into,
|
||||
not copied unchanged -- otherwise a sensitive key inside it would
|
||||
reach the audit log unredacted."""
|
||||
result = _sanitize_params({"items": [[{"password": "hunter2"}]]})
|
||||
assert result["items"][0][0]["password"] == "[REDACTED]" # noqa: S105
|
||||
|
||||
def test_non_dict_passthrough(self) -> None:
|
||||
assert _sanitize_params("not-a-dict") == "not-a-dict" # type: ignore[arg-type]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_logger_includes_severity(self) -> None:
|
||||
"""Event logger payload should include severity field."""
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
# 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.
|
||||
"""Regression guards for dataset "Hours offset" bound and grain handling.
|
||||
|
||||
Two independent defects are covered here.
|
||||
|
||||
Defect 1 -- DATE-column filter bounds use a whole-day effective offset so rendering
|
||||
date-only literals cannot discard a sub-day remainder and move the window.
|
||||
|
||||
Defect 2 -- grained axis expressions apply the dataset offset in SQL before time
|
||||
grain truncation. Dataframe normalization suppresses its legacy post-query
|
||||
offset only for labels that were shifted in SQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy import column, create_engine, DateTime
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
||||
from superset.db_engine_specs.base import BaseEngineSpec
|
||||
from superset.db_engine_specs.postgres import PostgresEngineSpec
|
||||
from superset.db_engine_specs.sqlite import SqliteEngineSpec
|
||||
from superset.models.core import Database
|
||||
from superset.superset_typing import AdhocColumn, QueryObjectDict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defect 1 -- DATE-column filter bound literal truncated to day precision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pg_dataset(offset: int, col_type: str) -> SqlaTable:
|
||||
"""A Postgres-backed dataset with a single temporal column of ``col_type``
|
||||
and the given dataset Hours ``offset``."""
|
||||
database = Database(
|
||||
id=1,
|
||||
database_name="pg",
|
||||
# A postgres:// URI selects PostgresEngineSpec; the SQL is only compiled,
|
||||
# never executed, so no live server is required.
|
||||
sqlalchemy_uri="postgresql://u:p@localhost:5432/db",
|
||||
)
|
||||
columns = [
|
||||
TableColumn(column_name="loan_date", is_dttm=1, type=col_type),
|
||||
TableColumn(column_name="value", type="INTEGER"),
|
||||
]
|
||||
return SqlaTable(
|
||||
table_name="loans",
|
||||
columns=columns,
|
||||
main_dttm_col="loan_date",
|
||||
database=database,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _generated_sql(dataset: SqlaTable, mocker: MockerFixture, app: Flask) -> str:
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.security_manager.get_guest_rls_filters",
|
||||
return_value=[],
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.security_manager.is_guest_user",
|
||||
return_value=False,
|
||||
)
|
||||
# Requested window: the whole month of August 2026, i.e. [2026-08-01, 2026-09-01).
|
||||
query_obj: QueryObjectDict = {
|
||||
"granularity": "loan_date",
|
||||
"from_dttm": datetime(2026, 8, 1),
|
||||
"to_dttm": datetime(2026, 9, 1),
|
||||
"is_timeseries": False,
|
||||
"filter": [
|
||||
{
|
||||
"col": "loan_date",
|
||||
"op": "TEMPORAL_RANGE",
|
||||
"val": "2026-08-01 : 2026-09-01",
|
||||
}
|
||||
],
|
||||
"metrics": [],
|
||||
"columns": ["value"],
|
||||
}
|
||||
with app.test_request_context():
|
||||
return dataset.get_query_str_extended(query_obj, mutate=False).sql
|
||||
|
||||
|
||||
def test_date_column_hours_offset_does_not_shift_selected_day_window(
|
||||
mocker: MockerFixture, app: Flask
|
||||
) -> None:
|
||||
"""A pure ``DATE`` column stores calendar dates at midnight, so a +1h Hours
|
||||
offset can never move a value across a day boundary: the selected window must
|
||||
stay 2026-08-01 .. 2026-08-31 (identical to offset 0).
|
||||
|
||||
The bug shifts the bounds back 1h (2026-07-31 23:00 / 2026-08-31 23:00) and
|
||||
then truncates each with ``.date()`` -> ``TO_DATE('2026-07-31')`` /
|
||||
``TO_DATE('2026-08-31')``. That window, [2026-07-31, 2026-08-31), admits the
|
||||
out-of-range day 2026-07-31 and silently drops the last requested day,
|
||||
2026-08-31.
|
||||
"""
|
||||
sql = _generated_sql(_pg_dataset(1, "DATE"), mocker, app)
|
||||
|
||||
assert ">= TO_DATE('2026-08-01'" in sql, sql
|
||||
assert "< TO_DATE('2026-09-01'" in sql, sql
|
||||
# The lower bound must not admit the day before the requested range.
|
||||
assert ">= TO_DATE('2026-07-31'" not in sql, (
|
||||
f"DATE-column +1h offset admits out-of-range day 2026-07-31; SQL was:\n{sql}"
|
||||
)
|
||||
# The upper bound must not drop the last requested day (2026-08-31).
|
||||
assert "< TO_DATE('2026-08-31'" not in sql, (
|
||||
f"DATE-column +1h offset drops last requested day 2026-08-31; SQL was:\n{sql}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offset", "expected_start", "expected_end"),
|
||||
[
|
||||
(0, "2026-08-01", "2026-09-01"),
|
||||
(1, "2026-08-01", "2026-09-01"),
|
||||
(24, "2026-07-31", "2026-08-31"),
|
||||
(25, "2026-07-31", "2026-08-31"),
|
||||
(-1, "2026-08-01", "2026-09-01"),
|
||||
(-25, "2026-08-02", "2026-09-02"),
|
||||
],
|
||||
)
|
||||
def test_date_column_hours_offset_uses_whole_day_bounds(
|
||||
offset: int,
|
||||
expected_start: str,
|
||||
expected_end: str,
|
||||
mocker: MockerFixture,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
"""DATE bounds discard sub-day remainders symmetrically around zero."""
|
||||
sql = _generated_sql(_pg_dataset(offset, "DATE"), mocker, app)
|
||||
|
||||
assert f">= TO_DATE('{expected_start}'" in sql, sql
|
||||
assert f"< TO_DATE('{expected_end}'" in sql, sql
|
||||
|
||||
|
||||
def test_timestamp_column_hours_offset_preserves_exact_hour_bounds(
|
||||
mocker: MockerFixture, app: Flask
|
||||
) -> None:
|
||||
"""Control for Defect 1: the same +1h offset on a ``TIMESTAMP`` column keeps
|
||||
exact-hour precision (2026-07-31 23:00:00 / 2026-08-31 23:00:00) and loses
|
||||
nothing. This passes today and documents that the defect is DATE-specific."""
|
||||
sql = _generated_sql(_pg_dataset(1, "TIMESTAMP"), mocker, app)
|
||||
|
||||
assert "2026-07-31 23:00:00" in sql, sql
|
||||
assert "2026-08-31 23:00:00" in sql, sql
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offset", "expected_start", "expected_end"),
|
||||
[
|
||||
(0, "2026-08-01 00:00:00", "2026-09-01 00:00:00"),
|
||||
(1, "2026-07-31 23:00:00", "2026-08-31 23:00:00"),
|
||||
(24, "2026-07-31 00:00:00", "2026-08-31 00:00:00"),
|
||||
(25, "2026-07-30 23:00:00", "2026-08-30 23:00:00"),
|
||||
(-1, "2026-08-01 01:00:00", "2026-09-01 01:00:00"),
|
||||
(-25, "2026-08-02 01:00:00", "2026-09-02 01:00:00"),
|
||||
],
|
||||
)
|
||||
def test_timestamp_column_hours_offset_uses_exact_hour_bounds(
|
||||
offset: int,
|
||||
expected_start: str,
|
||||
expected_end: str,
|
||||
mocker: MockerFixture,
|
||||
app: Flask,
|
||||
) -> None:
|
||||
"""Timestamp bounds preserve every configured offset hour."""
|
||||
sql = _generated_sql(_pg_dataset(offset, "TIMESTAMP"), mocker, app)
|
||||
|
||||
assert expected_start in sql, sql
|
||||
assert expected_end in sql, sql
|
||||
|
||||
|
||||
def test_datetime_named_column_keeps_exact_hour_bounds(
|
||||
mocker: MockerFixture, app: Flask
|
||||
) -> None:
|
||||
"""A DATETIME type name must not be mistaken for a pure DATE type."""
|
||||
sql = _generated_sql(_pg_dataset(1, "DATETIME"), mocker, app)
|
||||
|
||||
assert "2026-07-31 23:00:00" in sql, sql
|
||||
assert "2026-08-31 23:00:00" in sql, sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defect 2 -- Hours offset applied after DB-side time-grain truncation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sqlite_dataset(
|
||||
mocker: MockerFixture,
|
||||
offset: int,
|
||||
column_type: str,
|
||||
rows: list[str],
|
||||
) -> tuple[SqlaTable, Engine]:
|
||||
"""Build an executable SQLite dataset with controlled temporal rows."""
|
||||
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(f"CREATE TABLE events (ts {column_type}, val INTEGER)")
|
||||
connection.executemany(
|
||||
"INSERT INTO events VALUES (?, 1)",
|
||||
[(row,) for row in rows],
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
@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)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.security_manager.get_guest_rls_filters",
|
||||
return_value=[],
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.security_manager.is_guest_user",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
return (
|
||||
SqlaTable(
|
||||
database=database,
|
||||
schema=None,
|
||||
table_name="events",
|
||||
main_dttm_col="ts",
|
||||
offset=offset,
|
||||
columns=[
|
||||
TableColumn(column_name="ts", is_dttm=True, type=column_type),
|
||||
TableColumn(column_name="val", type="INTEGER"),
|
||||
],
|
||||
),
|
||||
engine,
|
||||
)
|
||||
|
||||
|
||||
def _physical_axis_query(table: SqlaTable, time_grain: str | None) -> QueryObject:
|
||||
"""Build the physical-axis query shape used by legacy time-series charts."""
|
||||
return QueryObject(
|
||||
datasource=table,
|
||||
metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)", "label": "ct"}],
|
||||
columns=[],
|
||||
granularity="ts",
|
||||
from_dttm=pd.Timestamp("2026-07-01"),
|
||||
to_dttm=pd.Timestamp("2026-10-01"),
|
||||
is_timeseries=True,
|
||||
extras={"time_grain_sqla": time_grain} if time_grain else {},
|
||||
row_limit=100,
|
||||
)
|
||||
|
||||
|
||||
_EXPECTED_PHYSICAL_AXIS_TIMESTAMPS = {
|
||||
("TIMESTAMP", None): {
|
||||
0: "2026-08-01 23:30:00",
|
||||
1: "2026-08-02 00:30:00",
|
||||
24: "2026-08-02 23:30:00",
|
||||
25: "2026-08-03 00:30:00",
|
||||
-1: "2026-08-01 22:30:00",
|
||||
-25: "2026-07-31 22:30:00",
|
||||
},
|
||||
("TIMESTAMP", "P1D"): {
|
||||
0: "2026-08-01 00:00:00",
|
||||
1: "2026-08-02 00:00:00",
|
||||
24: "2026-08-02 00:00:00",
|
||||
25: "2026-08-03 00:00:00",
|
||||
-1: "2026-08-01 00:00:00",
|
||||
-25: "2026-07-31 00:00:00",
|
||||
},
|
||||
("DATE", None): {
|
||||
0: "2026-08-02 00:00:00",
|
||||
1: "2026-08-02 01:00:00",
|
||||
24: "2026-08-03 00:00:00",
|
||||
25: "2026-08-03 01:00:00",
|
||||
-1: "2026-08-01 23:00:00",
|
||||
-25: "2026-07-31 23:00:00",
|
||||
},
|
||||
("DATE", "P1D"): {
|
||||
0: "2026-08-02 00:00:00",
|
||||
1: "2026-08-02 00:00:00",
|
||||
24: "2026-08-03 00:00:00",
|
||||
25: "2026-08-03 00:00:00",
|
||||
-1: "2026-08-02 00:00:00",
|
||||
-25: "2026-08-01 00:00:00",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column_type", ["DATE", "TIMESTAMP"])
|
||||
@pytest.mark.parametrize("time_grain", [None, "P1D"])
|
||||
@pytest.mark.parametrize("offset", [0, 1, 24, 25, -1, -25])
|
||||
def test_physical_axis_offset_matrix(
|
||||
column_type: str,
|
||||
time_grain: str | None,
|
||||
offset: int,
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Physical axes apply each offset once at the precision of their grain."""
|
||||
raw_value = "2026-08-02" if column_type == "DATE" else "2026-08-01 23:30:00"
|
||||
table, _engine = _sqlite_dataset(mocker, offset, column_type, [raw_value])
|
||||
|
||||
result = table.get_query_result(_physical_axis_query(table, time_grain))
|
||||
|
||||
assert result.df["__timestamp"].tolist() == [
|
||||
pd.Timestamp(
|
||||
_EXPECTED_PHYSICAL_AXIS_TIMESTAMPS[(column_type, time_grain)][offset]
|
||||
)
|
||||
]
|
||||
expected_shifted_labels = {"__timestamp"} if time_grain and offset else set()
|
||||
assert result.sql_shifted_temporal_labels == expected_shifted_labels
|
||||
|
||||
|
||||
def test_ungrained_physical_axis_offset_is_applied_exactly_once(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""An ungrained axis stays on the established pandas-only offset path."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=["2026-08-01 23:30:00"],
|
||||
)
|
||||
|
||||
result = table.get_query_result(_physical_axis_query(table, time_grain=None))
|
||||
|
||||
assert result.df["__timestamp"].tolist() == [pd.Timestamp("2026-08-02 00:30:00")]
|
||||
assert result.sql_shifted_temporal_labels == set()
|
||||
|
||||
|
||||
def test_negative_subday_date_offset_does_not_move_grained_bucket(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A negative sub-day offset on a DATE grain quantizes to zero days."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=-1,
|
||||
column_type="DATE",
|
||||
rows=["2026-08-02"],
|
||||
)
|
||||
|
||||
result = table.get_query_result(_physical_axis_query(table, time_grain="P1D"))
|
||||
|
||||
assert result.df["__timestamp"].tolist() == [pd.Timestamp("2026-08-02 00:00:00")]
|
||||
assert result.sql_shifted_temporal_labels == {"__timestamp"}
|
||||
assert "+0 hours" not in result.query
|
||||
|
||||
|
||||
def test_adhoc_base_axis_offset_is_applied_exactly_once(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-timeseries BASE_AXIS query shifts before its embedded grain."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=["2026-08-01 23:30:00", "2026-08-02 10:00:00"],
|
||||
)
|
||||
base_axis: AdhocColumn = {
|
||||
"sqlExpression": "ts",
|
||||
"label": "ts",
|
||||
"isColumnReference": True,
|
||||
"columnType": "BASE_AXIS",
|
||||
"timeGrain": "P1D",
|
||||
}
|
||||
query_object = QueryObject(
|
||||
datasource=table,
|
||||
metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)", "label": "ct"}],
|
||||
columns=[base_axis],
|
||||
granularity=None,
|
||||
is_timeseries=False,
|
||||
extras={},
|
||||
row_limit=100,
|
||||
)
|
||||
|
||||
result = table.get_query_result(query_object)
|
||||
|
||||
assert result.df["ts"].tolist() == [pd.Timestamp("2026-08-02 00:00:00")]
|
||||
assert result.df["ct"].tolist() == [2]
|
||||
assert result.sql_shifted_temporal_labels == {"ts"}
|
||||
|
||||
|
||||
def test_adhoc_axis_without_temporal_shift_capability_uses_pandas_fallback(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""An ungated engine leaves an adhoc axis shift to pandas."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=["2026-08-01 23:30:00", "2026-08-02 10:00:00"],
|
||||
)
|
||||
mocker.patch.object(SqliteEngineSpec, "supports_temporal_column_shift", False)
|
||||
base_axis: AdhocColumn = {
|
||||
"sqlExpression": "ts",
|
||||
"label": "ts",
|
||||
"isColumnReference": True,
|
||||
"columnType": "BASE_AXIS",
|
||||
"timeGrain": "P1D",
|
||||
}
|
||||
query_object = QueryObject(
|
||||
datasource=table,
|
||||
metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)", "label": "ct"}],
|
||||
columns=[base_axis],
|
||||
granularity=None,
|
||||
is_timeseries=False,
|
||||
extras={},
|
||||
row_limit=100,
|
||||
)
|
||||
|
||||
result = table.get_query_result(query_object)
|
||||
|
||||
assert set(result.df["ts"]) == {
|
||||
pd.Timestamp("2026-08-01 01:00:00"),
|
||||
pd.Timestamp("2026-08-02 01:00:00"),
|
||||
}
|
||||
assert result.df["ct"].tolist() == [1, 1]
|
||||
assert result.sql_shifted_temporal_labels == set()
|
||||
assert "+1 hours" not in result.query
|
||||
assert "DATETIME(DATETIME(ts" not in result.query
|
||||
|
||||
|
||||
def test_adhoc_base_axis_probe_quantizes_date_offset(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A probed DATE expression quantizes a sub-day offset to zero hours."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=-1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=["2026-08-02"],
|
||||
)
|
||||
probe = mocker.patch(
|
||||
"superset.connectors.sqla.models.get_columns_description",
|
||||
return_value=[{"is_dttm": True, "type": "DATE"}],
|
||||
)
|
||||
base_axis: AdhocColumn = {
|
||||
"sqlExpression": "DATE(ts)",
|
||||
"label": "ts",
|
||||
"isColumnReference": False,
|
||||
"columnType": "BASE_AXIS",
|
||||
"timeGrain": "P1D",
|
||||
}
|
||||
query_object = QueryObject(
|
||||
datasource=table,
|
||||
metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)", "label": "ct"}],
|
||||
columns=[base_axis],
|
||||
granularity=None,
|
||||
is_timeseries=False,
|
||||
extras={},
|
||||
row_limit=100,
|
||||
)
|
||||
|
||||
result = table.get_query_result(query_object)
|
||||
|
||||
probe.assert_called()
|
||||
assert result.df["ts"].tolist() == [pd.Timestamp("2026-08-02 00:00:00")]
|
||||
assert result.sql_shifted_temporal_labels == {"ts"}
|
||||
assert "-1 hours" not in result.query
|
||||
assert "DATETIME(DATETIME(DATE(ts)" not in result.query
|
||||
|
||||
|
||||
def test_engine_without_temporal_shift_capability_uses_pandas_fallback(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""An ungated engine leaves the axis unshifted and applies the offset in pandas."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=["2026-08-01 23:30:00"],
|
||||
)
|
||||
mocker.patch.object(SqliteEngineSpec, "supports_temporal_column_shift", False)
|
||||
|
||||
result = table.get_query_result(_physical_axis_query(table, time_grain="P1D"))
|
||||
|
||||
assert result.df["__timestamp"].tolist() == [pd.Timestamp("2026-08-01 01:00:00")]
|
||||
assert result.sql_shifted_temporal_labels == set()
|
||||
assert "+1 hours" not in result.query
|
||||
assert "DATETIME(DATETIME(ts" not in result.query
|
||||
|
||||
|
||||
def test_grained_physical_filter_sql_is_unchanged(
|
||||
mocker: MockerFixture, app: Flask
|
||||
) -> None:
|
||||
"""Physical grained filters keep their pre-existing unshifted expression."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=[],
|
||||
)
|
||||
query_object = QueryObject(
|
||||
datasource=table,
|
||||
columns=["val"],
|
||||
metrics=[],
|
||||
is_timeseries=False,
|
||||
filters=[
|
||||
{
|
||||
"col": "ts",
|
||||
"op": "TEMPORAL_RANGE",
|
||||
"val": "2026-08-02 : 2026-08-03",
|
||||
"grain": "P1D",
|
||||
}
|
||||
],
|
||||
)
|
||||
with app.test_request_context():
|
||||
query = table.get_query_str_extended(query_object.to_dict(), mutate=False)
|
||||
|
||||
assert query.sql == (
|
||||
"SELECT val AS val \n"
|
||||
"FROM events \n"
|
||||
"WHERE DATETIME(ts, 'start of day') >= '2026-08-01 23:00:00' "
|
||||
"AND DATETIME(ts, 'start of day') < '2026-08-02 23:00:00' GROUP BY val"
|
||||
)
|
||||
assert query.sql_shifted_temporal_labels == set()
|
||||
|
||||
|
||||
def test_grained_adhoc_filter_sql_is_unchanged(
|
||||
mocker: MockerFixture, app: Flask
|
||||
) -> None:
|
||||
"""A BASE_AXIS-shaped adhoc filter does not opt into the axis-only shift."""
|
||||
table, _engine = _sqlite_dataset(
|
||||
mocker,
|
||||
offset=1,
|
||||
column_type="TIMESTAMP",
|
||||
rows=[],
|
||||
)
|
||||
base_axis_filter: AdhocColumn = {
|
||||
"sqlExpression": "ts",
|
||||
"label": "ts",
|
||||
"isColumnReference": True,
|
||||
"columnType": "BASE_AXIS",
|
||||
"timeGrain": "P1D",
|
||||
}
|
||||
query_object = QueryObject(
|
||||
datasource=table,
|
||||
columns=["val"],
|
||||
metrics=[],
|
||||
is_timeseries=False,
|
||||
filters=[
|
||||
{
|
||||
"col": base_axis_filter,
|
||||
"op": "==",
|
||||
"val": "2026-08-02 00:00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
with app.test_request_context():
|
||||
query = table.get_query_str_extended(query_object.to_dict(), mutate=False)
|
||||
|
||||
assert query.sql == (
|
||||
"SELECT val AS val \n"
|
||||
"FROM events \n"
|
||||
"WHERE (DATETIME(ts, 'start of day')) = '2026-08-02 00:00:00' GROUP BY val"
|
||||
)
|
||||
assert query.sql_shifted_temporal_labels == set()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offset", "postgres_sql", "sqlite_sql"),
|
||||
[
|
||||
(1, "ts + INTERVAL '1' HOUR", "DATETIME(ts, '+1 hours')"),
|
||||
(-1, "ts + INTERVAL '-1' HOUR", "DATETIME(ts, '-1 hours')"),
|
||||
(24, "ts + INTERVAL '24' HOUR", "DATETIME(ts, '+24 hours')"),
|
||||
(-25, "ts + INTERVAL '-25' HOUR", "DATETIME(ts, '-25 hours')"),
|
||||
],
|
||||
)
|
||||
def test_temporal_column_shift_expression_compiles_for_supported_guard_dialects(
|
||||
offset: int,
|
||||
postgres_sql: str,
|
||||
sqlite_sql: str,
|
||||
) -> None:
|
||||
"""The bounded engine hook emits valid PostgreSQL and SQLite shift syntax."""
|
||||
source = column("ts", type_=DateTime())
|
||||
|
||||
postgres_shift = PostgresEngineSpec.get_temporal_column_shift_expr(source, offset)
|
||||
sqlite_shift = SqliteEngineSpec.get_temporal_column_shift_expr(source, offset)
|
||||
postgres_bucket = PostgresEngineSpec.get_timestamp_expr(postgres_shift, None, "P1D")
|
||||
sqlite_bucket = SqliteEngineSpec.get_timestamp_expr(sqlite_shift, None, "P1D")
|
||||
|
||||
assert BaseEngineSpec.supports_temporal_column_shift is False
|
||||
assert PostgresEngineSpec.supports_temporal_column_shift is True
|
||||
assert SqliteEngineSpec.supports_temporal_column_shift is True
|
||||
assert str(postgres_shift.compile(dialect=postgresql.dialect())) == postgres_sql
|
||||
assert str(sqlite_shift.compile(dialect=sqlite.dialect())) == sqlite_sql
|
||||
assert str(postgres_bucket.compile(dialect=postgresql.dialect())) == (
|
||||
f"DATE_TRUNC('day', {postgres_sql})"
|
||||
)
|
||||
assert str(sqlite_bucket.compile(dialect=sqlite.dialect())) == (
|
||||
f"DATETIME({sqlite_sql}, 'start of day')"
|
||||
)
|
||||
|
||||
|
||||
def test_hours_offset_is_applied_before_time_grain_truncation(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""A row near a day boundary must be bucketed by the grain using its
|
||||
offset-shifted (local) time, not its raw time.
|
||||
|
||||
Raw ``2026-08-01 23:30`` at a +1h dataset offset is locally ``2026-08-02
|
||||
00:30``; under a daily grain it belongs to 2026-08-02. The bug truncates the
|
||||
raw value to 2026-08-01 in the database and only then adds the offset in
|
||||
pandas, so the row is mislabeled as 2026-08-01 (a full day early).
|
||||
"""
|
||||
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 events (ts TIMESTAMP, val INTEGER)")
|
||||
# Boundary row (local day 2026-08-02) and a same-local-day daytime row.
|
||||
connection.execute("INSERT INTO events VALUES ('2026-08-01 23:30:00', 1)")
|
||||
connection.execute("INSERT INTO events VALUES ('2026-08-02 10:00:00', 1)")
|
||||
connection.commit()
|
||||
|
||||
@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)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.security_manager.get_guest_rls_filters",
|
||||
return_value=[],
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.security_manager.is_guest_user",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
table = SqlaTable(
|
||||
database=database,
|
||||
schema=None,
|
||||
table_name="events",
|
||||
main_dttm_col="ts",
|
||||
offset=1,
|
||||
columns=[
|
||||
TableColumn(column_name="ts", is_dttm=True, type="TIMESTAMP"),
|
||||
TableColumn(column_name="val", type="INTEGER"),
|
||||
],
|
||||
)
|
||||
|
||||
from superset.common.query_object import QueryObject
|
||||
|
||||
query_object = QueryObject(
|
||||
datasource=table,
|
||||
metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)", "label": "ct"}],
|
||||
columns=[],
|
||||
granularity="ts",
|
||||
from_dttm=pd.Timestamp("2026-07-01"),
|
||||
to_dttm=pd.Timestamp("2026-10-01"),
|
||||
is_timeseries=True,
|
||||
extras={"time_grain_sqla": "P1D"},
|
||||
filters=[
|
||||
{"col": "ts", "op": "TEMPORAL_RANGE", "val": "2026-07-01 : 2026-10-01"}
|
||||
],
|
||||
row_limit=100,
|
||||
)
|
||||
|
||||
result = table.get_query_result(query_object)
|
||||
bucket_days = {ts.date() for ts in result.df["__timestamp"]}
|
||||
|
||||
# Both rows are locally on 2026-08-02, so every bucket must be 2026-08-02.
|
||||
# The bug leaves the boundary row on 2026-08-01.
|
||||
assert date(2026, 8, 1) not in bucket_days, (
|
||||
"Row raw 2026-08-01 23:30 (local 2026-08-02 00:30) was bucketed to "
|
||||
f"2026-08-01, a day early. Buckets: {sorted(bucket_days)}"
|
||||
)
|
||||
assert bucket_days == {date(2026, 8, 2)}, (
|
||||
f"All rows should bucket to 2026-08-02; got {sorted(bucket_days)}"
|
||||
)
|
||||
@@ -36,7 +36,6 @@ from superset.extensions import appbuilder
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.manager import (
|
||||
_collect_sortable_identifiers,
|
||||
_sql_filters_modified,
|
||||
freeze_value,
|
||||
query_context_modified,
|
||||
SupersetSecurityManager,
|
||||
@@ -3794,681 +3793,121 @@ def test_validate_guest_token_resources_accepts_embedded_int_id(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sql_filters_modified – block custom SQL injection by guest users
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sql_filters_extras_where_injected_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Injecting extras.where when the chart has no SQL filters is blocked."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"metrics": ["count"]}
|
||||
|
||||
query = QueryObject(extras={"where": "1=1"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_extras_having_injected_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Injecting extras.having when the chart has no SQL filters is blocked."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject(extras={"having": "COUNT(*) > 0"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_extras_where_replay_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Replaying the chart's own SQL WHERE filter is allowed."""
|
||||
sql_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "region = 'EMEA'",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
|
||||
|
||||
# freeform_where_having wraps each clause in parens
|
||||
query = QueryObject(extras={"where": "(region = 'EMEA')"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_extras_having_replay_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Replaying the chart's own SQL HAVING filter is allowed."""
|
||||
sql_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "SUM(sales) > 100",
|
||||
"clause": "HAVING",
|
||||
}
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
|
||||
|
||||
query = QueryObject(extras={"having": "(SUM(sales) > 100)"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_adhoc_sql_filter_injected_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Injecting a new SQL adhoc filter not on the stored chart is blocked."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject()
|
||||
query_context.queries = [query]
|
||||
|
||||
injected_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "1=1",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
form_data: dict[str, Any] = {"slice_id": 1, "adhoc_filters": [injected_filter]}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_adhoc_sql_filter_replay_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Replaying the exact stored SQL adhoc filter is allowed."""
|
||||
sql_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "region = 'EMEA'",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
|
||||
|
||||
query = QueryObject()
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1, "adhoc_filters": [sql_filter]}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_empty_extras_always_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""No SQL in extras is always allowed, even when the chart has SQL filters."""
|
||||
sql_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "region = 'EMEA'",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
|
||||
|
||||
query = QueryObject()
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_from_stored_qc_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""extras.where from stored query_context is allowed."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
stored_qc = {
|
||||
"queries": [{"extras": {"where": "(col > 5)"}}],
|
||||
}
|
||||
|
||||
query = QueryObject(extras={"where": "(col > 5)"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
|
||||
|
||||
|
||||
def test_sql_filters_multi_query_stored_predicate_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Multiple queries replaying predicates from the stored chart are allowed.
|
||||
|
||||
The allowed set is global across all stored queries — per-query pinning is
|
||||
intentionally not applied because there is no stable identity linking a
|
||||
request query to a stored query, and all queries share the same
|
||||
chart/datasource so predicates only restrict rows, never expand access.
|
||||
def test_is_editor_query_owner(mocker: MockerFixture, app_context: None) -> None:
|
||||
"""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
stored_qc = {
|
||||
"queries": [
|
||||
{"extras": {"where": "(region = 'EMEA')"}},
|
||||
{"extras": {"where": "(status = 'active')"}},
|
||||
],
|
||||
}
|
||||
|
||||
# Both request queries use predicates from the stored chart.
|
||||
query_context.queries = [
|
||||
QueryObject(extras={"where": "(region = 'EMEA')"}),
|
||||
QueryObject(extras={"where": "(status = 'active')"}),
|
||||
]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
|
||||
|
||||
|
||||
def test_sql_filters_multi_query_novel_predicate_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A novel predicate on any query is blocked even when others are valid."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
stored_qc = {
|
||||
"queries": [{"extras": {"where": "(region = 'EMEA')"}}],
|
||||
}
|
||||
|
||||
query_context.queries = [
|
||||
QueryObject(extras={"where": "(region = 'EMEA')"}),
|
||||
QueryObject(extras={"where": "(1=1)"}), # not stored
|
||||
]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
|
||||
|
||||
|
||||
def test_sql_filters_different_sql_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Modified SQL (appending extra predicates) is blocked."""
|
||||
sql_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "col > 5",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
|
||||
|
||||
# Attacker appends extra predicate
|
||||
query = QueryObject(
|
||||
extras={"where": "(col > 5) AND (1=1)"},
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_simple_filters_not_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""SIMPLE structured filters (from dashboard native filters) are not blocked."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject(
|
||||
filters=[{"col": "country", "op": "==", "val": "US"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
simple_adhoc_filter = {
|
||||
"expressionType": "SIMPLE",
|
||||
"subject": "country",
|
||||
"operator": "==",
|
||||
"comparator": "US",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
form_data: dict[str, Any] = {
|
||||
"slice_id": 1,
|
||||
"adhoc_filters": [simple_adhoc_filter],
|
||||
}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_structured_filter_adhoc_col_blocked(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Structured filter with an adhoc SQL column in ``col`` is blocked.
|
||||
|
||||
``ChartDataFilterSchema.col`` is ``fields.Raw``, so an attacker can pass
|
||||
an adhoc column dict that reaches ``adhoc_column_to_sqla`` and executes
|
||||
arbitrary SQL in the WHERE clause.
|
||||
Test that a Query owner is considered an editor via Subject resolution.
|
||||
"""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
from superset.models.sql_lab import Query
|
||||
|
||||
adhoc_col: Any = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "1; DROP TABLE users--",
|
||||
"label": "x",
|
||||
}
|
||||
query = QueryObject(
|
||||
filters=[{"col": adhoc_col, "op": "!=", "val": "z"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_structured_filter_stored_adhoc_col_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Cross-filter with an adhoc SQL column matching a stored chart dimension
|
||||
is allowed."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {
|
||||
"columns": [
|
||||
{"sqlExpression": "YEAR(order_date)", "label": "order_year"},
|
||||
],
|
||||
}
|
||||
|
||||
adhoc_col: Any = {
|
||||
"sqlExpression": "YEAR(order_date)",
|
||||
"label": "order_year",
|
||||
}
|
||||
query = QueryObject(
|
||||
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_cross_filter_adhoc_col_from_sibling_chart_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Cross-filter with an adhoc SQL column from a sibling chart on the same
|
||||
dashboard is allowed."""
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
# Target chart (chart B) has no custom SQL columns.
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.id = 2
|
||||
stored_chart.params_dict = {"metrics": ["count"]}
|
||||
|
||||
# Source chart (chart A) has the custom SQL dimension.
|
||||
sibling_chart = mocker.MagicMock()
|
||||
sibling_chart.id = 1
|
||||
sibling_chart.params_dict = {
|
||||
"columns": [
|
||||
{"sqlExpression": "YEAR(order_date)", "label": "order_year"},
|
||||
],
|
||||
}
|
||||
|
||||
# Dashboard contains both charts.
|
||||
dashboard = mocker.MagicMock(spec=Dashboard)
|
||||
dashboard.slices = [sibling_chart, stored_chart]
|
||||
|
||||
mocker.patch("superset.db.session.query")
|
||||
db_query = mocker.patch("superset.db.session.query").return_value
|
||||
db_query.filter.return_value.one_or_none.return_value = dashboard
|
||||
mocker.patch(
|
||||
"superset.security_manager.has_guest_access",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
adhoc_col: Any = {
|
||||
"sqlExpression": "YEAR(order_date)",
|
||||
"label": "order_year",
|
||||
}
|
||||
query = QueryObject(
|
||||
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 10}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_cross_filter_rejected_for_unauthorized_dashboard(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Cross-filter lookup must not use a dashboard the guest has no access to."""
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.id = 2
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
sibling_chart = mocker.MagicMock()
|
||||
sibling_chart.id = 1
|
||||
sibling_chart.params_dict = {
|
||||
"columns": [{"sqlExpression": "YEAR(order_date)", "label": "order_year"}],
|
||||
}
|
||||
|
||||
dashboard = mocker.MagicMock(spec=Dashboard)
|
||||
dashboard.slices = [sibling_chart, stored_chart]
|
||||
|
||||
mocker.patch("superset.db.session.query")
|
||||
db_query = mocker.patch("superset.db.session.query").return_value
|
||||
db_query.filter.return_value.one_or_none.return_value = dashboard
|
||||
mocker.patch(
|
||||
"superset.security_manager.has_guest_access",
|
||||
return_value=False,
|
||||
)
|
||||
|
||||
adhoc_col: Any = {"sqlExpression": "YEAR(order_date)", "label": "order_year"}
|
||||
query = QueryObject(
|
||||
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 999}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_cross_filter_rejected_when_chart_not_on_dashboard(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Cross-filter lookup must verify the target chart belongs to the dashboard."""
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.id = 99 # not on the dashboard
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
sibling_chart = mocker.MagicMock()
|
||||
sibling_chart.id = 1
|
||||
sibling_chart.params_dict = {
|
||||
"columns": [{"sqlExpression": "YEAR(order_date)", "label": "order_year"}],
|
||||
}
|
||||
|
||||
dashboard = mocker.MagicMock(spec=Dashboard)
|
||||
dashboard.slices = [sibling_chart] # stored_chart not here
|
||||
|
||||
mocker.patch("superset.db.session.query")
|
||||
db_query = mocker.patch("superset.db.session.query").return_value
|
||||
db_query.filter.return_value.one_or_none.return_value = dashboard
|
||||
mocker.patch(
|
||||
"superset.security_manager.has_guest_access",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
adhoc_col: Any = {"sqlExpression": "YEAR(order_date)", "label": "order_year"}
|
||||
query = QueryObject(
|
||||
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
form_data: dict[str, Any] = {"slice_id": 99, "dashboardId": 10}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_sibling_expressions_cannot_inject_where_having(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Sibling chart column expressions must not legitimize novel WHERE/HAVING."""
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.id = 2
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
# Sibling has a column expression that an attacker tries to use as WHERE.
|
||||
sibling_chart = mocker.MagicMock()
|
||||
sibling_chart.id = 1
|
||||
sibling_chart.params_dict = {
|
||||
"columns": [
|
||||
{"sqlExpression": "(SELECT secret FROM users LIMIT 1)", "label": "x"},
|
||||
],
|
||||
}
|
||||
|
||||
dashboard = mocker.MagicMock(spec=Dashboard)
|
||||
dashboard.slices = [sibling_chart, stored_chart]
|
||||
|
||||
mocker.patch("superset.db.session.query")
|
||||
db_query = mocker.patch("superset.db.session.query").return_value
|
||||
db_query.filter.return_value.one_or_none.return_value = dashboard
|
||||
|
||||
# Attacker injects the sibling expression into extras.where.
|
||||
query = QueryObject(
|
||||
extras={"where": "(SELECT secret FROM users LIMIT 1)"},
|
||||
)
|
||||
query_context.queries = [query]
|
||||
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 10}
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_collect_allowed_sql_includes_scalar_column_params(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Scalar column params like x_axis contribute their sqlExpression."""
|
||||
from superset.security.manager import _collect_allowed_sql
|
||||
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {
|
||||
"x_axis": {"sqlExpression": "DATE_TRUNC('month', ts)", "label": "m"},
|
||||
"groupby": [{"sqlExpression": "UPPER(country)", "label": "c"}],
|
||||
}
|
||||
|
||||
_, col_allowed = _collect_allowed_sql(stored_chart, None)
|
||||
|
||||
assert "DATE_TRUNC('month', ts)" in col_allowed
|
||||
assert "UPPER(country)" in col_allowed
|
||||
|
||||
|
||||
def test_sql_filters_structured_filter_string_col_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Structured filter with a plain string column is allowed."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject(
|
||||
filters=[{"col": "status", "op": "==", "val": "active"}],
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_empty_filter_sentinel_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The ``(1 = 0)`` sentinel from a required-but-empty native filter is allowed."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject(extras={"where": "(1 = 0)"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_double_sentinel_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Two required-but-empty filters compose ``(1 = 0) AND (1 = 0)``."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject(extras={"where": "(1 = 0) AND (1 = 0)"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_stored_clause_plus_sentinel_allowed(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A stored SQL filter composed with the empty-filter sentinel is allowed."""
|
||||
sql_filter = {
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": "region = 'EMEA'",
|
||||
"clause": "WHERE",
|
||||
}
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
|
||||
|
||||
query = QueryObject(
|
||||
extras={"where": "(region = 'EMEA') AND (1 = 0)"},
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_non_dict_adhoc_filter_skipped(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Non-dict items in adhoc_filters are skipped, not 500."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject()
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {
|
||||
"slice_id": 1,
|
||||
"adhoc_filters": ["not_a_dict", 42, None],
|
||||
}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_raise_for_access_guest_user_sql_filter_injection_blocked(
|
||||
mocker: MockerFixture,
|
||||
app_context: None,
|
||||
stored_metrics: list[AdhocMetric],
|
||||
) -> None:
|
||||
"""Guest user injecting SQL via extras.where is rejected by raise_for_access."""
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
mocker.patch.object(sm, "is_guest_user", return_value=True)
|
||||
mocker.patch.object(sm, "can_access", return_value=True)
|
||||
|
||||
query_context = mocker.MagicMock()
|
||||
query_context.slice_.id = 42
|
||||
query_context.slice_.query_context = None
|
||||
query_context.slice_.params_dict = {"metrics": stored_metrics}
|
||||
|
||||
query_context.form_data = {"slice_id": 42, "metrics": stored_metrics}
|
||||
query_context.queries = [
|
||||
QueryObject(
|
||||
metrics=stored_metrics, # type: ignore
|
||||
extras={"where": "1=1 UNION SELECT password FROM users"},
|
||||
)
|
||||
]
|
||||
|
||||
with pytest.raises(SupersetSecurityException):
|
||||
sm.raise_for_access(query_context=query_context)
|
||||
|
||||
|
||||
def test_sql_filters_cache_replay_skips_check(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Cache-replay requests skip the SQL filter check."""
|
||||
query_context = mocker.MagicMock()
|
||||
query_context._from_cache_replay = True
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
|
||||
query = QueryObject(extras={"where": "(injected SQL)"})
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
|
||||
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
|
||||
|
||||
def test_sql_filters_column_expression_cannot_become_where(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A chart's column sqlExpression must not be injectable as extras.where."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {
|
||||
"columns": [
|
||||
{
|
||||
"sqlExpression": "(SELECT secret FROM users LIMIT 1)",
|
||||
"label": "x",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
query = QueryObject(
|
||||
extras={"where": "((SELECT secret FROM users LIMIT 1))"},
|
||||
mocker.patch.object(sm, "is_admin", return_value=False)
|
||||
mocker.patch(
|
||||
"superset.security.manager.get_user_id",
|
||||
return_value=100,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.subjects.utils.get_user_subject_ids",
|
||||
return_value={1000},
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.manager.get_extra_editor_subject_ids",
|
||||
return_value=set(),
|
||||
)
|
||||
query_context.queries = [query]
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
subject_user_100 = mocker.MagicMock(id=1000)
|
||||
subject_user_200 = mocker.MagicMock(id=2000)
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
def mock_get_user_subject(uid: int):
|
||||
if uid == 100:
|
||||
return subject_user_100
|
||||
if uid == 200:
|
||||
return subject_user_200
|
||||
return None
|
||||
|
||||
mocker.patch(
|
||||
"superset.subjects.utils.get_user_subject",
|
||||
side_effect=mock_get_user_subject,
|
||||
)
|
||||
|
||||
query = Query(user_id=100)
|
||||
assert sm.is_editor(query) is True
|
||||
|
||||
other_query = Query(user_id=200)
|
||||
assert sm.is_editor(other_query) is False
|
||||
|
||||
|
||||
def test_sql_filters_unbalanced_parens_rejected(
|
||||
mocker: MockerFixture,
|
||||
def test_is_editor_saved_query_owner(mocker: MockerFixture, app_context: None) -> None:
|
||||
"""
|
||||
Test that a SavedQuery owner is considered an editor via Subject resolution.
|
||||
"""
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
mocker.patch.object(sm, "is_admin", return_value=False)
|
||||
mocker.patch(
|
||||
"superset.security.manager.get_user_id",
|
||||
return_value=100,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.subjects.utils.get_user_subject_ids",
|
||||
return_value={1000},
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.manager.get_extra_editor_subject_ids",
|
||||
return_value=set(),
|
||||
)
|
||||
|
||||
subject_user_100 = mocker.MagicMock(id=1000)
|
||||
subject_user_200 = mocker.MagicMock(id=2000)
|
||||
|
||||
def mock_get_user_subject(uid: int):
|
||||
if uid == 100:
|
||||
return subject_user_100
|
||||
if uid == 200:
|
||||
return subject_user_200
|
||||
return None
|
||||
|
||||
mocker.patch(
|
||||
"superset.subjects.utils.get_user_subject",
|
||||
side_effect=mock_get_user_subject,
|
||||
)
|
||||
|
||||
saved_query = SavedQuery(user_id=100)
|
||||
assert sm.is_editor(saved_query) is True
|
||||
|
||||
other_saved_query = SavedQuery(user_id=200)
|
||||
assert sm.is_editor(other_saved_query) is False
|
||||
|
||||
|
||||
def test_is_editor_other_model_with_user_id_not_editor(
|
||||
mocker: MockerFixture, app_context: None
|
||||
) -> None:
|
||||
"""Unbalanced parens in extras.where are rejected (403, not 500)."""
|
||||
query_context = mocker.MagicMock()
|
||||
stored_chart = mocker.MagicMock()
|
||||
stored_chart.params_dict = {}
|
||||
"""
|
||||
Test that a model with user_id that is NOT Query or SavedQuery
|
||||
does NOT receive the fallback and is not considered an editor.
|
||||
"""
|
||||
from superset.models.sql_lab import TabState
|
||||
|
||||
query = QueryObject(extras={"where": "(a) AND (b"})
|
||||
query_context.queries = [query]
|
||||
sm = SupersetSecurityManager(appbuilder)
|
||||
mocker.patch.object(sm, "is_admin", return_value=False)
|
||||
mocker.patch(
|
||||
"superset.security.manager.get_user_id",
|
||||
return_value=100,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.subjects.utils.get_user_subject_ids",
|
||||
return_value={1000},
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.security.manager.get_extra_editor_subject_ids",
|
||||
return_value=set(),
|
||||
)
|
||||
|
||||
form_data: dict[str, Any] = {"slice_id": 1}
|
||||
subject_user_100 = mocker.MagicMock(id=1000)
|
||||
mocker.patch(
|
||||
"superset.subjects.utils.get_user_subject",
|
||||
return_value=subject_user_100,
|
||||
)
|
||||
|
||||
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
|
||||
tab_state = TabState(user_id=100)
|
||||
assert sm.is_editor(tab_state) is False
|
||||
|
||||
@@ -323,7 +323,7 @@ def test_update_command_skips_removal_of_inaccessible_objects(
|
||||
side_effect=can_modify,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.daos.tag.current_user_can_modify_object",
|
||||
"superset.commands.utils.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
|
||||
@@ -397,7 +397,7 @@ def test_update_command_empty_objects_to_tag_only_removes_accessible(
|
||||
side_effect=can_modify,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.daos.tag.current_user_can_modify_object",
|
||||
"superset.commands.utils.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user