Compare commits

..
Author SHA1 Message Date
sadpandajoeandClaude Sonnet 5 8ca0bbf54b fix(echarts): suppress Auto bar value labels for illegibly small segments
Neither an inside nor an outside placement gives a stacked segment's value
label non-overlapping room once the segment's own extent drops below its
label's rendered text height, so a handful of tiny adjacent segments (e.g.
0.77, 0.14) still collided regardless of which side the label moved to.
In Auto mode only, suppress the label entirely once a segment's
value-axis extent falls below MIN_LABEL_SEGMENT_SIZE_PX, matching the
minimum-segment-size floor other charting libraries (Highcharts, D3) use
for the same reason. Manual label positions are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 05:01:41 +00:00
sadpandajoeandClaude Sonnet 5 8c60b0a59c fix(echarts): remove duplicate getNumberFormatter import
The duplicate named import broke oxlint, jest transform, and pre-commit CI checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 19:38:45 +00:00
sadpandajoeandClaude Sonnet 5 f046ef030c fix(echarts): add missing valueLabelPosition to timeseries test fixture
CI's lint-frontend job failed tsc type-checking because the
EchartsTimeseriesFormData test fixture predated the new required
valueLabelPosition field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:47:52 +00:00
sadpandajoe 7c06c3a23f fix(echarts): align bar label contrast paths 2026-08-20 18:47:52 +00:00
sadpandajoe dc0b64754a fix(echarts): refine bar label fit handling 2026-08-20 18:47:52 +00:00
sadpandajoe 8b22d466e3 fix(echarts): make bar value labels fit-aware 2026-08-20 18:47:52 +00:00
Parman MohammadalizadehandEvan Rusackas 148ffaff50 fix(plugin-chart-echarts): omit stacked value labels on zero-height segments (#42756)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-20 11:04:19 -07:00
Ankit 83c6ea4e03 fix(explore): show the empty state when Samples returns no result payload (#43115) 2026-08-20 11:00:54 -07:00
Hans Yu 1ca5e61f54 chore: Unset future flag in engines and sessions. (#43273) 2026-08-20 10:58:37 -07:00
rlei 1ef12580a7 fix(chart): ignore chart actions for a chart no longer in state (#43228) 2026-08-20 10:57:21 -07:00
c0884c0f0c fix(explore): keep x-axis label when overriding Time Column with time comparison (#42875)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-20 10:52:22 -07:00
Sepuri Sai KrishnaandClaude Opus 5 2ff79bd495 fix(github): point the issue templates at labels that exist (#43357)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:13:36 -07:00
e7dccd44a7 fix(reports): prevent blank/partial report PDFs from virtualized charts (#43348)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-08-20 09:10:30 -04:00
dependabot[bot] fdd3945dde chore(deps): bump github/codeql-action/analyze from 4.37.6 to 4.37.7 (#43361)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 13:58:31 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8b67fb4d30 chore(deps-dev): bump globals from 17.10.0 to 17.11.0 in /superset-websocket (#43360)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:49 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b288db91f9 chore(deps): bump github/codeql-action/init from 4.37.6 to 4.37.7 (#43362)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 80bba12d0f chore(deps-dev): bump globals from 17.10.0 to 17.11.0 in /docs (#43363)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:38 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5d4358a149 chore(deps): bump google-auth-library from 11.0.1 to 11.0.2 in /superset-frontend (#43364)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-20 00:41:25 -07:00
f9cedf84e2 fix: drop post-processing options the operation no longer accepts (#42927)
Signed-off-by: Arya Ketan <aryaketan@sharechat.co>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-19 18:15:15 -07:00
Grégoire GaillyandEvan Rusackas c2d653b4b8 fix: set maxHeight of List components to height when in AutoSizer (#43056)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-19 16:56:30 -07:00
Đỗ Trọng HảiandJoe Li 5a96c3f538 chore(ci): disable Git commit info capture in Playwright E2E tests to avoid timeout (#43213)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-19 16:54:44 -07:00
ʈᵃᵢ faf7c34c0a fix(explore): legacy boolean filters and limit available operators based on calculated column type (#43341) 2026-08-19 15:37:09 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b8fca2145d chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.0 (#43322)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 14:40:02 -07:00
Alejandro Solares c10054f521 fix(plugin-chart-chord): declare react as a peerDependency (#43304) 2026-08-19 17:35:38 -04:00
Amin GhadersohiandClaude 8c500ccee1 fix(users): show password validation errors (#43191)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 16:57:46 -04:00
Joe LiandClaude Sonnet 5 6d77efad29 fix(chart): stop contextmenu propagation in BigNumberViz (#43267)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 13:57:28 -07:00
Amin GhadersohiandClaude 8222db3340 fix(dataset): preserve legacy default dashboard URLs (#43190)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 14:44:54 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 01ce8358a6 chore(deps-dev): bump globals from 17.9.0 to 17.10.0 in /superset-websocket (#43321)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:48 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 53a8a0e140 chore(deps): bump the docusaurus-openapi group in /docs with 2 updates (#43323)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:44 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> eafbff9f8d chore(deps-dev): bump globals from 17.9.0 to 17.10.0 in /docs (#43324)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:37 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 1339bcd9da chore(deps): bump swagger-ui-react from 5.32.12 to 5.32.13 in /docs (#43325)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:34 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 334e280489 chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /superset-frontend (#43326)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:30 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c07f3ebf2d chore(deps-dev): bump @swc/plugin-emotion from 14.15.0 to 14.19.0 in /superset-frontend (#43328)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-19 11:36:26 -07:00
Sumit KumarandClaude Opus 4.8 1569915096 feat(multi-value): array-typed column filters with two-tier operators (ClickHouse MVP) (#41279)
Signed-off-by: thedeceptio <thedeceptio@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 10:56:49 -07:00
BexultanandBexultan Mustafin fde0ba26d1 fix(mcp): validate virtual dataset metadata and surface errors (#43129)
Co-authored-by: Bexultan Mustafin <bexultan.mustafin@ffins.kz>
2026-08-19 10:50:37 -07:00
239 changed files with 4069 additions and 551 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
name: Bug report
description: Report a bug to improve Superset's stability
labels: ["bug"]
labels: ["#bug"]
body:
- type: markdown
attributes:
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Cosmetic Issue
about: Describe a cosmetic issue with CSS, positioning, layout, labeling, or similar
labels: "cosmetic-issue"
labels: "#bug:cosmetic"
---
## Screenshot
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
python-version: "3.11"
enable-cache: true
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -64,8 +64,8 @@
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.13",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.1.3",
"docusaurus-theme-openapi-docs": "^5.1.3",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
"js-yaml": "^5.2.3",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
@@ -78,7 +78,7 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.7",
"swagger-ui-react": "^5.32.12",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -93,7 +93,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.9.0",
"globals": "^17.11.0",
"oxfmt": "^0.63.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
+23 -23
View File
@@ -8014,10 +8014,10 @@ doctrine@^2.1.0:
dependencies:
esutils "^2.0.2"
docusaurus-plugin-openapi-docs@^5.1.3:
version "5.1.3"
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
docusaurus-plugin-openapi-docs@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.2.0.tgz#8318ec90cd21fed023be57696211af7d72fd81db"
integrity sha512-MjrfRAMB64uvdxRVz6L9AXWe4QFjCdoBAzYs306yyI3nnXHsFj2lv2FnLA90JV9CAUZaGiYMvvkzBo2Nrkq/9w==
dependencies:
"@apidevtools/json-schema-ref-parser" "^15.3.3"
"@redocly/openapi-core" "^2.25.2"
@@ -8035,10 +8035,10 @@ docusaurus-plugin-openapi-docs@^5.1.3:
swagger2openapi "^7.0.8"
xml-formatter "^3.6.6"
docusaurus-theme-openapi-docs@^5.1.3:
version "5.1.3"
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
docusaurus-theme-openapi-docs@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.2.0.tgz#6d93a74e2e3cf0ae77d24e1c4144bd2e74a52115"
integrity sha512-L0b80LzaMUfr76a9EQXRPCf8nxkEz8Xo6Aknnke1UeE2oXsgoiVki6U+RTE7GmJRjO8zSNKXyckGmGmqqWuHeA==
dependencies:
"@hookform/error-message" "^2.0.1"
"@reduxjs/toolkit" "^2.8.2"
@@ -8123,7 +8123,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
dompurify@^3.3.3, dompurify@^3.4.12:
dompurify@^3.3.3, dompurify@^3.4.13:
version "3.4.13"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
@@ -9174,10 +9174,10 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globals@^17.9.0:
version "17.9.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
globals@^17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
globalthis@^1.0.4:
version "1.0.4"
@@ -10284,10 +10284,10 @@ js-levenshtein@^1.1.6:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@4.1.0, js-yaml@=4.3.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
version "4.3.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
dependencies:
argparse "^2.0.1"
@@ -15103,10 +15103,10 @@ swagger-client@^3.37.8:
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
swagger-ui-react@^5.32.12:
version "5.32.12"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.12.tgz#47525a26774eb02db0e6203af72f5b32fa6205cc"
integrity sha512-WCdkNOQyMTZDu+z356FpwVWHf1dwZgQPUjdQPh1L4r7jULaJTKKlIItXq6WsZdYeXvsHndMdxxccEQXOAroUHQ==
swagger-ui-react@^5.32.13:
version "5.32.13"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -15115,11 +15115,11 @@ swagger-ui-react@^5.32.12:
classnames "^2.5.1"
css.escape "1.5.1"
deep-extend "0.6.0"
dompurify "^3.4.12"
dompurify "^3.4.13"
ieee754 "^1.2.1"
immutable "^4.3.9"
js-file-download "^0.4.12"
js-yaml "=4.3.0"
js-yaml "=4.3.1"
lodash "^4.18.1"
prop-types "^15.8.1"
randexp "^0.5.3"
+1 -1
View File
@@ -93,7 +93,7 @@ def find_models(module: ModuleType) -> list[type[Model]]: # noqa: C901
# where the current model is out-of-sync with the existing table after a
# downgrade
sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
engine = create_engine(sqlalchemy_uri, future=True)
engine = create_engine(sqlalchemy_uri)
Base = automap_base() # noqa: N806
Base.prepare(engine, reflect=True)
seen = set()
+11 -20
View File
@@ -99,7 +99,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -186,7 +186,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
@@ -11808,9 +11808,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "14.15.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.15.0.tgz",
"integrity": "sha512-nCsTO7mOOPz2UnT3N6YWb014uI0CVxeKg53A/KM/CvuSIE6H3KPkhaziJQ3q2jI3u3LfFuDKEnU5ZmB1330Dqg==",
"version": "14.19.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -22783,9 +22783,9 @@
"license": "MIT"
},
"node_modules/google-auth-library": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.1.tgz",
"integrity": "sha512-ZqfaYduu9ASUaFuUk5dF9g9QvufdhhSj7jFiEnCrTQcH57sFPKYetM0iU4dcKkQk6CqC1xpSrVr5uQ9NhqjNOg==",
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -43420,22 +43420,13 @@
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
}
},
"plugins/plugin-chart-chord/node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/plugin-chart-country-map": {
+2 -2
View File
@@ -176,7 +176,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^11.0.1",
"google-auth-library": "^11.0.2",
"immer": "^11.1.16",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -263,7 +263,7 @@
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.15.0",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
@@ -130,6 +130,7 @@ export enum GenericDataType {
String = 1,
Temporal = 2,
Boolean = 3,
MultiValue = 4,
}
/**
@@ -28,6 +28,7 @@ import {
FieldBinaryOutlined,
FieldStringOutlined,
NumberOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Icons } from '@superset-ui/core/components';
@@ -72,6 +73,10 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
} else if (type === GenericDataType.Temporal) {
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
} else if (type === GenericDataType.MultiValue) {
typeIcon = (
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
);
}
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
@@ -64,4 +64,21 @@ describe('ColumnOption', () => {
renderColumnTypeLabel({ type: GenericDataType.Temporal });
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
});
test('multi-value (array) type shows list icon', () => {
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
});
});
describe('GenericDataType enum parity', () => {
// These numeric values are shared with the backend enum in
// superset/utils/core.py (GenericDataType). They must stay in sync because
// the backend serializes columns using these integers.
test('values match the backend contract', () => {
expect(GenericDataType.Numeric).toBe(0);
expect(GenericDataType.String).toBe(1);
expect(GenericDataType.Temporal).toBe(2);
expect(GenericDataType.Boolean).toBe(3);
expect(GenericDataType.MultiValue).toBe(4);
});
});
+4
View File
@@ -47,6 +47,10 @@ export default defineConfig({
// Retry logic - 2 retries in CI, 0 locally
retries: process.env.CI ? 2 : 0,
// Disable capturing Git commit info as the project's history is increasingly dense
// and breach Playwright's default 3-seconds `git` command timeout limit
captureGitInfo: { commit: false, diff: false },
// Reporter configuration - multiple reporters for better visibility
reporter: process.env.CI
? [
@@ -30,12 +30,12 @@
},
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
"@superset-ui/core": "*",
"react": "^18.3.0"
}
}
@@ -17,6 +17,10 @@
* under the License.
*/
import { getNumberFormatter } from '@superset-ui/core';
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
import BigNumberVis from './BigNumberViz';
/**
* Tests for the color threshold formatter logic in BigNumberViz.
*
@@ -83,3 +87,33 @@ describe('BigNumberViz color formatters', () => {
expect(getColorFromValue).not.toHaveBeenCalled();
});
});
describe('BigNumberViz context menu', () => {
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
const onContextMenu = jest.fn();
const ancestorHandler = jest.fn();
const { container } = render(
<div onContextMenu={ancestorHandler}>
<BigNumberVis
width={200}
height={100}
bigNumber={42}
headerFormatter={getNumberFormatter()}
headerFontSize={0.3}
subheaderFontSize={0.125}
subtitleFontSize={0.125}
subtitle=""
refs={{}}
onContextMenu={onContextMenu}
/>
</div>,
);
const headerLine = container.querySelector('.header-line');
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
expect(ancestorHandler).not.toHaveBeenCalled();
});
});
@@ -224,6 +224,7 @@ function BigNumberVis({
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
}
};
@@ -390,6 +390,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
[GenericDataType.String]: undefined,
[GenericDataType.Temporal]: tooltipTimeFormatter,
[GenericDataType.Boolean]: undefined,
[GenericDataType.MultiValue]: undefined,
};
const echartOptions: EChartsCoreOption = {
@@ -59,6 +59,7 @@ import {
LegendOrientation,
Refs,
} from '../types';
import { BarValueLabelPosition } from '../Timeseries/types';
import { parseAxisBound } from '../utils/controls';
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import {
@@ -512,6 +513,7 @@ export default function transformProps(
areaOpacity: opacity,
seriesType,
showValue,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
onlyTotal,
stack: Boolean(stack),
stackIdSuffix: '\na',
@@ -600,6 +602,7 @@ export default function transformProps(
areaOpacity: opacityB,
seriesType: seriesTypeB,
showValue: showValueB,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
onlyTotal: onlyTotalB,
stack: Boolean(stackB),
stackIdSuffix: '\nb',
@@ -34,6 +34,7 @@ import {
} from '../types';
import EchartsTimeseries from './EchartsTimeseries';
import {
BarValueLabelPosition,
EchartsTimeseriesSeriesType,
OrientationType,
type EchartsTimeseriesFormData,
@@ -146,6 +147,7 @@ const defaultFormData: EchartsTimeseriesFormData & {
xAxisLabelRotation: 0,
xAxisLabelInterval: 0,
showValue: false,
valueLabelPosition: BarValueLabelPosition.Auto,
onlyTotal: false,
showExtraControls: true,
percentageThreshold: 0,
@@ -23,6 +23,7 @@ import {
import { t } from '@apache-superset/core/translation';
import { LegendOrientation, LegendType } from '../types';
import {
BarValueLabelPosition,
OrientationType,
EchartsTimeseriesSeriesType,
EchartsTimeseriesFormData,
@@ -86,6 +87,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
xAxisLabelInterval: defaultXAxis.xAxisLabelInterval,
groupby: [],
showValue: false,
valueLabelPosition: BarValueLabelPosition.Auto,
onlyTotal: false,
percentageThreshold: 0,
orientation: OrientationType.Vertical,
@@ -47,6 +47,7 @@ import {
NumberFormats,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { isThemeDark } from '@apache-superset/core/theme';
import {
extractExtraMetrics,
getOriginalSeries,
@@ -63,6 +64,7 @@ import {
EchartsTimeseriesChartProps,
EchartsTimeseriesFormData,
EchartsTimeseriesSeriesType,
BarValueLabelPosition,
OrientationType,
TimeseriesChartTransformedProps,
} from './types';
@@ -290,6 +292,7 @@ export default function transformProps(
seriesType,
showLegend,
showValue,
valueLabelPosition,
size,
colorByPrimaryAxis,
sliceId,
@@ -327,6 +330,8 @@ export default function transformProps(
zoomable,
stackDimension,
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const resolvedValueLabelPosition =
valueLabelPosition ?? BarValueLabelPosition.Auto;
const refs: Refs = {};
const groupBy = ensureIsArray(groupby);
@@ -737,6 +742,7 @@ export default function transformProps(
labelMap?.[seriesName]?.[0],
) ?? defaultFormatter),
showValue,
valueLabelPosition: resolvedValueLabelPosition,
onlyTotal,
totalStackedValues: sortedTotalValues,
showValueIndexes,
@@ -1370,6 +1376,10 @@ export default function transformProps(
const echartOptions: EChartsCoreOption = {
useUTC: true,
...(seriesType === EchartsTimeseriesSeriesType.Bar &&
resolvedValueLabelPosition === BarValueLabelPosition.Auto
? { darkMode: isThemeDark(theme) }
: {}),
grid: {
...defaultGrid,
...padding,
@@ -35,6 +35,9 @@ import type {
CallbackDataParams,
DefaultStatesMixin,
ItemStyleOption,
LabelLayoutOption,
LabelLayoutOptionCallback,
LabelLayoutOptionCallbackParams,
LineStyleOption,
OptionName,
SeriesLabelOption,
@@ -49,6 +52,7 @@ import type {
import type { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
import { extractForecastSeriesContext } from '../utils/forecast';
import {
BarValueLabelPosition,
EchartsTimeseriesSeriesType,
ForecastSeriesEnum,
LegendOrientation,
@@ -69,6 +73,107 @@ import {
TIMESERIES_CONSTANTS,
} from '../constants';
const AUTO_LABEL_FIT_RATIO = 0.8;
const BAR_LABEL_DISTANCE = 5;
// Neither an inside nor an outside placement gives a stacked segment's value
// label legible, non-overlapping room once the segment's own extent drops
// below roughly the label text's height, since the closest available
// placement then collides with a neighboring segment's label regardless of
// which side it's drawn on. Highcharts and D3 apply the same kind of floor.
// The label font size is theme.fontSizeSM (~12px, see series.ts), so 16px
// covers the glyph height plus a couple of pixels of breathing room.
const MIN_LABEL_SEGMENT_SIZE_PX = 16;
// The labelLayout callback only applies align/verticalAlign/width/height/
// fontSize from its return value (LABEL_OPTION_TO_STYLE_KEYS in ECharts'
// LabelManager) — there is no hide/ignore field, so a zero font size is the
// supported way to suppress an individual label from this callback.
const HIDDEN_LABEL_LAYOUT: LabelLayoutOption = { fontSize: 0 };
type BarLabelPosition =
| 'bottom'
| 'inside'
| 'insideBottom'
| 'insideLeft'
| 'insideRight'
| 'insideTop'
| 'left'
| 'right'
| 'top';
type NegativeBarLabelPosition = BarLabelPosition | 'outside';
/** Resolve the fixed ECharts label position for a bar value. */
function getBarLabelPosition(
position: BarValueLabelPosition,
isHorizontal: boolean,
isNegative = false,
): BarLabelPosition {
if (position === BarValueLabelPosition.OutsideEnd) {
if (isHorizontal) return isNegative ? 'left' : 'right';
return isNegative ? 'bottom' : 'top';
}
if (position === BarValueLabelPosition.InsideCenter) return 'inside';
const isEnd = position !== BarValueLabelPosition.InsideBase;
const usePositiveEnd = isEnd !== isNegative;
if (isHorizontal) return usePositiveEnd ? 'insideRight' : 'insideLeft';
return usePositiveEnd ? 'insideTop' : 'insideBottom';
}
/** Place a horizontal bar label just beyond its value end. */
function getHorizontalOutsideLayout(
params: LabelLayoutOptionCallbackParams,
isNegative: boolean,
): LabelLayoutOption {
return {
x: isNegative
? params.rect.x - BAR_LABEL_DISTANCE
: params.rect.x + params.rect.width + BAR_LABEL_DISTANCE,
y: params.rect.y + params.rect.height / 2,
align: isNegative ? 'right' : 'left',
verticalAlign: 'middle',
};
}
/** Place a vertical bar label just beyond its value end. */
function getVerticalOutsideLayout(
params: LabelLayoutOptionCallbackParams,
isNegative: boolean,
): LabelLayoutOption {
return {
x: params.rect.x + params.rect.width / 2,
y: isNegative
? params.rect.y + params.rect.height + BAR_LABEL_DISTANCE
: params.rect.y - BAR_LABEL_DISTANCE,
align: 'center',
verticalAlign: isNegative ? 'top' : 'bottom',
};
}
/** Keep fitting labels inside, move oversized labels outside the bar, and
* suppress labels for segments too small to legibly fit one either way. */
export function getAutoBarLabelLayout(
params: LabelLayoutOptionCallbackParams,
isHorizontal: boolean,
isNegative = false,
): LabelLayoutOption {
const segmentSize = isHorizontal
? Math.abs(params.rect.width)
: Math.abs(params.rect.height);
if (segmentSize < MIN_LABEL_SEGMENT_SIZE_PX) {
return HIDDEN_LABEL_LAYOUT;
}
const fitsWidth =
params.labelRect.width <=
Math.abs(params.rect.width) * AUTO_LABEL_FIT_RATIO;
const fitsHeight =
params.labelRect.height <=
Math.abs(params.rect.height) * AUTO_LABEL_FIT_RATIO;
if (fitsWidth && fitsHeight) return {};
return isHorizontal
? getHorizontalOutsideLayout(params, isNegative)
: getVerticalOutsideLayout(params, isNegative);
}
function parseTimeShiftToMs(timeShift?: string | null): number {
if (!timeShift) return 0;
@@ -167,31 +272,69 @@ export const getBaselineSeriesForStream = (
};
};
/** Identify object-form ECharts data items. */
function isDataItemObject(
dataItem: unknown,
): dataItem is Record<string, unknown> {
return (
typeof dataItem === 'object' &&
dataItem !== null &&
!Array.isArray(dataItem)
);
}
/** Return whether an ECharts bar datum is negative on its value axis. */
function isNegativeBarDataItem(
dataItem: unknown,
isHorizontal: boolean,
): boolean {
const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
const axisValue = Array.isArray(value)
? value[isHorizontal ? 0 : 1]
: undefined;
return typeof axisValue === 'number' && axisValue < 0;
}
/** Create a fit-aware layout callback bound to one bar series. */
function createAutoBarLabelLayout(
data: unknown,
isHorizontal: boolean,
): LabelLayoutOptionCallback {
return params => {
const dataItem =
Array.isArray(data) && params.dataIndex !== undefined
? data[params.dataIndex]
: undefined;
return getAutoBarLabelLayout(
params,
isHorizontal,
isNegativeBarDataItem(dataItem, isHorizontal),
);
};
}
/** Apply the value-end label position to a negative bar datum. */
function transformNegativeLabel(
dataItem: unknown,
isHorizontal: boolean,
negativePosition: NegativeBarLabelPosition,
): unknown {
if (!isNegativeBarDataItem(dataItem, isHorizontal)) return dataItem;
const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
const item = isDataItemObject(dataItem) ? dataItem : { value };
const label = isDataItemObject(item.label) ? item.label : {};
return { ...item, label: { ...label, position: negativePosition } };
}
/** Adjust label positions for negative values in a bar series. */
export function transformNegativeLabelsPosition(
series: SeriesOption,
isHorizontal: boolean,
negativePosition: NegativeBarLabelPosition = 'outside',
): TimeseriesDataRecord[] {
/*
* Adjusts label position for negative values in bar series
* @param series - Array of series options
* @param isHorizontal - Whether chart is horizontal
* @returns data with adjusted label positions for negative values
*/
const transformValue = (value: any) => {
const [xValue, yValue] = Array.isArray(value) ? value : [null, null];
const axisValue = isHorizontal ? xValue : yValue;
return axisValue < 0
? {
value,
label: {
position: 'outside',
},
}
: value;
};
return (series.data as TimeseriesDataRecord[]).map(transformValue);
return (series.data as unknown[]).map(dataItem =>
transformNegativeLabel(dataItem, isHorizontal, negativePosition),
) as TimeseriesDataRecord[];
}
export function applyColorByPrimaryAxis(
@@ -237,6 +380,7 @@ export function transformSeries(
stackIdSuffix?: string;
yAxisIndex?: number;
showValue?: boolean;
valueLabelPosition?: BarValueLabelPosition;
onlyTotal?: boolean;
legendState?: LegendState;
formatter?: ValueFormatter;
@@ -272,6 +416,7 @@ export function transformSeries(
stackIdSuffix,
yAxisIndex = 0,
showValue,
valueLabelPosition = BarValueLabelPosition.Auto,
onlyTotal,
formatter,
legendState,
@@ -392,23 +537,33 @@ export function transformSeries(
symbol = opts.lineSymbol || (isDarkMode ? 'circle' : 'emptyCircle');
}
let transformedData = data;
if (Array.isArray(data) && colorByPrimaryAxis) {
transformedData = applyColorByPrimaryAxis(
series,
colorScale,
sliceId,
opacity,
isHorizontal,
);
}
if (Array.isArray(transformedData) && plotType === 'bar') {
transformedData = transformNegativeLabelsPosition(
{ ...series, data: transformedData },
isHorizontal,
getBarLabelPosition(valueLabelPosition, isHorizontal, true),
);
}
const isAutoBarLabel =
plotType === 'bar' && valueLabelPosition === BarValueLabelPosition.Auto;
const isInsideBarLabel =
plotType === 'bar' &&
valueLabelPosition !== BarValueLabelPosition.OutsideEnd;
return {
...series,
...(Array.isArray(data)
? colorByPrimaryAxis
? {
data: applyColorByPrimaryAxis(
series,
colorScale,
sliceId,
opacity,
isHorizontal,
),
}
: seriesType === 'bar' && !stack
? { data: transformNegativeLabelsPosition(series, isHorizontal) }
: null
: null),
...(Array.isArray(data) ? { data: transformedData } : null),
connectNulls,
queryIndex,
yAxisIndex,
@@ -441,10 +596,22 @@ export function transformSeries(
showSymbol,
symbol,
symbolSize: symbolSizeFn ?? markerSize,
...(isAutoBarLabel
? {
labelLayout: createAutoBarLabelLayout(transformedData, isHorizontal),
}
: {}),
label: {
show: !!showValue,
position: isHorizontal ? 'right' : 'top',
color: theme?.colorText,
position:
plotType === 'bar'
? getBarLabelPosition(valueLabelPosition, isHorizontal)
: isHorizontal
? 'right'
: 'top',
// ECharts derives contrast from the bar fill for inside positions.
// Auto x/y overflow clears the position, selecting its outside fill.
...(isInsideBarLabel ? {} : { color: theme?.colorText }),
textBorderWidth: 0,
formatter: (params: any) => {
// don't show confidence band value labels, as they're already visible on the tooltip
@@ -467,6 +634,14 @@ export function transformSeries(
return formatter(numericValue);
}
if (!onlyTotal) {
// A stacked segment with no height begins and ends at the same
// coordinate as the top of the segment beneath it, so its label is
// drawn over that segment's label. Zero and null have no height, so
// they carry no label. The rich tooltip omits zero observations from
// a stacked series for the same reason.
if (stack && !numericValue) {
return '';
}
if (
numericValue >=
(thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)
@@ -52,6 +52,14 @@ export enum EchartsTimeseriesSeriesType {
End = 'end',
}
export enum BarValueLabelPosition {
Auto = 'auto',
InsideEnd = 'insideEnd',
OutsideEnd = 'outsideEnd',
InsideCenter = 'insideCenter',
InsideBase = 'insideBase',
}
export type EchartsTimeseriesFormData = QueryFormData & {
annotationLayers: AnnotationLayer[];
area: boolean;
@@ -99,6 +107,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
xAxisLabelRotation: number;
xAxisLabelInterval: number | string;
showValue: boolean;
valueLabelPosition: BarValueLabelPosition;
onlyTotal: boolean;
showExtraControls: boolean;
percentageThreshold: number;
@@ -34,6 +34,7 @@ import {
StackControlOptionsWithoutStream,
} from './constants';
import { DEFAULT_FORM_DATA } from './Timeseries/constants';
import { BarValueLabelPosition } from './Timeseries/types';
import { defaultXAxis } from './defaults';
const { legendMargin, legendOrientation, legendType, showLegend } =
@@ -140,6 +141,28 @@ export const showValueControl: ControlSetItem = {
},
};
export const valueLabelPositionControl: ControlSetItem = {
name: 'value_label_position',
config: {
type: 'SelectControl',
freeForm: false,
clearable: false,
label: t('Value label position'),
choices: [
[BarValueLabelPosition.Auto, t('Auto')],
[BarValueLabelPosition.InsideEnd, t('Inside End')],
[BarValueLabelPosition.OutsideEnd, t('Outside End')],
[BarValueLabelPosition.InsideCenter, t('Inside Center')],
[BarValueLabelPosition.InsideBase, t('Inside Base')],
],
default: DEFAULT_FORM_DATA.valueLabelPosition,
renderTrigger: true,
description: t('Choose where to display values relative to the bars'),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.show_value?.value),
},
};
export const colorByPrimaryAxisControl: ControlSetItem = {
name: 'color_by_primary_axis',
config: {
@@ -235,6 +258,7 @@ export const showValueSectionWithoutStack: ControlSetRow[] = [
export const showValueSectionWithoutStream: ControlSetRow[] = [
[showValueControl],
[valueLabelPositionControl],
[stackControlWithoutStream],
[onlyTotalControl],
[percentageThresholdControl],
@@ -43,6 +43,7 @@ import {
} from '../../src/MixedTimeseries/types';
import { createEchartsTimeseriesTestChartProps } from '../helpers';
import type { SeriesOption } from 'echarts';
import type { BarSeriesOption } from 'echarts/charts';
type LabelFormatterParams = {
value: [number, number];
@@ -193,6 +194,61 @@ function formatSeriesLabel(
});
}
test('bar value labels retain their legacy outside position', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showValueB: true },
queriesData,
});
const transformed = transformProps(chartProps);
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
(series): series is BarSeriesOption => series.type === 'bar',
);
expect(barSeries).not.toHaveLength(0);
barSeries.forEach(series => {
expect(series.label).toMatchObject({ show: true, position: 'top' });
expect(series.labelLayout).toBeUndefined();
});
});
test('negative bar values retain their legacy outside position', () => {
const negativeRows = [
{ boy: -1, girl: -2, ds: 599616000000 },
{ boy: -3, girl: -4, ds: 599916000000 },
];
const negativeQueriesData = [
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
];
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: negativeQueriesData,
formData: { ...formData, showValueB: true },
queriesData: negativeQueriesData,
});
const transformed = transformProps(chartProps);
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
(series): series is BarSeriesOption => series.type === 'bar',
);
expect(barSeries).not.toHaveLength(0);
barSeries.forEach(series => {
expect(series.data?.[0]).toMatchObject({
label: { position: 'bottom' },
});
});
});
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
@@ -23,7 +23,10 @@ import {
StackControlOptionsWithoutStream,
StackControlsValue,
} from '../../../src/constants';
import { OrientationType } from '../../../src/Timeseries/types';
import {
BarValueLabelPosition,
OrientationType,
} from '../../../src/Timeseries/types';
const config = controlPanel;
@@ -132,6 +135,41 @@ test('should include stack control in the panel', () => {
expect(stackControl).toBeDefined();
});
test('should expose Auto and manual value label positions for Bar charts', () => {
const valueLabelPositionControl = getControl(
'value_label_position',
) as unknown as {
config: {
choices: [BarValueLabelPosition, string][];
default: BarValueLabelPosition;
visibility: (props: ControlPanelsContainerProps) => boolean;
};
};
expect(valueLabelPositionControl.config.default).toBe(
BarValueLabelPosition.Auto,
);
expect(
valueLabelPositionControl.config.choices.map(([value]) => value),
).toEqual([
BarValueLabelPosition.Auto,
BarValueLabelPosition.InsideEnd,
BarValueLabelPosition.OutsideEnd,
BarValueLabelPosition.InsideCenter,
BarValueLabelPosition.InsideBase,
]);
expect(
valueLabelPositionControl.config.visibility({
controls: { show_value: { value: true } },
} as unknown as ControlPanelsContainerProps),
).toBe(true);
expect(
valueLabelPositionControl.config.visibility({
controls: { show_value: { value: false } },
} as unknown as ControlPanelsContainerProps),
).toBe(false);
});
test('should use StackControlOptionsWithoutStream for stack control', () => {
const stackControl: any = getControl('stack');
expect(stackControl).toBeDefined();
@@ -29,6 +29,7 @@ import type {
GridComponentOption,
LegendComponentOption,
} from 'echarts/components';
import type { BarSeriesOption } from 'echarts/charts';
import {
EchartsTimeseriesChartProps,
LegendOrientation,
@@ -37,6 +38,7 @@ import {
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
import {
BarValueLabelPosition,
EchartsTimeseriesFormData,
OrientationType,
EchartsTimeseriesSeriesType,
@@ -74,6 +76,105 @@ function createTestQueryData(
};
}
test('manual Bar value label position flows through transformProps', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: DEFAULT_FORM_DATA,
defaultVizType: 'echarts_timeseries_bar',
formData: {
seriesType: EchartsTimeseriesSeriesType.Bar,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
metrics: ['Sales'],
xAxis: '__timestamp',
showValue: true,
},
queriesData: [
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
colnames: ['Sales', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
}),
],
});
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(series.label).toMatchObject({ position: 'top' });
expect(series.labelLayout).toBeUndefined();
expect(echartOptions.darkMode).toBeUndefined();
});
test('Auto Bar labels enable theme-aware ECharts contrast', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: DEFAULT_FORM_DATA,
defaultVizType: 'echarts_timeseries_bar',
formData: {
seriesType: EchartsTimeseriesSeriesType.Bar,
metrics: ['Sales'],
xAxis: '__timestamp',
showValue: true,
},
queriesData: [
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
colnames: ['Sales', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
}),
],
});
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(typeof series.labelLayout).toBe('function');
expect(echartOptions.darkMode).toBe(false);
});
test('legacy Bar labels without a saved position enable Auto contrast', () => {
const legacyFormData: Partial<EchartsTimeseriesFormData> = {
...DEFAULT_FORM_DATA,
};
delete legacyFormData.valueLabelPosition;
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps
>({
defaultFormData: legacyFormData as EchartsTimeseriesFormData,
defaultVizType: 'echarts_timeseries_bar',
formData: {
seriesType: EchartsTimeseriesSeriesType.Bar,
metrics: ['Sales'],
xAxis: '__timestamp',
showValue: true,
},
queriesData: [
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
colnames: ['Sales', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
}),
],
});
expect(chartProps.formData).not.toHaveProperty('valueLabelPosition');
const { echartOptions } = transformProps(chartProps);
const [series] = echartOptions.series as BarSeriesOption[];
expect(typeof series.labelLayout).toBe('function');
expect(echartOptions.darkMode).toBe(false);
Reflect.set(chartProps.formData, 'valueLabelPosition', undefined);
const undefinedPositionOptions = transformProps(chartProps).echartOptions;
const [undefinedPositionSeries] =
undefinedPositionOptions.series as BarSeriesOption[];
expect(typeof undefinedPositionSeries.labelLayout).toBe('function');
expect(undefinedPositionOptions.darkMode).toBe(false);
});
describe('Bar Chart X-axis Time Formatting', () => {
const baseFormData: SqlaFormData = {
...DEFAULT_FORM_DATA,
@@ -19,14 +19,19 @@
import {
CategoricalColorScale,
ChartProps,
getNumberFormatter,
TimeGranularity,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import type { SeriesOption } from 'echarts';
import type { ScatterSeriesOption } from 'echarts/charts';
import { EchartsTimeseriesSeriesType } from '../../src';
import { TIMESERIES_CONSTANTS } from '../../src/constants';
import { init, type SeriesOption } from 'echarts';
import type {
BarSeriesOption,
LineSeriesOption,
ScatterSeriesOption,
} from 'echarts/charts';
import { BarValueLabelPosition, EchartsTimeseriesSeriesType } from '../../src';
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
import {
LegendOrientation,
EchartsTimeseriesChartProps,
@@ -34,6 +39,7 @@ import {
import {
transformSeries,
transformNegativeLabelsPosition,
getAutoBarLabelLayout,
getPadding,
} from '../../src/Timeseries/transformers';
import transformProps from '../../src/Timeseries/transformProps';
@@ -160,6 +166,416 @@ describe('transformSeries', () => {
});
});
test('Auto bar labels move outside narrow stacked segments', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
showValue: true,
},
) as BarSeriesOption;
const { labelLayout } = result;
expect(result.label).toMatchObject({
show: true,
position: 'insideTop',
});
expect((result.label as { color?: string }).color).toBeUndefined();
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 12, height: 20 },
labelRect: { x: 1, y: 22, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 15,
align: 'center',
verticalAlign: 'bottom',
});
});
test('Auto labels stay inside when both dimensions fit within 80% of the bar', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar },
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'center',
verticalAlign: 'top',
rect: { x: 10, y: 20, width: 50, height: 40 },
labelRect: { x: 19, y: 25, width: 32, height: 14 },
}),
).toEqual({});
});
test('Auto moves wide labels outside tall narrow vertical bars', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 100]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar },
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'center',
verticalAlign: 'top',
rect: { x: 10, y: 20, width: 12, height: 200 },
labelRect: { x: 1, y: 25, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 15,
align: 'center',
verticalAlign: 'bottom',
});
});
test('Auto overflow uses ECharts outside-label text color', () => {
const darkBarColorScale = jest.fn(() => '#111111');
const series = transformSeries(
{ name: 'test-series', type: 'bar', data: [[0, 123456789012]] },
darkBarColorScale as unknown as CategoricalColorScale,
'test-key',
{
formatter: getNumberFormatter('d'),
seriesType: EchartsTimeseriesSeriesType.Bar,
showValue: true,
},
) as BarSeriesOption;
const chart = init(null, null, {
renderer: 'svg',
ssr: true,
width: 300,
height: 220,
});
chart.setOption({
animation: false,
darkMode: false,
xAxis: { type: 'category', data: ['A'], show: false },
// A tall bar (well above the segment-legibility floor) whose 12-digit
// label is too wide to fit inside, so ECharts still moves it outside.
yAxis: { type: 'value', max: 250_000_000_000, show: false },
series: [series],
});
expect(chart.renderToSVGString()).toMatch(
/fill="#333"[^>]*>123456789012<\/text>/,
);
chart.dispose();
});
test('Auto bar labels use horizontal bar length and move to the value end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[1, 2026]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '1,000',
align: 'right',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 20, height: 12 },
labelRect: { x: 0, y: 19, width: 30, height: 14 },
}),
).toEqual({
x: 35,
y: 26,
align: 'left',
verticalAlign: 'middle',
});
});
test.each([
[BarValueLabelPosition.InsideEnd, 'insideTop'],
[BarValueLabelPosition.OutsideEnd, 'top'],
[BarValueLabelPosition.InsideCenter, 'inside'],
[BarValueLabelPosition.InsideBase, 'insideBottom'],
] as const)(
'manual %s bar labels use fixed position %s',
(position, expected) => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
valueLabelPosition: position,
theme: supersetTheme,
},
) as BarSeriesOption;
expect(result.labelLayout).toBeUndefined();
expect(result.label).toMatchObject({ position: expected });
if (position === BarValueLabelPosition.OutsideEnd) {
expect(result.label).toMatchObject({ color: supersetTheme.colorText });
} else {
expect(result.label).not.toHaveProperty('color');
}
},
);
test('manual Outside End positions negative stacked segments below the bar', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
},
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [2026, -1],
label: { position: 'bottom' },
},
]);
expect(result.labelLayout).toBeUndefined();
});
test('Auto positions negative stacked segments at their inside end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
},
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [2026, -1],
label: { position: 'insideBottom' },
},
]);
expect(typeof result.labelLayout).toBe('function');
if (typeof result.labelLayout !== 'function') return;
expect(
result.labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '-1,000',
align: 'center',
verticalAlign: 'bottom',
rect: { x: 10, y: 20, width: 12, height: 30 },
labelRect: { x: 1, y: 35, width: 30, height: 14 },
}),
).toEqual({
x: 16,
y: 55,
align: 'center',
verticalAlign: 'top',
});
});
test('Auto moves horizontal negative labels beyond their value end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[-1, 2026]] },
mockColorScale,
'test-key',
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
) as BarSeriesOption;
expect(result.data).toEqual([
{
value: [-1, 2026],
label: { position: 'insideLeft' },
},
]);
expect(typeof result.labelLayout).toBe('function');
if (typeof result.labelLayout !== 'function') return;
expect(
result.labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '-1,000',
align: 'left',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 20, height: 12 },
labelRect: { x: 10, y: 19, width: 30, height: 14 },
}),
).toEqual({
x: 5,
y: 26,
align: 'right',
verticalAlign: 'middle',
});
});
test('Auto label layout does not change non-Bar series', () => {
const result = transformSeries(
{ name: 'test-series', type: 'line', data: [[2026, 1]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Line,
theme: supersetTheme,
},
) as LineSeriesOption;
expect(result).not.toHaveProperty('labelLayout');
expect(result.label).toMatchObject({
position: 'top',
color: supersetTheme.colorText,
});
});
test('Auto suppresses the label for a vertical segment below the legibility floor', () => {
// A 10px-tall stacked segment can't legibly fit its 14px-tall label inside
// or outside without colliding with a neighboring segment's label.
expect(
getAutoBarLabelLayout(
{
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 40, height: 10 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
},
false,
),
).toEqual({ fontSize: 0 });
});
test('Auto keeps placing labels normally for a vertical segment at the legibility floor', () => {
expect(
getAutoBarLabelLayout(
{
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 40, height: 16 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
},
false,
),
).not.toEqual({ fontSize: 0 });
});
test('Auto suppresses the label for a horizontal segment below the legibility floor', () => {
// Horizontal bars stack along the x axis, so the value-axis dimension that
// matters is rect.width rather than rect.height.
expect(
getAutoBarLabelLayout(
{
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'left',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 10, height: 40 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
},
true,
),
).toEqual({ fontSize: 0 });
});
test('Auto suppresses labels for tiny adjacent stacked segments end to end', () => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
showValue: true,
},
) as BarSeriesOption;
const { labelLayout } = result;
expect(typeof labelLayout).toBe('function');
if (typeof labelLayout !== 'function') return;
expect(
labelLayout({
dataIndex: 0,
seriesIndex: 0,
text: '0.14',
align: 'center',
verticalAlign: 'middle',
rect: { x: 10, y: 20, width: 40, height: 8 },
labelRect: { x: 12, y: 22, width: 20, height: 14 },
}),
).toEqual({ fontSize: 0 });
});
test.each([
[BarValueLabelPosition.InsideEnd, 'insideTop'],
[BarValueLabelPosition.OutsideEnd, 'top'],
[BarValueLabelPosition.InsideCenter, 'inside'],
[BarValueLabelPosition.InsideBase, 'insideBottom'],
] as const)(
'manual %s label placement is unaffected by tiny segments (no labelLayout applied)',
(position, expected) => {
const result = transformSeries(
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
mockColorScale,
'test-key',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
valueLabelPosition: position,
showValue: true,
theme: supersetTheme,
},
) as BarSeriesOption;
// Manual positions don't use the fit-aware labelLayout callback at all,
// so a tiny segment can't trigger the Auto-only suppression behavior.
expect(result.labelLayout).toBeUndefined();
expect(result.label).toMatchObject({ position: expected });
},
);
describe('transformNegativeLabelsPosition', () => {
test('label position bottom of negative value no Horizontal', () => {
const isHorizontal = false;
@@ -566,3 +982,70 @@ test('getPadding should handle Left position with zero margin correctly', () =>
getChartPaddingSpy.mockRestore();
}
});
/**
* #42702: a stacked segment with no height starts and ends at the same
* coordinate as the top of the segment beneath it, so a value label on it is
* drawn over that segment's label. `percentage_threshold` does not filter these
* out: it defaults to 0, and `thresholdValues[dataIndex] || MIN_SAFE_INTEGER`
* turns a 0 threshold into "no filtering", which is intentional.
*/
const stackedLabel = (
numericValue: number | null,
opts: Record<string, unknown> = {},
) => {
const series = transformSeries(
{ id: 'B', name: 'B', data: [[1, numericValue]] } as SeriesOption,
mockColorScale,
'B',
{
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: StackControlsValue.Stack,
showValue: true,
onlyTotal: false,
formatter: getNumberFormatter(),
thresholdValues: [0],
...opts,
},
) as SeriesOption & {
label: { formatter: (params: unknown) => string };
};
return series.label.formatter({
value: [1, numericValue],
dataIndex: 0,
seriesIndex: 1,
seriesName: 'B',
});
};
test('stacked value labels are omitted for a zero-height segment', () => {
expect(stackedLabel(0)).toBe('');
expect(stackedLabel(null)).toBe('');
});
test('stacked value labels are kept for segments that have height', () => {
expect(stackedLabel(32)).toBe('32');
expect(stackedLabel(-5)).toBe('-5');
});
test('a zero value keeps its label when the series is not stacked', () => {
// Without a stack the label sits on the bar itself, so there is nothing for
// it to collide with.
expect(stackedLabel(0, { stack: undefined })).toBe('0');
});
test('percentage_threshold still filters values below the threshold', () => {
// 10% of a 100 total. The zero-height guard must not swallow this rule.
expect(stackedLabel(5, { thresholdValues: [10] })).toBe('');
expect(stackedLabel(50, { thresholdValues: [10] })).toBe('50');
});
test('only-total labels are unaffected by the zero-height guard', () => {
expect(
stackedLabel(0, {
onlyTotal: true,
showValueIndexes: [1],
totalStackedValues: [32],
}),
).toBe('32');
});
@@ -223,6 +223,11 @@ export default function chartReducer(
}
if (action.type in actionHandlers) {
// ADD_CHART creates the entry, so it runs without prior state; every other
// handler reads state that is absent once the chart has been removed
if (action.type !== actions.ADD_CHART && !charts[action.key]) {
return charts;
}
return {
...charts,
[action.key]: actionHandlers[action.type](charts[action.key]),
@@ -91,4 +91,20 @@ describe('chart reducers', () => {
expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0);
expect(newState[chartKey].chartStatus).toEqual('failed');
});
test('ignores an action for a chart that is no longer in state', () => {
const action = actions.chartUpdateStopped(999, new AbortController());
expect(() => chartReducer(charts, action)).not.toThrow();
expect(chartReducer(charts, action)).toEqual(charts);
});
test('still adds a chart that is not yet in state', () => {
const newChartKey = 2;
const newState = chartReducer(
charts,
actions.addChart({ ...chart, id: newChartKey }, newChartKey),
);
expect(newState[newChartKey].id).toEqual(newChartKey);
expect(newState[chartKey]).toEqual(testChart);
});
});
@@ -1627,9 +1627,7 @@ function DatasourceEditor({
{t(
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
)}{' '}
<Typography.Text code>
/superset/dashboard/{'{id}'}/
</Typography.Text>
<Typography.Text code>/dashboard/{'{id}'}/</Typography.Text>
</>
}
control={<TextControl controlId="default_endpoint" />}
@@ -71,6 +71,17 @@ test('renders Tabs', async () => {
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
});
test('recommends a registered client route for the default URL', async () => {
await asyncRender(createProps());
userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
expect(await screen.findByText('/dashboard/{id}/')).toBeInTheDocument();
expect(
screen.queryByText('/superset/dashboard/{id}/'),
).not.toBeInTheDocument();
});
test('can sync columns from source', async () => {
const testProps = createProps();
await asyncRender({
@@ -468,7 +468,7 @@ function SliceAdder({
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
style={{ width, height }}
style={{ width, height, maxHeight: height }}
rowCount={filteredSlices.length}
rowHeight={DEFAULT_CELL_HEIGHT}
rowProps={listRowProps}
@@ -105,10 +105,17 @@ export const SamplesPane = ({
1,
)
.then(response => {
setData(ensureIsArray(response.data));
setColnames(ensureIsArray(response.colnames));
setColtypes(ensureIsArray(response.coltypes));
setRowCount(response.rowcount);
// A 200 that carries no `result` payload resolves to undefined here.
// Read through it so the pane falls back to its empty state instead
// of throwing a TypeError that surfaces as an internal error message.
const rows = ensureIsArray(response?.data);
setData(rows);
setColnames(ensureIsArray(response?.colnames));
setColtypes(ensureIsArray(response?.coltypes));
// Fall back to the rows actually returned rather than to zero: the
// controls only render when there are rows, and a hardcoded 0 would
// label a populated table as "0 rows".
setRowCount(response?.rowcount ?? rows.length);
setResponseError('');
cache.set(queryFormData, true);
if (queryForce) {
@@ -60,6 +60,27 @@ describe('SamplesPane', () => {
400,
);
// A 200 response that carries no `result` payload, as reported in #36840.
fetchMock.post(
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=37&per_page=100&page=1',
{},
);
// A 200 whose result carries rows but omits `rowcount`.
fetchMock.post(
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=38&per_page=100&page=1',
{
result: {
data: [
{ __timestamp: 1230768000000, genre: 'Action' },
{ __timestamp: 1230768000010, genre: 'Horror' },
],
colnames: ['__timestamp', 'genre'],
coltypes: [2, 1],
},
},
);
const setForceQuery = jest.fn();
afterAll(() => {
@@ -114,4 +135,29 @@ describe('SamplesPane', () => {
expect(queryByText('Action')).toBeVisible();
expect(queryByText('Horror')).toBeVisible();
});
test('renders the empty state when the response carries no result payload', async () => {
const props = createSamplesPaneProps({ datasourceId: 37 });
const { findByText, queryByRole } = render(<SamplesPane {...props} />, {
useRedux: true,
});
expect(
await findByText('No samples were returned for this dataset'),
).toBeVisible();
// The pane should not leak an internal TypeError through the error alert.
expect(queryByRole('alert')).not.toBeInTheDocument();
});
test('counts the returned rows when the response omits rowcount', async () => {
const props = createSamplesPaneProps({ datasourceId: 38 });
const { findByText, queryByText } = render(<SamplesPane {...props} />, {
useRedux: true,
});
expect(await findByText('Action')).toBeVisible();
// Falling back to 0 here would label a populated table as "0 rows".
expect(queryByText('0 rows')).not.toBeInTheDocument();
expect(queryByText('2 rows')).toBeVisible();
});
});
@@ -148,7 +148,7 @@ export const DatasourceItems = ({
return (
<List
style={{ width: width - BORDER_WIDTH, height }}
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
rowHeight={rowHeight}
rowCount={flattenedItems.length}
rowProps={rowProps}
@@ -251,4 +251,11 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
],
],
[GenericDataType.MultiValue]: [
[
'columnWidth',
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
],
['truncateLongCells'],
],
};
@@ -207,38 +207,6 @@ describe('AdhocFilter', () => {
expect(adhocFilter10.isValid()).toBe(true);
});
test('is invalid when a comparator-taking operator has no comparator', () => {
// A comparator that was never set, or that was cleared through the value
// Select's clear affordance, is `undefined` rather than `null` or `[]`.
const adhocFilter1 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'is_intro',
operator: 'IN',
comparator: undefined,
clause: Clauses.Where,
});
expect(adhocFilter1.isValid()).toBe(false);
const adhocFilter2 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'is_intro',
operator: '==',
comparator: undefined,
clause: Clauses.Where,
});
expect(adhocFilter2.isValid()).toBe(false);
// `false` is a legitimate boolean comparator, not a missing value
const adhocFilter3 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'is_intro',
operator: '==',
comparator: false,
clause: Clauses.Where,
});
expect(adhocFilter3.isValid()).toBe(true);
});
test('can translate from simple expressions to sql expressions', () => {
const adhocFilter1 = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
@@ -302,6 +270,74 @@ describe('AdhocFilter', () => {
});
expect(adhocFilter.comparator).toBe(undefined);
});
// Charts saved before #32701 persisted `==` as the operation for IS_TRUE and
// IS_FALSE, alongside a boolean comparator. `translateToSql` and the backend
// both key off `operator`, so dropping the comparator would render such a
// filter as `col =` and query it as `col IS NULL`.
test('keeps the legacy boolean comparator for IS_TRUE', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsTrue,
comparator: true,
clause: Clauses.Where,
});
expect(adhocFilter.operator).toBe('==');
expect(adhocFilter.comparator).toBe(true);
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
});
test('keeps the legacy boolean comparator for IS_FALSE', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsFalse,
comparator: false,
clause: Clauses.Where,
});
expect(adhocFilter.operator).toBe('==');
expect(adhocFilter.comparator).toBe(false);
expect(adhocFilter.translateToSql()).toBe("col = 'FALSE'");
});
test('restores the boolean even when the stored comparator is missing', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsTrue,
clause: Clauses.Where,
});
expect(adhocFilter.comparator).toBe(true);
});
test('keeps a legacy boolean filter intact when the control re-posts it', () => {
const stored = {
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.IsTrue,
comparator: true,
clause: Clauses.Where,
};
// DndFilterSelect wraps props.value and hands those instances to onChange
const posted = JSON.parse(JSON.stringify(new AdhocFilter(stored)));
expect(posted.operator).toBe('==');
expect(posted.comparator).toBe(true);
expect(posted.operatorId).toBe(Operators.IsTrue);
});
test('leaves a genuine equality filter on a boolean value alone', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'col',
operator: '==',
operatorId: Operators.Equals,
comparator: true,
clause: Clauses.Where,
});
expect(adhocFilter.operator).toBe('==');
expect(adhocFilter.comparator).toBe(true);
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
});
test('sets the label properly if subject is a string', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
@@ -30,6 +30,15 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
// Charts saved before #32701 store `==` for IS_TRUE/IS_FALSE with the boolean
// in the comparator; blanking it makes them query `col IS NULL`. Restoring it
// leaves the emitted SQL untouched -- reconciling `operator` to `IS TRUE`
// would not, and Druid rejects that predicate on VARCHAR columns.
const LEGACY_BOOLEAN_COMPARATORS = new Map<string, boolean>([
[Operators.IsTrue, true],
[Operators.IsFalse, false],
]);
interface AdhocFilterInput {
expressionType?: string;
subject?: string | { column_name?: string; [key: string]: unknown } | null;
@@ -77,6 +86,16 @@ export default class AdhocFilter {
) {
this.comparator = undefined;
}
if (
this.operator ===
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation &&
adhocFilter.operatorId &&
LEGACY_BOOLEAN_COMPARATORS.has(adhocFilter.operatorId)
) {
this.comparator = LEGACY_BOOLEAN_COMPARATORS.get(
adhocFilter.operatorId,
);
}
this.clause = adhocFilter.clause || Clauses.Where;
this.sqlExpression = null;
} else if (this.expressionType === ExpressionTypes.Sql) {
@@ -163,10 +182,8 @@ export default class AdhocFilter {
// A non-empty array of values ('IN' or 'NOT IN' clauses)
return this.comparator.length > 0;
}
// A value has been selected or typed. An unset comparator is
// `undefined` rather than `null`: picking a new subject resets it, and
// the value Select's clear affordance emits `undefined` too.
return this.comparator != null;
// A value has been selected or typed
return this.comparator !== null;
}
}
@@ -181,29 +181,6 @@ describe('AdhocFilterEditPopover', () => {
expect(saveButton).toBeDisabled();
});
test('disables save button when a boolean column has no value selected', async () => {
const booleanColumn = { type: 'BOOL', column_name: 'is_intro' };
renderPopover({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
clause: Clauses.Where,
}),
options: [booleanColumn],
datasource: { columns: [booleanColumn], filter_select: false },
});
// Picking the subject resets the comparator to `undefined`; the value
// control is then left untouched, mirroring the reported repro.
await userEvent.click(screen.getByTestId('select-element'));
await userEvent.click(
await screen.findByRole('option', { name: /is_intro/ }),
);
expect(
screen.getByTestId('adhoc-filter-edit-popover-save-button'),
).toBeDisabled();
});
test('initiates resize when resize handle is dragged', async () => {
const onResize = jest.fn();
renderPopover({ onResize });
@@ -367,8 +367,22 @@ function AdhocFilterEditPopover({
</ErrorBoundary>
),
},
...(datasource?.type === 'semantic_view'
? []
...(datasource?.type === 'semantic_view' ||
[
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
Operators.LengthEquals,
Operators.LengthGreaterThan,
Operators.LengthLessThan,
Operators.LengthGreaterThanOrEqual,
Operators.LengthLessThanOrEqual,
].includes(adhocFilter.operatorId as Operators)
? // Hide the Custom SQL tab for element-level array operators: they
// have no portable SQL representation, and converting one would
// silently turn the filter into invalid raw SQL.
[]
: [
{
key: ExpressionTypes.Sql,
@@ -35,6 +35,7 @@ import {
} from 'src/explore/constants';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import fetchMock from 'fetch-mock';
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
@@ -252,6 +253,78 @@ test('shows boolean only operators when subject is number', () => {
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
});
test('shows array operators (tier 1 + tier 2) when subject is multi-value', () => {
const props = setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'skills',
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
}),
datasource: {
columns: [
{
id: 3,
column_name: 'skills',
type: 'Array(String)',
type_generic: GenericDataType.MultiValue,
},
],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
// Tier 1 (whole-array) + Tier 2 (element-level) are all relevant.
[
Operators.Equals,
Operators.NotEquals,
Operators.In,
Operators.NotIn,
Operators.IsNull,
Operators.IsNotNull,
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
].forEach(operator =>
expect(isOperatorRelevant(operator, 'skills')).toBe(true),
);
// scalar-only operators are hidden for array columns
[Operators.GreaterThan, Operators.LessThan, Operators.Like].forEach(
operator => expect(isOperatorRelevant(operator, 'skills')).toBe(false),
);
});
test('hides element-level array operators for non multi-value columns', () => {
const props = setup({
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
operatorId: undefined,
operator: undefined,
comparator: undefined,
clause: undefined,
}),
datasource: {
columns: [{ id: 3, column_name: 'value', type: 'STRING' }],
},
});
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
[
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
].forEach(operator =>
expect(isOperatorRelevant(operator, 'value')).toBe(false),
);
});
test('will convert from individual comparator to array if the operator changes to multi', () => {
const props = setup();
const { onOperatorChange } = useSimpleTabFilterProps(
@@ -309,6 +382,49 @@ test('will convert from array to individual comparators if the operator changes
);
});
test('resets the comparator when switching between array value families', () => {
// Equal to (whole-array literal) -> Contains all (individual elements):
// the value spaces are incompatible, so the stale value must be cleared.
const wholeArrayFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'scores',
operatorId: Operators.Equals,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
comparator: '[5,6,7]',
clause: Clauses.Where,
});
const props = setup({ adhocFilter: wholeArrayFilter });
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.ContainsAll);
const lastCall =
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
expect(lastCall.operatorId).toEqual(Operators.ContainsAll);
expect(lastCall.comparator).toBeUndefined();
});
test('keeps the value when switching within the element family', () => {
// Contains any <-> Contains all both take individual elements, so the
// selected elements should carry over.
const elementFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'scores',
operatorId: Operators.ContainsAny,
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.ContainsAny].operation,
comparator: ['5', '6'],
clause: Clauses.Where,
});
const props = setup({ adhocFilter: elementFilter });
const { onOperatorChange } = useSimpleTabFilterProps(
props as unknown as Props,
);
onOperatorChange(Operators.ContainsAll);
const lastCall =
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
expect(lastCall.comparator).toEqual(['5', '6']);
});
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
const props = setup();
const { onComparatorChange } = useSimpleTabFilterProps(
@@ -399,6 +515,28 @@ test('will not display boolean operators when column type is string', () => {
});
});
test.each(['STRING', 'DATE'])(
'will not display boolean operators when an expression column declares type %s',
type => {
const props = setup({
datasource: {
type: 'table' as const,
datasource_name: 'table1',
schema: 'schema',
columns: [{ column_name: 'value', type, expression: '"value"' }],
},
adhocFilter: simpleAdhocFilter,
});
const { isOperatorRelevant } = useSimpleTabFilterProps(
props as unknown as Props,
);
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(false);
});
},
);
test('will display boolean operators when column is an expression', () => {
const props = setup({
datasource: {
@@ -32,6 +32,7 @@ import {
isDefined,
SupersetClient,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { styled, useTheme, css } from '@apache-superset/core/theme';
import {
Operators,
@@ -118,6 +119,8 @@ export const useSimpleTabFilterProps = (props: Props) => {
const isColumnNumber =
!!column && (column.type === 'INT' || column.type === 'INTEGER');
const isColumnFunction = !!column && !!column.expression;
const isColumnMultiValue =
!!column && column.type_generic === GenericDataType.MultiValue;
if (operator && operator === Operators.LatestPartition) {
const { partitionColumn } = props;
@@ -127,8 +130,41 @@ export const useSimpleTabFilterProps = (props: Props) => {
// hide the TEMPORAL_RANGE operator
return false;
}
// Element-level array operators only apply to multi-value columns.
const arrayElementOperators = [
Operators.ContainsAny,
Operators.ContainsAll,
Operators.IsEmpty,
Operators.IsNotEmpty,
Operators.LengthEquals,
Operators.LengthGreaterThan,
Operators.LengthLessThan,
Operators.LengthGreaterThanOrEqual,
Operators.LengthLessThanOrEqual,
];
if (arrayElementOperators.includes(operator)) {
return isColumnMultiValue;
}
if (isColumnMultiValue) {
// Array columns support whole-array operators (=, !=, In, Not in, null
// checks) plus the element-level operators above. Scalar-only operators
// (Like, <, >, <=, >=) are hidden because they aren't valid on an array.
return [
Operators.Equals,
Operators.NotEquals,
Operators.In,
Operators.NotIn,
Operators.IsNull,
Operators.IsNotNull,
...arrayElementOperators,
].includes(operator);
}
if (operator === Operators.IsTrue || operator === Operators.IsFalse) {
return isColumnBoolean || isColumnNumber || isColumnFunction;
// An expression column may evaluate to a boolean, but that is only a
// safe assumption while its type is unknown; a declared type wins.
return (
isColumnBoolean || isColumnNumber || (isColumnFunction && !column?.type)
);
}
if (isColumnBoolean) {
return operator === Operators.IsNull || operator === Operators.IsNotNull;
@@ -167,9 +203,19 @@ export const useSimpleTabFilterProps = (props: Props) => {
].operation
: null;
if (!isDefined(operator)) {
// if operator is `null`, use the `IN` and reset the comparator.
operator = Operators.In;
operatorId = Operators.In;
// The previous operator is not relevant for the new subject; pick a
// sensible default and reset the comparator. Multi-value (array) columns
// default to "Contains any" (element membership) rather than the
// scalar-only IN.
const newColumn = props.datasource.columns?.find(
col => col.column_name === subject,
);
const defaultOperator =
newColumn?.type_generic === GenericDataType.MultiValue
? Operators.ContainsAny
: Operators.In;
operator = defaultOperator;
operatorId = defaultOperator;
comparator = undefined;
}
@@ -193,10 +239,38 @@ export const useSimpleTabFilterProps = (props: Props) => {
};
const onOperatorChange = (operatorId: Operators) => {
const currentComparator = props.adhocFilter.comparator;
// The value space differs between operator families: element-level array
// ops (Contains any/all) take individual elements, whole-array/scalar ops
// (=, In, …) take whole arrays or scalars, Length ops take a count, and the
// unary ops take nothing. A value from one family is meaningless in another,
// so reset the value when the family changes (e.g. Equal to -> Contains all).
const comparatorKind = (op?: Operators): string => {
if (!op) return 'none';
if (op === Operators.ContainsAny || op === Operators.ContainsAll) {
return 'element';
}
if (
op === Operators.LengthEquals ||
op === Operators.LengthGreaterThan ||
op === Operators.LengthLessThan ||
op === Operators.LengthGreaterThanOrEqual ||
op === Operators.LengthLessThanOrEqual
) {
return 'length';
}
if (DISABLE_INPUT_OPERATORS.includes(op)) return 'none';
return 'value';
};
const valueFamilyChanged =
comparatorKind(props.adhocFilter.operatorId as Operators | undefined) !==
comparatorKind(operatorId);
let newComparator;
// convert between list of comparators and individual comparators
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
if (MULTI_OPERATORS.has(operatorId)) {
if (valueFamilyChanged) {
newComparator = undefined;
} else if (MULTI_OPERATORS.has(operatorId)) {
// convert between list of comparators and individual comparators
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
newComparator = Array.isArray(currentComparator)
? currentComparator
: [currentComparator].filter(element => element != null);
@@ -433,19 +507,42 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
if (loadingComparatorSuggestions) {
controller.abort();
}
// Element-level array operators (Contains any / Contains all) search
// inside the array, so suggest individual elements; whole-array
// operators (=, In, …) keep the default distinct-array suggestions.
const { operatorId } = props.adhocFilter;
const arrayElements =
operatorId === Operators.ContainsAny ||
operatorId === Operators.ContainsAll;
setLoadingComparatorSuggestions(true);
SupersetClient.get({
signal,
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/`,
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
arrayElements ? '?array_elements=true' : ''
}`,
})
.then(({ json }) => {
setSuggestions(
json.result.map(
(suggestion: null | number | boolean | string) => ({
value: suggestion,
label: optionLabel(suggestion),
}),
),
json.result.map((suggestion: unknown) => {
// Complex column values arrive as JS arrays or objects: whole
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
// A raw array/object is neither a valid single-select value
// (antd collapses an array to its first element) nor renderable
// as a React child (an object throws). Render it as its literal
// string, which is also exactly what the backend's
// parse_array_literal expects for the whole-array operators.
if (suggestion !== null && typeof suggestion === 'object') {
const literal = JSON.stringify(suggestion);
return { value: literal, label: literal };
}
return {
value: suggestion as null | number | boolean | string,
label: optionLabel(
suggestion as null | number | boolean | string,
),
};
}),
);
setLoadingComparatorSuggestions(false);
})
@@ -464,6 +561,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
}, [
props.adhocFilter.subject,
props.adhocFilter.clause,
props.adhocFilter.operatorId,
props.datasource,
datePicker,
]);
@@ -44,6 +44,17 @@ export const OPERATORS_TO_SQL = {
'IS NULL': 'IS NULL',
'IS TRUE': 'IS TRUE',
'IS FALSE': 'IS FALSE',
// Element-level array operators (shown as filter labels; not executable SQL —
// the Custom SQL tab is hidden for these).
CONTAINS_ANY: 'CONTAINS ANY',
CONTAINS_ALL: 'CONTAINS ALL',
IS_EMPTY: 'IS EMPTY',
IS_NOT_EMPTY: 'IS NOT EMPTY',
LENGTH_EQUALS: 'LENGTH =',
LENGTH_GREATER_THAN: 'LENGTH >',
LENGTH_LESS_THAN: 'LENGTH <',
LENGTH_GREATER_THAN_OR_EQUALS: 'LENGTH >=',
LENGTH_LESS_THAN_OR_EQUALS: 'LENGTH <=',
'LATEST PARTITION': ({
datasource,
}: {
+52 -1
View File
@@ -45,6 +45,17 @@ export enum Operators {
IsTrue = 'IS_TRUE',
IsFalse = 'IS_FALSE',
TemporalRange = 'TEMPORAL_RANGE',
// Element-level operators for multi-value (array) columns
ContainsAny = 'CONTAINS_ANY',
ContainsAll = 'CONTAINS_ALL',
IsEmpty = 'IS_EMPTY',
IsNotEmpty = 'IS_NOT_EMPTY',
// Length (element-count) comparison operators for array columns
LengthEquals = 'LENGTH_EQUALS',
LengthGreaterThan = 'LENGTH_GREATER_THAN',
LengthLessThan = 'LENGTH_LESS_THAN',
LengthGreaterThanOrEqual = 'LENGTH_GREATER_THAN_OR_EQUALS',
LengthLessThanOrEqual = 'LENGTH_LESS_THAN_OR_EQUALS',
}
export interface OperatorType {
@@ -89,6 +100,39 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
display: t('TEMPORAL_RANGE'),
operation: 'TEMPORAL_RANGE',
},
[Operators.ContainsAny]: {
display: t('Contains any'),
operation: 'CONTAINS_ANY',
},
[Operators.ContainsAll]: {
display: t('Contains all'),
operation: 'CONTAINS_ALL',
},
[Operators.IsEmpty]: { display: t('Is empty'), operation: 'IS_EMPTY' },
[Operators.IsNotEmpty]: {
display: t('Is not empty'),
operation: 'IS_NOT_EMPTY',
},
[Operators.LengthEquals]: {
display: t('Length equals (=)'),
operation: 'LENGTH_EQUALS',
},
[Operators.LengthGreaterThan]: {
display: t('Length greater than (>)'),
operation: 'LENGTH_GREATER_THAN',
},
[Operators.LengthLessThan]: {
display: t('Length less than (<)'),
operation: 'LENGTH_LESS_THAN',
},
[Operators.LengthGreaterThanOrEqual]: {
display: t('Length greater or equal (>=)'),
operation: 'LENGTH_GREATER_THAN_OR_EQUALS',
},
[Operators.LengthLessThanOrEqual]: {
display: t('Length less or equal (<=)'),
operation: 'LENGTH_LESS_THAN_OR_EQUALS',
},
};
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
@@ -105,7 +149,12 @@ export const HAVING_OPERATORS = [
Operators.GreaterThan,
Operators.GreaterThanOrEqual,
];
export const MULTI_OPERATORS = new Set([Operators.In, Operators.NotIn]);
export const MULTI_OPERATORS = new Set([
Operators.In,
Operators.NotIn,
Operators.ContainsAny,
Operators.ContainsAll,
]);
// CUSTOM_OPERATORS will show operator in simple mode,
// but will generate customized sqlExpression
export const CUSTOM_OPERATORS = new Set([
@@ -120,6 +169,8 @@ export const DISABLE_INPUT_OPERATORS = [
Operators.LatestPartition,
Operators.IsTrue,
Operators.IsFalse,
Operators.IsEmpty,
Operators.IsNotEmpty,
];
export const sqlaAutoGeneratedMetricNameRegex =
@@ -82,3 +82,14 @@ test('Should handle boolean true comparator as a string value', () => {
"subject operator 'TRUE'",
);
});
test('Should render array-literal comparators as-is (not quoted)', () => {
// Whole-array = filter: the pasted array literal is shown unquoted.
expect(getSimpleSQLExpression('ingredients', '=', "['1 large egg']")).toBe(
"ingredients = ['1 large egg']",
);
// IN with multiple array literals.
expect(
getSimpleSQLExpression('ingredients', Operators.In, ["['a']", "['b']"]),
).toBe(`ingredients ${Operators.In} (['a'], ['b'])`);
});
@@ -461,10 +461,15 @@ export const getSimpleSQLExpression = (
if (comparatorArray.length > 0 && showComparator) {
const formattedComparators = comparatorArray
.map(val => optionLabel(val))
.map(
val =>
`${quote}${isString ? String(val).replace(/'/g, "''") : val}${quote}`,
);
.map(val => {
// Array-literal values (e.g. ['a', 'b']) are shown as-is rather than
// quoted/escaped as a string, so array-column filters read naturally.
const asString = String(val);
if (asString.startsWith('[') && asString.endsWith(']')) {
return asString;
}
return `${quote}${isString ? asString.replace(/'/g, "''") : val}${quote}`;
});
expression += ` ${prefix}${formattedComparators.join(', ')}${suffix}`;
}
}
@@ -31,7 +31,12 @@ import {
import { Group, Role, UserObject } from 'src/pages/UsersList/types';
import { Actions } from 'src/constants';
import { BaseUserListModalProps, FormValues } from './types';
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
import {
createUser,
updateUser,
atLeastOneRoleOrGroup,
handleUserError,
} from './utils';
export interface UserModalProps extends BaseUserListModalProps {
roles: Role[];
@@ -51,36 +56,6 @@ function UserListModal({
}: UserModalProps) {
const { addDangerToast, addSuccessToast } = useToasts();
const handleFormSubmit = async (values: FormValues) => {
const handleError = async (
err: any,
action: Actions.CREATE | Actions.UPDATE,
) => {
let errorMessage =
action === Actions.CREATE
? t('There was an error creating the user. Please, try again.')
: t('There was an error updating the user. Please, try again.');
if (err.status === 422) {
const errorData = await err.json();
const detail = errorData?.message || '';
if (detail.includes('duplicate key value')) {
if (detail.includes('ab_user_username_key')) {
errorMessage = t(
'This username is already taken. Please choose another one.',
);
} else if (detail.includes('ab_user_email_key')) {
errorMessage = t(
'This email is already associated with an account. Please choose another one.',
);
}
}
}
addDangerToast(errorMessage);
throw err;
};
if (isEditMode) {
if (!user) {
throw new Error('User is required in edit mode');
@@ -89,14 +64,14 @@ function UserListModal({
await updateUser(user.id, values);
addSuccessToast(t('The user has been updated successfully.'));
} catch (err) {
await handleError(err, Actions.UPDATE);
await handleUserError(err as Response, Actions.UPDATE, addDangerToast);
}
} else {
try {
await createUser(values);
addSuccessToast(t('The user has been created successfully.'));
} catch (err) {
await handleError(err, Actions.CREATE);
await handleUserError(err as Response, Actions.CREATE, addDangerToast);
}
}
};
@@ -0,0 +1,99 @@
/**
* 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 { Actions } from 'src/constants';
import { handleUserError } from './utils';
test('shows the password validation message from a 400 response', async () => {
const error = new Response(
JSON.stringify({
message: {
password: ['Password must be at least 8 characters long.'],
},
}),
{ status: 400 },
);
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'Password must be at least 8 characters long.',
);
});
test('shows a plain string message from a 400 response', async () => {
const error = new Response(
JSON.stringify({ message: 'User must have at least one role or group!' }),
{ status: 400 },
);
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.UPDATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'User must have at least one role or group!',
);
});
test('keeps the duplicate username message for a 422 response', async () => {
const error = new Response(
JSON.stringify({
message:
'duplicate key value violates unique constraint "ab_user_username_key"',
}),
{ status: 422 },
);
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'This username is already taken. Please choose another one.',
);
});
test('shows the generic message when a 422 response has no message', async () => {
const error = new Response(JSON.stringify({ foo: 'bar' }), { status: 422 });
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'There was an error creating the user. Please, try again.',
);
});
test('shows the generic message when a 400 response is not JSON', async () => {
const error = new Response('<html>Bad request</html>', {
status: 400,
headers: { 'Content-Type': 'text/html' },
});
const addDangerToast = jest.fn();
await expect(
handleUserError(error, Actions.CREATE, addDangerToast),
).rejects.toBe(error);
expect(addDangerToast).toHaveBeenCalledWith(
'There was an error creating the user. Please, try again.',
);
});
+40 -1
View File
@@ -17,10 +17,49 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
import { SelectOption } from 'src/components/ListView';
import { Actions } from 'src/constants';
import { FormValues } from './types';
type AddDangerToast = (message: string) => void;
export const handleUserError = async (
err: Response,
action: Actions.CREATE | Actions.UPDATE,
addDangerToast: AddDangerToast,
): Promise<never> => {
let errorMessage =
action === Actions.CREATE
? t('There was an error creating the user. Please, try again.')
: t('There was an error updating the user. Please, try again.');
if (err.status === 400 || err.status === 422) {
const errorData = await getClientErrorObject(err);
const message: unknown = errorData.message;
if (err.status === 400 && message && errorData.error) {
errorMessage = errorData.error;
} else if (
err.status === 422 &&
errorData.error?.includes('duplicate key value')
) {
if (errorData.error.includes('ab_user_username_key')) {
errorMessage = t(
'This username is already taken. Please choose another one.',
);
} else if (errorData.error.includes('ab_user_email_key')) {
errorMessage = t(
'This email is already associated with an account. Please choose another one.',
);
}
}
}
addDangerToast(errorMessage);
throw err;
};
export const createUser = async (values: FormValues) => {
const { confirmPassword: _confirmPassword, ...payload } = values;
if (payload.active == null) {
@@ -1157,6 +1157,34 @@ test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DAT
});
});
test('legacy dashboard default URLs use the registered client route', async () => {
const dataset = {
...mockDatasets[0],
explore_url: '/superset/dashboard/123/?standalone=1#section',
};
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(
mockAdminUser,
{},
{
common: {
conf: {
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true,
},
},
},
);
const datasetLink = await screen.findByRole('link', {
name: dataset.table_name,
});
expect(datasetLink).toHaveAttribute(
'href',
'/dashboard/123/?standalone=1#section',
);
});
// Note: These delete error tests verify that the modal doesn't open when fetching
// related_objects fails. The component's openDatasetDeleteModal error handler
// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no
@@ -54,10 +54,18 @@ import {
const APP_ROOT = '/superset';
const renderUnderSubdirectory = () => {
const renderUnderSubdirectory = (preventUnsafeDefaultUrls = false) => {
const defaultState = createDefaultStoreState(mockAdminUser);
const store = createMockStore({
...createDefaultStoreState(mockAdminUser),
...defaultState,
user: mockAdminUser,
common: {
...defaultState.common,
conf: {
...defaultState.common?.conf,
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: preventUnsafeDefaultUrls,
},
},
});
return render(
<Provider store={store}>
@@ -115,6 +123,31 @@ test('explore link is single-prefixed under a subdirectory deployment', async ()
expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset');
});
test('legacy dashboard default URL uses the router basename once', async () => {
// A subdirectory user pastes the full browser path, so the saved value
// carries both the application root and the legacy `/superset` prefix.
// stripAppRoot removes the root and the legacy normalization removes the
// prefix, leaving the basename to re-add the root exactly once.
const dataset = {
...mockDatasets[0],
explore_url: `${APP_ROOT}/superset/dashboard/123/?standalone=1#section`,
};
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderUnderSubdirectory(true);
const dashboardLink = await screen.findByRole('link', {
name: dataset.table_name,
});
expect(dashboardLink).toHaveAttribute(
'href',
`${APP_ROOT}/dashboard/123/?standalone=1#section`,
);
expect(dashboardLink.getAttribute('href')).not.toContain(
'/superset/superset',
);
});
test('external default_endpoint passes through unprefixed', async () => {
const dataset = {
...mockDatasets[0],
@@ -87,7 +87,6 @@ import withToasts from 'src/components/MessageToasts/withToasts';
import { Icons } from '@superset-ui/core/components/Icons';
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
import {
PAGE_SIZE,
SORT_BY,
@@ -114,6 +113,10 @@ import type {
} from 'src/types/bootstrapTypes';
import type User from 'src/types/User';
// Keep saved Default URLs compatible with the prefix-free SPA route.
const normalizeLegacyDashboardUrl = (url: string) =>
url.replace(/^\/superset(?=\/dashboard(?:\/|$))/, '');
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
type DatasetExtra = {
certification?: {
@@ -722,7 +725,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
// Router basename, which re-prefixes the root — so strip it here to
// avoid a doubled `/superset/superset/...`. External
// `default_endpoint` URLs pass through unchanged.
const exploreTo = stripAppRoot(exploreURL);
const exploreTo = normalizeLegacyDashboardUrl(
stripAppRoot(exploreURL),
);
let titleLink: JSX.Element;
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
titleLink = (
+4 -4
View File
@@ -28,7 +28,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"globals": "^17.11.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
@@ -2053,9 +2053,9 @@
}
},
"node_modules/globals": {
"version": "17.9.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
"version": "17.11.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -36,7 +36,7 @@
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.9.0",
"globals": "^17.11.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
-3
View File
@@ -280,9 +280,6 @@ def test_sqlalchemy_dialect(
"""
Test the SQLAlchemy dialect, making sure it supports everything Superset needs.
"""
if "future" not in engine_kwargs:
engine_kwargs["future"] = True
engine = create_engine(sqlalchemy_uri, **engine_kwargs)
dialect = engine.dialect
+1 -1
View File
@@ -227,7 +227,7 @@ class BaseStreamingCSVExportCommand(BaseCommand):
delimiter = csv_export_config.get("sep", ",")
decimal_separator = csv_export_config.get("decimal", ".")
with db.session(future=True) as session:
with db.session() as session:
# Merge database to prevent DetachedInstanceError
merged_database = session.merge(database)
+16 -5
View File
@@ -291,19 +291,30 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
),
None,
)
# Replaces x-axis column values with granularity
# Point the x-axis at the overridden Time Column (granularity).
if x_axis_column:
if isinstance(x_axis_column, dict):
# Only swap the underlying expression, keeping the
# column's original label. The temporal offset join
# (``processing_time_offsets``), the post-processing
# pivot ``index`` and the frontend all reference this
# column by its label; renaming it to the granularity
# here desynchronizes those consumers from the label
# the saved chart still advertises, which — with a Time
# Comparison offset — collapses the series into a single
# point.
x_axis_column["sqlExpression"] = granularity
x_axis_column["label"] = granularity
else:
# A bare string x-axis has no distinct label, so it is
# replaced wholesale and the pivot ``index`` must be
# realigned to the overridden column.
query_object.columns = [
granularity if column == x_axis_column else column
for column in query_object.columns
]
for post_processing in query_object.post_processing:
if post_processing.get("operation") == "pivot":
post_processing["options"]["index"] = [granularity]
for post_processing in query_object.post_processing:
if post_processing.get("operation") == "pivot":
post_processing["options"]["index"] = [granularity]
# If no temporal x-axis, then get the default temporal filter
if not filter_to_remove:
+81 -2
View File
@@ -17,6 +17,7 @@
# pylint: disable=invalid-name
from __future__ import annotations
import inspect
import logging
from datetime import datetime
from pprint import pformat
@@ -205,8 +206,86 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def _set_post_processing(
self, post_processing: list[dict[str, Any] | None] | None
) -> None:
post_processing = post_processing or []
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
self.post_processing = [
self._drop_unsupported_options(post_proc)
for post_proc in post_processing or []
if post_proc
]
@staticmethod
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
"""
Drop options that the post-processing operation no longer accepts.
A chart's ``query_context`` is written when the chart is saved and is
never rewritten afterwards, while Explore rebuilds the query from
``form_data`` at every render. A chart saved by an older version of
Superset can therefore reference an option that has since been removed
from the operation. ``exec_post_processing`` passes the stored options
as keyword arguments, so that option raises a bare ``TypeError`` on
every path that replays the stored ``query_context`` -- the chart data
endpoint, alerts and reports, thumbnails, CSV export -- while the same
chart still renders correctly in Explore.
Comparing against the signature avoids a hard-coded list of removed
option names, which would need extending at each release.
"""
operation = post_proc.get("operation")
function = (
getattr(pandas_postprocessing, operation, None)
if isinstance(operation, str)
else None
)
if function is None:
# A missing or unknown operation is left untouched, so that
# exec_post_processing reports it as InvalidPostProcessingError.
return post_proc
parameters = inspect.signature(function).parameters
if any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
):
return post_proc
# `exec_post_processing` calls the operation as `operation(df, **options)`,
# so an option can only reach a parameter that a caller may fill by
# keyword. That excludes the first parameter, which receives the
# DataFrame positionally, and any positional-only or `*args` parameter.
keyword_parameters = {
name
for position, (name, parameter) in enumerate(parameters.items())
if position > 0
and parameter.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
}
options = post_proc.get("options") or {}
unsupported = {key for key in options if key not in keyword_parameters}
if not unsupported:
return post_proc
# Logged at info: a chart saved before the option was removed hits this
# on every render, so a warning would repeat for as long as the chart
# is not resaved, without anything new to report.
logger.info(
"Dropping unsupported option(s) %s of post-processing operation "
"`%s`. The chart's stored query_context predates the current "
"signature of that operation.",
sorted(unsupported),
operation,
)
return {
**post_proc,
"options": {
key: value
for key, value in options.items()
if key in keyword_parameters
},
}
def _init_series_columns(
self,
+7 -1
View File
@@ -957,7 +957,13 @@ class AnnotationDatasource(BaseDatasource):
def get_query_str(self, query_obj: QueryObjectDict) -> str:
raise NotImplementedError()
def values_for_column(self, column_name: str, limit: int = 10000) -> list[Any]:
def values_for_column(
self,
column_name: str,
limit: int = 10000,
denormalize_column: bool = False,
array_elements: bool = False,
) -> list[Any]:
raise NotImplementedError()
+5
View File
@@ -133,6 +133,9 @@ class DatasourceRestApi(BaseSupersetApi):
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
denormalize_column = not datasource.normalize_columns
# Element-level operators (Contains any / Contains all) request the
# distinct array *elements* rather than distinct whole arrays.
array_elements = parse_boolean_string(request.args.get("array_elements"))
# Cache distinct column-value results so a dashboard with many filters
# backed by the same (often heavy) virtual dataset doesn't re-execute
@@ -165,6 +168,7 @@ class DatasourceRestApi(BaseSupersetApi):
"col": column_name,
"limit": row_limit,
"denorm": denormalize_column,
"elements": array_elements,
"rls": security_manager.get_rls_cache_key(datasource),
"changed_on": str(getattr(datasource, "changed_on", "")),
},
@@ -189,6 +193,7 @@ class DatasourceRestApi(BaseSupersetApi):
column_name=column_name,
limit=row_limit,
denormalize_column=denormalize_column,
array_elements=array_elements,
)
except KeyError:
return self.response(
+111 -1
View File
@@ -55,7 +55,13 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql import literal_column, quoted_name, text
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
from sqlalchemy.sql.expression import (
BinaryExpression,
ColumnClause,
ColumnElement,
Select,
TextClause,
)
from sqlalchemy.types import TypeEngine
from superset import db
@@ -528,6 +534,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
time_groupby_inline = False
limit_method = LimitMethod.FORCE_LIMIT
supports_multivalues_insert = False
# Whether this engine supports first-class multi-value (array-typed) columns.
# When True, array columns are classified as ``GenericDataType.MULTI_VALUE`` and
# 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
allows_joins = True
allows_subqueries = True
allows_alias_in_select = True
@@ -2571,6 +2582,105 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
logger.error(ex, exc_info=True)
raise
@classmethod
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
"""
Build a boolean expression testing whether array column ``col`` contains
**any** of ``values`` (element-level membership, like ``IN``). Engines
that set ``supports_multivalue_columns = True`` must override this with
their native function (e.g. ClickHouse ``hasAny``).
:param col: SQLAlchemy column element for the array column
:param values: element values to look for inside the array
:return: a SQLAlchemy boolean expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
"""
Build a boolean expression testing whether array column ``col`` contains
**all** of ``values``. Engines that set
``supports_multivalue_columns = True`` must override this with their
native function (e.g. ClickHouse ``hasAll``).
:param col: SQLAlchemy column element for the array column
:param values: element values that must all be present
:return: a SQLAlchemy boolean expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_length(cls, col: ColumnElement) -> ColumnElement:
"""
Build a numeric expression returning the number of elements in array
column ``col``. Engines that set ``supports_multivalue_columns = True``
must override this with their native array-length function. Used both for
the ``Length`` filter and the ``Is empty`` / ``Is not empty`` operators.
:param col: SQLAlchemy column element for the array column
:return: a SQLAlchemy numeric expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_literal(cls, values: list[Any]) -> ColumnElement:
"""
Build an array-literal expression from ``values`` (e.g. ClickHouse
``array(v1, v2)`` == ``[v1, v2]``). Used for the whole-array (column-
level) operators ``=`` / ``!=`` / ``IN`` / ``NOT IN`` where the array is
compared as a single value. Engines that set
``supports_multivalue_columns = True`` must override this.
:param values: element values that make up the array
:return: a SQLAlchemy array-literal expression
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def array_explode(cls, col: ColumnElement) -> ColumnElement:
"""
Build an expression that expands array column ``col`` into one row per
element (e.g. ClickHouse ``arrayJoin``). Used to source **element-level**
value suggestions (``SELECT DISTINCT array_explode(col)``) for the
``Contains any`` / ``Contains all`` filter operators, so the picker offers
individual elements rather than whole arrays. Engines that set
``supports_multivalue_columns = True`` must override this.
:param col: SQLAlchemy column element for the array column
:return: a SQLAlchemy expression yielding one element per row
"""
raise NotImplementedError(
f"{cls.engine} does not support multi-value (array) columns"
)
@classmethod
def get_array_element_type( # pylint: disable=unused-argument
cls, native_type: str | None
) -> GenericDataType | None:
"""
Return the generic type of an array column's **element** type, derived
from its native type string (e.g. ClickHouse ``Array(Int32)`` ->
``NUMERIC``), or ``None`` when the engine has no array support or the
element type cannot be resolved.
Callers use this to coerce filter values to the element type before
building array expressions, so, for example, a ``Contains any`` filter on
a numeric array compares against numbers rather than quoted strings.
:param native_type: native column type string of the array column
:return: the element's :class:`GenericDataType`, or ``None``
"""
return None
@classmethod
def get_column_spec( # pylint: disable=unused-argument
cls,
+62 -4
View File
@@ -26,8 +26,9 @@ from flask import current_app as app
from flask_babel import gettext as __
from marshmallow import fields, Schema
from marshmallow.validate import Range
from sqlalchemy import types
from sqlalchemy import func, types
from sqlalchemy.engine.url import URL
from sqlalchemy.sql.expression import ColumnElement
from urllib3.exceptions import NewConnectionError
from superset.databases.utils import make_url_safe
@@ -55,6 +56,7 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
time_groupby_inline = True
supports_multivalues_insert = True
supports_multivalue_columns = True
# ClickHouse doesn't support IS true/false syntax, use = true/false instead
use_equality_for_boolean_filters = True
@@ -128,12 +130,18 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
column_type_mappings = (
(
re.compile(r".*Enum.*", re.IGNORECASE),
# Anchor to the start so only top-level arrays match. This must be
# ordered before the ``Enum`` entry below: ``Array(Enum8(...))`` is a
# real array and should classify as MULTI_VALUE, not STRING. The
# anchor also prevents over-matching nested arrays such as
# ``Map(String, Array(String))`` or ``Tuple(Array(String))``, which
# are not themselves array columns and must keep their own type.
re.compile(r"^Array\(", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
GenericDataType.MULTI_VALUE,
),
(
re.compile(r".*Array.*", re.IGNORECASE),
re.compile(r".*Enum.*", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
@@ -174,6 +182,56 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
),
)
@classmethod
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
# ClickHouse: hasAny(arr, [v1, v2]) -> 1 if arr shares any element.
# func.array(*values) renders as array(v1, v2) == [v1, v2].
return func.hasAny(col, func.array(*values))
@classmethod
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
# ClickHouse: hasAll(arr, [v1, v2]) -> 1 if arr contains all elements.
return func.hasAll(col, func.array(*values))
@classmethod
def array_length(cls, col: ColumnElement) -> ColumnElement:
# ClickHouse: length(arr) -> number of elements
return func.length(col)
@classmethod
def array_literal(cls, values: list[Any]) -> ColumnElement:
# ClickHouse: array(v1, v2) is equivalent to the literal [v1, v2].
return func.array(*values)
@classmethod
def array_explode(cls, col: ColumnElement) -> ColumnElement:
# ClickHouse: arrayJoin(arr) yields one row per element, so
# SELECT DISTINCT arrayJoin(arr) returns the distinct elements.
return func.arrayJoin(col)
# Matches the element type inside a top-level ``Array(...)`` column, e.g.
# ``Array(Int32)`` -> ``Int32``, ``Array(Nullable(String))`` -> ``String``.
_ARRAY_ELEMENT_RE = re.compile(r"^Array\((?P<inner>.+)\)$", re.IGNORECASE)
# Element-type wrappers that don't change the underlying generic type.
_ELEMENT_WRAPPER_RE = re.compile(
r"^(?:Nullable|LowCardinality)\((?P<inner>.+)\)$", re.IGNORECASE
)
@classmethod
def get_array_element_type(cls, native_type: str | None) -> GenericDataType | None:
if not native_type:
return None
match = cls._ARRAY_ELEMENT_RE.match(native_type.strip())
if not match:
return None
inner = match.group("inner").strip()
# Peel wrappers (Nullable/LowCardinality) that don't alter the generic
# type so the inner scalar type drives classification.
while wrapper := cls._ELEMENT_WRAPPER_RE.match(inner):
inner = wrapper.group("inner").strip()
spec = cls.get_column_spec(inner)
return spec.generic_type if spec else None
@classmethod
def epoch_to_dttm(cls) -> str:
return "{col}"
-1
View File
@@ -389,7 +389,6 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
}
}
},
future=True,
)
conn = engine.connect()
idx = 0
+16 -1
View File
@@ -63,6 +63,7 @@ from superset.mcp_service.utils import (
sanitize_for_llm_context,
)
from superset.mcp_service.utils.response_utils import humanize_timestamp
from superset.sql.parse import has_aggregate
from superset.utils import json
@@ -386,13 +387,27 @@ class CreateDatasetMetric(BaseModel):
"""Metric definition for dataset creation."""
metric_name: str = Field(..., description="Name of the metric")
expression: str = Field(..., description="SQL expression for the metric")
expression: str = Field(
...,
description="Aggregate SQL expression for the metric, e.g. SUM(amount)",
)
verbose_name: str | None = None
description: str | None = None
metric_type: str | None = None
d3format: str | None = None
warning_text: str | None = None
@field_validator("expression")
@classmethod
def expression_must_aggregate(cls, value: str) -> str:
if not has_aggregate(value):
raise ValueError(
"saved metrics must aggregate rows; wrap a row-level column in "
"an aggregate such as MAX(column), or omit the saved metric and "
"use the dataset column directly"
)
return value
class CreateDatasetCalculatedColumn(BaseModel):
"""Calculated column definition for dataset creation."""
@@ -21,6 +21,7 @@ from typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.exceptions import SupersetGenericDBErrorException
from superset.extensions import event_logger
from superset.mcp_service.dataset.schemas import (
CreateVirtualDatasetRequest,
@@ -67,14 +68,17 @@ def _cleanup_failed_dataset(dataset_id: int) -> None:
def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> Any:
from superset.commands.dataset.exceptions import DatasetUpdateFailedError
from superset.commands.dataset.exceptions import (
DatasetInvalidError,
DatasetUpdateFailedError,
)
from superset.commands.dataset.update import UpdateDatasetCommand
try:
return UpdateDatasetCommand(dataset_id, update_props).run()
except Exception as exc:
_cleanup_failed_dataset(dataset_id)
if not isinstance(exc, DatasetUpdateFailedError):
if not isinstance(exc, (DatasetInvalidError, DatasetUpdateFailedError)):
raise DatasetUpdateFailedError() from exc
raise
@@ -89,7 +93,7 @@ def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> An
destructiveHint=False,
),
)
async def create_virtual_dataset(
async def create_virtual_dataset( # noqa: C901
request: CreateVirtualDatasetRequest, ctx: Context
) -> CreateVirtualDatasetResponse:
"""Save a SQL query as a virtual dataset so it can be charted.
@@ -213,6 +217,18 @@ async def create_virtual_dataset(
url=None,
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
)
except SupersetGenericDBErrorException as exc:
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
return CreateVirtualDatasetResponse(
id=None,
dataset_name=request.dataset_name,
sql=request.sql,
database_id=request.database_id,
columns=[],
url=None,
error=f"Dataset SQL could not be executed: {exc}",
)
except Exception as exc:
await ctx.error(
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
+2 -2
View File
@@ -376,7 +376,7 @@ def upgrade_catalog_perms(engines: set[str] | None = None) -> None:
"""
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# The Database model has an eager-loaded (``lazy="joined"``) ``ssh_tunnel``
# backref. Eager-loading it here would SELECT every column on ``ssh_tunnels``,
@@ -581,7 +581,7 @@ def downgrade_catalog_perms(engines: set[str] | None = None) -> None:
WARNING: models (datasets and charts) not in the default catalog are deleted!
"""
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# See upgrade_catalog_perms: avoid eager-loading the ``ssh_tunnel`` backref so the
# query stays schema-safe across migration revisions.
@@ -51,7 +51,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
op.add_column("slices", sa.Column("perm", sa.String(length=2000), nullable=True))
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# Use Slice class defined here instead of models.Slice
for slc in session.query(Slice).all():
@@ -59,7 +59,7 @@ def upgrade():
)
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# don't use models.DruidMetric
# because it assumes the context is consistent with the application
@@ -94,7 +94,7 @@ class Dashboard(AuditMixin, Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
objects = session.query(Slice).all()
objects += session.query(Dashboard).all()
@@ -50,7 +50,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
op.add_column("slices", sa.Column("datasource_id", sa.Integer()))
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
if slc.druid_datasource_id:
@@ -63,7 +63,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
if slc.datasource_type == "druid":
slc.druid_datasource_id = slc.datasource_id
@@ -45,7 +45,7 @@ class Database(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for obj in session.query(Database).all():
obj.allow_run_sync = True
@@ -48,7 +48,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
slices = session.query(Slice).all()
slice_len = len(slices)
@@ -61,7 +61,7 @@ class Url(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
urls = session.query(Url).all()
urls_len = len(urls)
@@ -45,7 +45,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).filter(Slice.viz_type.like("deck_%")):
params = json.loads(slc.params)
@@ -45,7 +45,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).filter(
or_(Slice.viz_type.like("line"), Slice.viz_type.like("bar"))
@@ -75,7 +75,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).filter(
or_(Slice.viz_type.like("line"), Slice.viz_type.like("bar"))
@@ -46,7 +46,7 @@ class Dashboard(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards):
@@ -68,7 +68,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards):
@@ -57,7 +57,7 @@ def upgrade():
),
)
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# Use Slice class defined here instead of models.Slice
for tbl in session.query(Table).all():
@@ -49,7 +49,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
slices = session.query(Slice).filter_by(viz_type="cal_heatmap").all()
slice_len = len(slices)
@@ -45,7 +45,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -63,7 +63,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -45,7 +45,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -68,7 +68,7 @@ class Database(Base):
def replace(source, target):
with db.Session(bind=op.get_bind(), future=True) as session:
with db.Session(bind=op.get_bind()) as session:
with session.begin():
query = (
session.query(Slice, Database)
@@ -80,7 +80,7 @@ def replace(source, target):
for slc, database in query:
try:
engine = create_engine(database.sqlalchemy_uri, future=True)
engine = create_engine(database.sqlalchemy_uri)
if engine.dialect.identifier_preparer._double_percents:
params = json.loads(slc.params)
@@ -50,7 +50,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -66,7 +66,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -47,7 +47,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).filter(Slice.viz_type == "pie").all():
try:
@@ -68,7 +68,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).filter(Slice.viz_type == "pie").all():
try:
@@ -134,7 +134,7 @@ def compute_time_compare(granularity, periods):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for chart in session.query(Slice):
params = json.loads(chart.params or "{}")
@@ -163,7 +163,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for chart in session.query(Slice):
params = json.loads(chart.params or "{}")
@@ -159,7 +159,7 @@ class TableColumn(BaseColumnMixin, Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
tables = [
Annotation,
@@ -59,7 +59,7 @@ class TableColumn(BaseColumnMixin, Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# Delete the orphaned columns records.
for record in session.query(DruidColumn).all():
@@ -59,7 +59,7 @@ class SqlMetric(BaseMetricMixin, Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# Delete the orphaned metrics records.
for record in session.query(DruidMetric).all():
@@ -579,7 +579,7 @@ def scan_dashboard_positions_data(positions):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards):
@@ -55,7 +55,7 @@ def is_v2_dash(positions):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards): # noqa: B007
@@ -46,7 +46,7 @@ class Dashboard(Base):
def upgrade(): # noqa: C901
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards):
@@ -75,7 +75,7 @@ def upgrade_slice(slc):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
filter_box_slices = session.query(Slice).filter_by(viz_type="filter_box")
for slc in filter_box_slices.all():
@@ -90,7 +90,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
filter_box_slices = session.query(Slice).filter_by(viz_type="filter_box")
for slc in filter_box_slices.all():
@@ -87,7 +87,7 @@ def upgrade():
bind = op.get_bind()
insp = sa.engine.reflection.Inspector.from_engine(bind)
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
tables = session.query(SqlaTable).all()
for table in tables:
@@ -159,7 +159,7 @@ class TableColumn(BaseColumnMixin, Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
tables = [
Annotation,
@@ -62,7 +62,7 @@ def add_parent_ids(node, layout):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards):
@@ -88,7 +88,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
dashboards = session.query(Dashboard).all()
for i, dashboard in enumerate(dashboards):
@@ -47,7 +47,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -94,7 +94,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -46,7 +46,7 @@ class DashboardSlices(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
# find dup records in dashboard_slices tbl
dup_records = (
@@ -71,7 +71,7 @@ def upgrade():
op.add_column("tables", Column("schema_perm", String(length=1000), nullable=True))
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for t in session.query(Sqlatable).all():
db_name = (
t.database.verbose_name
@@ -161,7 +161,7 @@ down_revision = "11c737c17cc6"
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
tables = [
Annotation,
@@ -48,7 +48,7 @@ class Slice(Base):
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:
@@ -63,7 +63,7 @@ def upgrade():
def downgrade():
bind = op.get_bind()
session = db.Session(bind=bind, future=True)
session = db.Session(bind=bind)
for slc in session.query(Slice).all():
try:

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