Compare commits

..
Author SHA1 Message Date
rusackasandClaude Opus 4.8 aaa9584782 fix(charts): reject a type-only update that flips datasource_type away from table
A chart update payload that sets datasource_type without also sending
datasource_id skipped the non-table rejection entirely, since the guard
only checked datasource_type when datasource_id was also present. That
left Slice.datasource unable to resolve the chart, since its relationship
only ever joins on datasource_type == 'table'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 00:08:18 -07:00
rusackasandClaude Opus 4.8 e82adfbf5d test: ensure saved_query cleanup runs even if the chart-create assertion fails
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 19:34:33 -07:00
rusackasandClaude Opus 4.8 d4df7ca02c fix(charts): don't clobber the datasource_type-required error
Skip the non-table datasource_type guard entirely when datasource_type
is empty, so the existing "Datasource type is required" message isn't
overwritten by "Datasource type is invalid" for the same field key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 23:13:02 -07:00
Evan RusackasandClaude Sonnet 5 f7503213e2 fix(charts): reject non-table datasource_type instead of crashing
Slice.datasource only ever resolves the "table" relationship, so a
chart created (or repointed via update) with datasource_type
"saved_query" or "query" would either crash outright or "succeed" as
a chart that can never actually render:

- "saved_query": SavedQuery has no .name attribute, so validate()
  crashes with an unhandled AttributeError -- surfaced to API clients
  as an opaque 500 "Fatal error" (fixes #29697).
- "query": Query has a synthetic .name property (used for CTAS table
  naming, not a real display name), so this one doesn't crash -- it
  silently creates a permanently broken chart instead.

CreateChartCommand and UpdateChartCommand now reject both up front
with the existing DatasourceTypeInvalidError (422), matching the
pattern already used for this same class of problem in
explore/utils.py and dataset/duplicate.py, rather than adding a new
one-off error type.

Adds unit tests for both commands (TDD: written first against
unfixed code to confirm they reproduce the two distinct failure
modes above, then the fix, then confirmed green) and an integration
test reproducing the original bug report's exact API call shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:41:33 -07:00
17 changed files with 587 additions and 640 deletions
+3 -3
View File
@@ -58,10 +58,10 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.9",
"@storybook/addon-docs": "^10.5.8",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.15",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.9",
"storybook": "^10.5.8",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
+28 -28
View File
@@ -3789,7 +3789,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/select@~1.10.0", "@rc-component/select@~1.10.1":
"@rc-component/select@~1.10.0":
version "1.10.1"
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
@@ -3824,7 +3824,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/table@~1.11.1":
"@rc-component/table@~1.11.0":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
@@ -3866,7 +3866,7 @@
"@rc-component/util" "^1.7.0"
clsx "^2.1.1"
"@rc-component/tree-select@~1.16.1":
"@rc-component/tree-select@~1.16.0":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
@@ -4122,23 +4122,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.9.tgz#6d871977f7ad833dc142d12ee43490105dec4d07"
integrity sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==
"@storybook/addon-docs@^10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.9"
"@storybook/csf-plugin" "10.5.8"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.9"
"@storybook/react-dom-shim" "10.5.8"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz#805e4c93a1704b220351d62bb0c74ce3b78c5e10"
integrity sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==
"@storybook/csf-plugin@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
dependencies:
unplugin "^2.3.5"
@@ -4152,10 +4152,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz#549793845bb8b966acd36002d33d7b54cc5c92d4"
integrity sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==
"@storybook/react-dom-shim@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -6181,10 +6181,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
antd@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6217,16 +6217,16 @@ antd@^6.6.1:
"@rc-component/rate" "~1.0.1"
"@rc-component/resize-observer" "^1.1.2"
"@rc-component/segmented" "~1.3.0"
"@rc-component/select" "~1.10.1"
"@rc-component/select" "~1.10.0"
"@rc-component/slider" "~1.1.1"
"@rc-component/steps" "~1.2.2"
"@rc-component/switch" "~1.0.3"
"@rc-component/table" "~1.11.1"
"@rc-component/table" "~1.11.0"
"@rc-component/tabs" "~1.12.0"
"@rc-component/tooltip" "~1.5.0"
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/tree-select" "~1.16.1"
"@rc-component/tree-select" "~1.16.0"
"@rc-component/trigger" "^3.10.1"
"@rc-component/upload" "~1.1.1"
"@rc-component/util" "^1.12.0"
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.9:
version "10.5.9"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.9.tgz#61f476fd73785dcf09e9198ddf404b9b8c06964a"
integrity sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==
storybook@^10.5.8:
version "10.5.8"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
+196 -12
View File
@@ -86,7 +86,7 @@
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -218,7 +218,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -15715,9 +15715,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
"version": "2.11.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -18559,9 +18559,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"version": "1.11.22",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -19140,10 +19140,11 @@
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -42985,7 +42986,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -43145,6 +43146,189 @@
"version": "0.20.3",
"license": "Apache-2.0"
},
"plugins/legacy-plugin-chart-calendar": {
"name": "@superset-ui/legacy-plugin-chart-calendar",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3-array": "^3.2.4",
"d3-selection": "^3.0.0",
"d3-tip": "^0.9.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-chord": {
"name": "@superset-ui/legacy-plugin-chart-chord",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
}
},
"plugins/legacy-plugin-chart-country-map": {
"name": "@superset-ui/legacy-plugin-chart-country-map",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-array": "^3.2.4",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-horizon": {
"name": "@superset-ui/legacy-plugin-chart-horizon",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-paired-t-test": {
"name": "@superset-ui/legacy-plugin-chart-paired-t-test",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"distributions": "^2.2.0",
"prop-types": "^15.8.1",
"reactable": "^1.1.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-parallel-coordinates": {
"name": "@superset-ui/legacy-plugin-chart-parallel-coordinates",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3v3": "npm:d3@3.5.17",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-partition": {
"name": "@superset-ui/legacy-plugin-chart-partition",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-hierarchy": "^3.1.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-rose": {
"name": "@superset-ui/legacy-plugin-chart-rose",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"nvd3-fork": "^2.0.5",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-world-map": {
"name": "@superset-ui/legacy-plugin-chart-world-map",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-array": "^3.2.4",
"datamaps": "^0.5.10",
"prop-types": "^15.8.1",
"tinycolor2": "^1.6.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-preset-chart-nvd3": {
"name": "@superset-ui/legacy-preset-chart-nvd3",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.4.12",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"nvd3-fork": "^2.0.5",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.21",
"react": "^18.3.0"
}
},
"plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
+2 -2
View File
@@ -163,7 +163,7 @@
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -295,7 +295,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -67,7 +67,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -73,7 +73,6 @@ import {
getLegendProps,
getMinAndMaxFromBounds,
getOverMaxHiddenFormatter,
getTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
@@ -759,22 +758,6 @@ export default function transformProps(
const { setDataMask = () => {}, onContextMenu } = hooks;
const alignTicks = yAxisIndex !== yAxisIndexB;
// Weekly grains: pin the ticks to the buckets. Both queries share the axis.
// Skipped when a timeseries annotation is shown: it widens the axis past the
// buckets and ECharts clips pinned ticks to the extent, leaving that span bare.
const hasTimeseriesAnnotation = annotationLayers.some(
(layer: AnnotationLayer) =>
layer.show && isTimeseriesAnnotationLayer(layer),
);
const temporalTickValues = hasTimeseriesAnnotation
? undefined
: getTemporalTickValues(
[...rebasedDataA, ...rebasedDataB],
xAxisLabel,
xAxisType,
resolvedTimeGrain,
);
const echartOptions: EChartsCoreOption = {
useUTC: true,
grid: {
@@ -787,33 +770,19 @@ export default function transformProps(
nameGap: xAxisTitleMarginPx,
nameLocation: 'middle',
axisLabel: {
// Pinned ticks label every bucket, so keep thinning on even when the
// showMaxLabel/rotation branch would otherwise drop it.
hideOverlap:
!!temporalTickValues ||
(showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
// Skipped for pinned ticks: the boundary buckets are already real
// ticks there, and showMaxLabel only shields its immediate
// neighbour — hideOverlap can still drop it against a farther
// label on a crowded weekly axis, reopening #39899.
...(showMaxLabel &&
!temporalTickValues && {
showMaxLabel: true,
alignMaxLabel: 'right',
showMinLabel: true,
alignMinLabel: 'left',
}),
...(temporalTickValues && { customValues: temporalTickValues }),
...(showMaxLabel && {
showMaxLabel: true,
alignMaxLabel: 'right',
showMinLabel: true,
alignMinLabel: 'left',
}),
},
// Gridlines, when shown, follow axisTick.customValues too.
...(temporalTickValues && {
axisTick: { customValues: temporalTickValues },
}),
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
@@ -88,7 +88,6 @@ import {
getHorizontalLegendAvailableWidth,
getLegendProps,
getMinAndMaxFromBounds,
getTemporalTickValues,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import {
@@ -1244,23 +1243,6 @@ export default function transformProps(
})()
: xAxisFormatter;
// Weekly grains: pin the ticks to the buckets ECharts would otherwise miss.
// A timeseries annotation contributes its own timestamps and widens the axis
// past the buckets, and ECharts clips pinned ticks to the extent, so that
// span would render bare — leave those charts on ECharts' own ticks.
const hasTimeseriesAnnotation = annotationLayers.some(
(layer: AnnotationLayer) =>
layer.show && isTimeseriesAnnotationLayer(layer),
);
const temporalTickValues = hasTimeseriesAnnotation
? undefined
: getTemporalTickValues(
rebasedData,
xAxisLabel,
xAxisType,
resolvedTimeGrain,
);
let xAxis: any = {
type: xAxisType,
name: xAxisTitle,
@@ -1276,13 +1258,10 @@ export default function transformProps(
// have less overlap, so disabling hideOverlap is safe.
// At 0° rotation, also disable hideOverlap when showMaxLabel
// is active so the forced boundary label is never suppressed
// by ECharts' overlap detection (#39899). Pinned ticks label
// every bucket, which does crowd, so thinning always wins there.
hideOverlap:
!!temporalTickValues ||
(showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
// by ECharts' overlap detection (#39899).
hideOverlap: showMaxLabel
? false
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
formatter: deduplicatedFormatter,
rotate: xAxisLabelRotation,
interval: xAxisLabelInterval,
@@ -1290,23 +1269,14 @@ export default function transformProps(
// and last dates stay visible: hideOverlap can hide the last label,
// and a min date that falls between "nice" ticks otherwise renders
// no beginning label. Skipped when rotated to avoid phantom labels
// at the axis boundary. Also skipped for pinned ticks: the boundary
// buckets are already real ticks there, and showMaxLabel only shields
// its immediate neighbour — hideOverlap can still drop it against a
// farther label on a crowded weekly axis, reopening #39899.
...(showMaxLabel &&
!temporalTickValues && {
showMaxLabel: true,
alignMaxLabel: 'right',
showMinLabel: true,
alignMinLabel: 'left',
}),
...(temporalTickValues && { customValues: temporalTickValues }),
// at the axis boundary.
...(showMaxLabel && {
showMaxLabel: true,
alignMaxLabel: 'right',
showMinLabel: true,
alignMinLabel: 'left',
}),
},
// Gridlines, when shown, follow axisTick.customValues too.
...(temporalTickValues && {
axisTick: { customValues: temporalTickValues },
}),
minorTick: { show: minorTicks },
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
@@ -89,16 +89,6 @@ export const StackControlOptionsWithoutStream: [
[StackControlsValue.Stack, t('Stack')],
];
// Grains ECharts' time axis cannot tick on; see getTemporalTickValues in
// utils/series.
export const WEEKLY_TIME_GRAINS: ReadonlySet<string> = new Set([
TimeGranularity.WEEK,
TimeGranularity.WEEK_STARTING_SUNDAY,
TimeGranularity.WEEK_STARTING_MONDAY,
TimeGranularity.WEEK_ENDING_SATURDAY,
TimeGranularity.WEEK_ENDING_SUNDAY,
]);
export const TIMEGRAIN_TO_TIMESTAMP = {
[TimeGranularity.HOUR]: 3600 * 1000,
[TimeGranularity.DAY]: 3600 * 1000 * 24,
@@ -42,7 +42,6 @@ import {
NULL_STRING,
StackControlsValue,
TIMESERIES_CONSTANTS,
WEEKLY_TIME_GRAINS,
} from '../constants';
import {
EchartsTimeseriesSeriesType,
@@ -987,62 +986,6 @@ export function getAxisType(
return AxisType.Category;
}
// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats
// zone-less strings as local time — mismatch would offset the pinned tick.
const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/;
function parseTemporalString(value: string): number {
const dateOnly = DATE_ONLY_RE.exec(value);
if (dateOnly) {
const [, year, month, day] = dateOnly;
return new Date(
Number(year),
Number(month || 1) - 1,
Number(day || 1),
).getTime();
}
return new Date(value).getTime();
}
/**
* Bucket timestamps a temporal axis should tick on, or undefined to let ECharts
* choose.
*
* ECharts generates time ticks from a calendar ladder with no week unit, so for
* weekly data it steps days from the 1st of each month instead: labels drift
* across weekdays and snap to month starts (#17226). Coarser grains already land
* on their data and keep ECharts' calendar-nice labels.
*/
export function getTemporalTickValues(
data: DataRecord[],
xAxisLabel: string,
xAxisType: AxisType,
timeGrain?: string,
): number[] | undefined {
if (
xAxisType !== AxisType.Time ||
!timeGrain ||
!WEEKLY_TIME_GRAINS.has(timeGrain)
) {
return undefined;
}
const values = new Set<number>();
data.forEach(row => {
const value = row[xAxisLabel];
const timestamp =
// eslint-disable-next-line no-nested-ternary
value instanceof Date
? value.getTime()
: typeof value === 'string'
? parseTemporalString(value)
: Number(value ?? NaN);
if (Number.isFinite(timestamp)) {
values.add(timestamp);
}
});
return values.size ? [...values].sort((a, b) => a - b) : undefined;
}
export function getOverMaxHiddenFormatter(
config: {
max?: number;
@@ -1456,94 +1456,3 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
expect(html).not.toContain(longSeriesName);
});
});
describe('weekly x-axis tick alignment', () => {
const WEEK_MS = 7 * 24 * 3600 * 1000;
const MONDAYS = Array.from(
{ length: 6 },
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
);
const weeklyLabelMap = { ds: ['ds'], sum__num: ['sum__num'] };
const weeklyQuery = (timestamps: number[]) =>
createTestQueryData(
timestamps.map((ds, i) => ({ ds, sum__num: 10 + i })),
{
label_map: weeklyLabelMap,
colnames: ['ds', 'sum__num'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
);
const weeklyChartProps = (
queryA: number[],
queryB: number[],
overrides: Partial<EchartsMixedTimeseriesFormData> = {},
) =>
createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
formData: {
...formData,
groupby: [],
groupbyB: [],
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
...overrides,
},
queriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
});
test('pins ticks, labels and gridlines to the weekly buckets', () => {
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
.echartOptions as any;
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
expect(xAxis.splitLine).toBeUndefined();
});
test('keeps label thinning on when the labels are rotated', () => {
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS, MONDAYS, { xAxisLabelRotation: 45 }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('skips the showMaxLabel override at 0° rotation, unlike unpinned axes', () => {
// Pinned ticks already include the boundary buckets, and showMaxLabel
// can't reliably protect a label under hideOverlap anyway (#39899).
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
.echartOptions as any;
expect(xAxis.axisLabel.showMaxLabel).toBeUndefined();
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('covers buckets contributed by either query', () => {
// The two queries share one axis, so a bucket present in only one of them
// still needs a tick.
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS.slice(0, 3), MONDAYS.slice(2)),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('leaves grains ECharts places correctly untouched', () => {
const { xAxis } = transformProps(
weeklyChartProps(MONDAYS, MONDAYS, {
timeGrainSqla: TimeGranularity.MONTH,
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
});
@@ -17,7 +17,6 @@
* under the License.
*/
import {
AnnotationData,
AnnotationSourceType,
AnnotationStyle,
AnnotationType,
@@ -2566,240 +2565,6 @@ describe('EchartsTimeseries tooltip truncation', () => {
});
});
describe('weekly x-axis tick alignment', () => {
// 13 Monday-aligned weekly buckets, the shape produced by a dataset that is
// pre-aggregated to weeks.
const WEEK_MS = 7 * 24 * 3600 * 1000;
const MONDAYS = Array.from(
{ length: 13 },
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
);
const weeklyChartProps = (
formDataOverrides: Partial<EchartsTimeseriesFormData> = {},
annotationData?: AnnotationData,
) =>
createTestChartProps({
annotationData,
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
xAxisTimeFormat: '%m-%d',
...formDataOverrides,
},
queriesData: [
createTestQueryData(
MONDAYS.map((__timestamp, i) => ({ __timestamp, sales: 100 + i })),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
// transformProps reads annotations off the query, not chartProps.
...(annotationData && { annotation_data: annotationData }),
},
),
],
});
test('pins ticks, labels and gridlines to the weekly buckets', () => {
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
expect(xAxis.type).toBe(AxisType.Time);
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
expect(xAxis.splitLine).toBeUndefined();
});
test('skips the showMaxLabel override at 0° rotation, unlike unpinned axes', () => {
// Pinned ticks already include the boundary buckets, and showMaxLabel
// can't reliably protect a label under hideOverlap anyway (#39899).
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
expect(xAxis.axisLabel.showMaxLabel).toBeUndefined();
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('pins ticks when the bucket column holds ISO date strings', () => {
// A dataset can arrive with __timestamp serialized as an ISO string
// rather than a Date/epoch-ms value.
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
},
queriesData: [
createTestQueryData(
MONDAYS.map((__timestamp, i) => ({
__timestamp: new Date(__timestamp).toISOString(),
sales: 100 + i,
})),
{
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
},
),
],
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('keeps label thinning on when the labels are rotated', () => {
// Rotation normally turns hideOverlap off, but pinned ticks put a label on
// every bucket, so without thinning a multi-year range draws hundreds.
const { xAxis } = transformProps(
weeklyChartProps({ xAxisLabelRotation: 45 }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
expect(xAxis.axisLabel.hideOverlap).toBe(true);
});
test('leaves rotation thinning alone when the ticks are not pinned', () => {
const { xAxis } = transformProps(
weeklyChartProps({
timeGrainSqla: TimeGranularity.MONTH,
xAxisLabelRotation: 45,
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisLabel.hideOverlap).toBe(false);
});
const timeseriesLayer = (show: boolean) =>
({
name: 'my annotation',
annotationType: AnnotationType.Timeseries,
sourceType: AnnotationSourceType.Line,
style: AnnotationStyle.Solid,
show,
value: 1,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
// The annotation's own timestamps run a year past the last bucket.
const annotationRecords = {
'my annotation': {
records: [
{ ds: MONDAYS[0], y: 1 },
{ ds: MONDAYS[12] + 52 * WEEK_MS, y: 2 },
],
},
};
test('does not pin ticks when a timeseries annotation widens the axis', () => {
// A Time axis takes no min/max, so it stretches to cover the annotation
// while ECharts clips pinned ticks to the extent — that span would be bare.
const { xAxis } = transformProps(
weeklyChartProps(
{ annotationLayers: [timeseriesLayer(true)] },
annotationRecords,
),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
test('still pins ticks for a hidden timeseries annotation', () => {
const { xAxis } = transformProps(
weeklyChartProps(
{ annotationLayers: [timeseriesLayer(false)] },
annotationRecords,
),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test.each([
TimeGranularity.WEEK,
TimeGranularity.WEEK_STARTING_SUNDAY,
TimeGranularity.WEEK_STARTING_MONDAY,
TimeGranularity.WEEK_ENDING_SATURDAY,
TimeGranularity.WEEK_ENDING_SUNDAY,
])('applies to the %s grain', grain => {
const { xAxis } = transformProps(weeklyChartProps({ timeGrainSqla: grain }))
.echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('a dashboard time-grain override drives the alignment', () => {
const { xAxis } = transformProps(
weeklyChartProps({
timeGrainSqla: TimeGranularity.DAY,
extraFormData: { time_grain_sqla: TimeGranularity.WEEK },
}),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
});
test('deduplicates and sorts the bucket timestamps', () => {
// A grouped query repeats each bucket once per series, and the rows are
// not necessarily ordered.
const chartProps = createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.WEEK,
groupby: ['region'],
},
queriesData: [
createTestQueryData(
[
{ __timestamp: MONDAYS[1], region: 'b', sales: 2 },
{ __timestamp: MONDAYS[0], region: 'a', sales: 1 },
{ __timestamp: MONDAYS[1], region: 'a', sales: 3 },
{ __timestamp: MONDAYS[0], region: 'b', sales: 4 },
],
{
colnames: ['__timestamp', 'region', 'sales'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.String,
GenericDataType.Numeric,
],
},
),
],
});
const { xAxis } = transformProps(chartProps).echartOptions as any;
expect(xAxis.axisLabel.customValues).toEqual([MONDAYS[0], MONDAYS[1]]);
});
test('leaves grains ECharts places correctly untouched', () => {
(
[
TimeGranularity.DAY,
TimeGranularity.MONTH,
TimeGranularity.QUARTER,
TimeGranularity.YEAR,
undefined,
] as const
).forEach(grain => {
const { xAxis } = transformProps(
weeklyChartProps({ timeGrainSqla: grain }),
).echartOptions as any;
expect(xAxis.axisLabel.customValues).toBeUndefined();
expect(xAxis.axisTick?.customValues).toBeUndefined();
});
});
test('leaves a categorical x-axis untouched', () => {
const { xAxis } = transformProps(
weeklyChartProps({ xAxisForceCategorical: true }),
).echartOptions as any;
expect(xAxis.type).toBe(AxisType.Category);
expect(xAxis.axisLabel.customValues).toBeUndefined();
});
});
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
const marker = '<span style="background-color:#1f77b4;"></span>';
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
@@ -22,7 +22,6 @@ import {
DataRecord,
getNumberFormatter,
getTimeFormatter,
TimeGranularity,
} from '@superset-ui/core';
import { supersetTheme as theme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
@@ -41,7 +40,6 @@ import {
getLegendProps,
getOverMaxHiddenFormatter,
getMinAndMaxFromBounds,
getTemporalTickValues,
sanitizeHtml,
sortAndFilterSeries,
sortRows,
@@ -1707,114 +1705,6 @@ test('getAxisType does not coerce Numeric x-axis to Time regardless of values',
);
});
describe('getTemporalTickValues', () => {
const xAxisLabel = '__timestamp';
test('returns undefined for a non-time axis', () => {
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Category,
TimeGranularity.WEEK,
),
).toBeUndefined();
});
test('returns undefined when there is no time grain', () => {
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
expect(
getTemporalTickValues(data, xAxisLabel, AxisType.Time, undefined),
).toBeUndefined();
});
test('returns undefined for a non-weekly time grain', () => {
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.MONTH,
),
).toBeUndefined();
});
test('returns sorted, de-duplicated bucket timestamps for numbers and Dates', () => {
const t0 = Date.UTC(2026, 3, 6);
const t1 = Date.UTC(2026, 3, 13);
const data: DataRecord[] = [
{ [xAxisLabel]: t1 },
{ [xAxisLabel]: new Date(t0) },
{ [xAxisLabel]: t0 }, // duplicate of the Date row above
];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([t0, t1]);
});
test('parses a zoned ISO string as the instant it names', () => {
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00.000Z' }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([Date.UTC(2026, 3, 6)]);
});
test('parses a zone-less datetime string as local time, matching ECharts', () => {
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00' }];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([new Date(2026, 3, 6, 0, 0, 0).getTime()]);
});
test('parses a bare date string as local midnight, matching ECharts rather than native Date', () => {
// `new Date('2026-04-06')` is UTC, but ECharts parses it as local time.
// jest.config.js fixes the test TZ to America/New_York, so they disagree.
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06' }];
const localMidnight = new Date(2026, 3, 6).getTime();
expect(localMidnight).not.toEqual(new Date('2026-04-06').getTime());
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toEqual([localMidnight]);
});
test('drops unparseable or nullish values and returns undefined when none remain', () => {
const data: DataRecord[] = [
{ [xAxisLabel]: 'not-a-date' },
{ [xAxisLabel]: null },
];
expect(
getTemporalTickValues(
data,
xAxisLabel,
AxisType.Time,
TimeGranularity.WEEK,
),
).toBeUndefined();
});
});
test('getMinAndMaxFromBounds returns empty object when not truncating', () => {
expect(
getMinAndMaxFromBounds(
+12
View File
@@ -32,11 +32,13 @@ from superset.commands.chart.exceptions import (
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
)
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.commands.utils import get_datasource_by_id, populate_subjects
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.exceptions import SupersetSecurityException
from superset.utils import json
from superset.utils.core import DatasourceType
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -71,6 +73,16 @@ class CreateChartCommand(CreateMixin, BaseCommand):
# Validate/Populate datasource
try:
# Slice.datasource only ever resolves the ``table`` relationship
# (see Slice.datasource in superset/models/slice.py), so a chart
# pointed at any other datasource_type would "create"
# successfully but could never actually render. Reject those
# up front instead of failing later -- either at this lookup
# (SavedQuery/Query have no ``.name`` attribute, so accessing it
# below raises an unhandled AttributeError) or silently, by
# producing a permanently broken chart.
if datasource_type != DatasourceType.TABLE:
raise DatasourceTypeInvalidError()
datasource = get_datasource_by_id(datasource_id, datasource_type)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
+25 -9
View File
@@ -35,6 +35,7 @@ from superset.commands.chart.exceptions import (
DashboardsNotFoundValidationError,
DatasourceTypeUpdateRequiredValidationError,
)
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.commands.utils import (
compute_subjects,
get_datasource_by_id,
@@ -49,6 +50,7 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
from superset.utils import json
from superset.utils.core import DatasourceType
from superset.utils.decorators import on_error, transaction
from superset.versioning.changes.normalization import (
register_matching_normalization_context,
@@ -183,11 +185,9 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
# Validate if datasource_id is provided datasource_type is required
datasource_id = self._properties.get("datasource_id")
datasource_type = ""
if datasource_id is not None:
datasource_type = self._properties.get("datasource_type", "")
if not datasource_type:
exceptions.append(DatasourceTypeUpdateRequiredValidationError())
datasource_type = self._properties.get("datasource_type", "")
if datasource_id is not None and not datasource_type:
exceptions.append(DatasourceTypeUpdateRequiredValidationError())
# Validate/populate model exists
self._model = ChartDAO.find_by_id(self._model_id)
@@ -221,11 +221,27 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
exceptions.append(ex)
# Validate/Populate datasource
if datasource_id is not None:
# An empty datasource_type was already flagged above via
# DatasourceTypeUpdateRequiredValidationError; skip this block so
# we don't clobber that message with DatasourceTypeInvalidError.
if datasource_type:
try:
datasource = get_datasource_by_id(datasource_id, datasource_type)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
# Slice.datasource only ever resolves the ``table``
# relationship (see Slice.datasource in
# superset/models/slice.py), so setting datasource_type to
# anything else would "succeed" but leave the chart
# permanently unable to render -- even for a type-only
# update that leaves datasource_id untouched. Reject those
# up front instead of failing later -- either at the lookup
# below (SavedQuery/Query have no ``.name`` attribute, so
# accessing it raises an unhandled AttributeError) or
# silently.
if datasource_type != DatasourceType.TABLE:
raise DatasourceTypeInvalidError()
if datasource_id is not None:
datasource = get_datasource_by_id(datasource_id, datasource_type)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
except SupersetSecurityException as ex:
raise ChartForbiddenError() from ex
except ValidationError as ex:
@@ -35,12 +35,14 @@ from superset.extensions import cache_manager, db, security_manager
from superset.models.core import Database, FavStar, FavStarClassName
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import SavedQuery
from superset.reports.models import ReportSchedule, ReportScheduleType
from superset.subjects.models import Subject
from superset.subjects.types import SubjectType
from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
from superset.utils import json
from superset.utils.core import get_example_default_schema
from superset.utils.database import get_example_database
from tests.integration_tests.base_api_tests import ApiEditorsTestCaseMixin
from tests.integration_tests.base_tests import (
subjects_from_users,
@@ -660,6 +662,47 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
response = json.loads(rv.data.decode("utf-8"))
assert response == {"message": {"datasource_id": ["Datasource does not exist"]}}
def test_create_chart_from_saved_query_rejected_cleanly(self):
"""
Chart API: creating a chart with datasource_type="saved_query" must
fail with a clean validation error, not the unhandled 500 "Fatal
error" reported in apache/superset#29697. Slice.datasource only
ever resolves the "table" relationship, so even a chart that
"created" successfully with this datasource_type could never
actually render -- "saved_query" is a real, existing row here
(not a bad ID), reproducing the original report exactly rather
than a not-found case.
"""
self.login(ADMIN_USERNAME)
example_db = get_example_database()
saved_query = SavedQuery(
db_id=example_db.id,
label="issue-29697-repro",
schema=get_example_default_schema(),
sql="SELECT 1 AS value",
)
db.session.add(saved_query)
db.session.commit()
saved_query_id = saved_query.id
chart_data = {
"slice_name": "issue-29697-repro-chart",
"datasource_id": saved_query_id,
"datasource_type": "saved_query",
"viz_type": "table",
}
try:
rv = self.post_assert_metric("/api/v1/chart/", chart_data, "post")
assert rv.status_code == 422
response = json.loads(rv.data.decode("utf-8"))
assert response == {
"message": {"datasource_type": ["Datasource type is invalid"]}
}
finally:
db.session.delete(db.session.query(SavedQuery).get(saved_query_id))
db.session.commit()
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
def test_create_chart_validate_user_is_dashboard_editor(self):
"""
@@ -0,0 +1,154 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for CreateChartCommand.
Regression coverage for apache/superset#29697: POST /api/v1/chart/ with
datasource_type="saved_query" (or "query") crashes with an unhandled
AttributeError -- reported to API clients as an opaque 500 "Fatal error" --
because SavedQuery and Query models have no ``.name`` attribute, and because
Slice.datasource only ever resolves a ``table``-typed datasource, so even a
successfully created chart of another type could never actually render.
"""
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.create import CreateChartCommand
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
def _base_mocks(mocker: MockerFixture) -> None:
mocker.patch(
"superset.commands.chart.create.DashboardDAO.find_by_ids", return_value=[]
)
mocker.patch(
"superset.commands.chart.create.populate_subjects",
side_effect=lambda properties, exceptions: None,
)
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
def test_create_chart_rejects_non_table_datasource_type(
mocker: MockerFixture, datasource_type: str
) -> None:
"""A chart can only ever query a table-backed datasource -- Slice.datasource
only ever resolves the ``table`` relationship, so any other type would
produce a chart that "creates" successfully but can never render.
The two types fail differently before this fix, which is exactly why
both are covered here:
- "saved_query": SavedQuery has no ``.name`` attribute, so validation
crashes with an unhandled AttributeError -- surfaced to API clients as
an opaque 500 "Fatal error" (apache/superset#29697).
- "query": Query *does* define a synthetic ``.name`` property (used for
CTAS table naming, not as a real display name), so this one doesn't
crash -- it silently "succeeds" and creates a chart with a nonsense
name and a datasource that Slice.datasource can never resolve.
``get_datasource_by_id`` is mocked with ``spec=`` the real model classes
so accessing ``.name`` on the mock behaves exactly like the real ORM
objects do if the new guard doesn't stop the code from getting there;
``raise_for_access`` is mocked to a no-op so nothing downstream masks
that behavior.
"""
from superset.models.sql_lab import Query, SavedQuery
_base_mocks(mocker)
model_cls = SavedQuery if datasource_type == "saved_query" else Query
get_datasource_by_id = mocker.patch(
"superset.commands.chart.create.get_datasource_by_id",
return_value=mocker.MagicMock(spec=model_cls),
)
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
with pytest.raises(ChartInvalidError) as exc_info:
CreateChartCommand(
{
"datasource_id": 11,
"datasource_type": datasource_type,
"slice_name": "some_name",
"viz_type": "table",
}
).validate()
assert any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
# The invalid type must be rejected before ever touching the datasource
# lookup, not caught incidentally by some downstream failure.
get_datasource_by_id.assert_not_called()
def test_create_chart_accepts_table_datasource(mocker: MockerFixture) -> None:
"""The one supported datasource_type must keep working."""
_base_mocks(mocker)
datasource = mocker.MagicMock(name="table_datasource")
datasource.name = "my_table"
mocker.patch(
"superset.commands.chart.create.get_datasource_by_id",
return_value=datasource,
)
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
cmd = CreateChartCommand(
{
"datasource_id": 11,
"datasource_type": "table",
"slice_name": "some_name",
"viz_type": "table",
}
)
cmd.validate()
assert cmd._properties["datasource_name"] == "my_table"
def test_create_chart_datasource_access_denied_still_raises_forbidden(
mocker: MockerFixture,
) -> None:
"""The invalid-type guard must not shadow the existing access-denied path
for a legitimately table-typed datasource the user can't access."""
_base_mocks(mocker)
datasource = mocker.MagicMock()
datasource.name = "my_table"
mocker.patch(
"superset.commands.chart.create.get_datasource_by_id",
return_value=datasource,
)
mocker.patch(
"superset.commands.chart.create.security_manager.raise_for_access",
side_effect=SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="No access",
level=ErrorLevel.ERROR,
)
),
)
with pytest.raises(ChartForbiddenError):
CreateChartCommand(
{
"datasource_id": 11,
"datasource_type": "table",
"slice_name": "some_name",
"viz_type": "table",
}
).validate()
+103 -1
View File
@@ -17,8 +17,13 @@
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
DatasourceTypeUpdateRequiredValidationError,
)
from superset.commands.chart.update import UpdateChartCommand
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.utils import json
@@ -238,3 +243,100 @@ def test_update_chart_query_context_without_datasource_is_allowed(
1,
{"query_context": query_context, "query_context_generation": True},
).validate()
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
def test_update_chart_rejects_repointing_to_non_table_datasource(
mocker: MockerFixture, datasource_type: str
) -> None:
"""Repointing a chart's datasource_id must be rejected the same way
CreateChartCommand rejects it (apache/superset#29697): Slice.datasource
only ever resolves the ``table`` relationship, so repointing at a
saved_query or query datasource would "succeed" but leave the chart
permanently unable to render -- or, for saved_query specifically, crash
on SavedQuery's missing ``.name`` attribute before that point is even
reached. This is a regular (non-query-context) update, so it goes
through editorship + compute_subjects, unlike the query-context-only
tests above."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
mocker.patch(
"superset.commands.chart.update.compute_subjects",
side_effect=lambda model, properties, exceptions: None,
)
get_datasource_by_id = mocker.patch(
"superset.commands.chart.update.get_datasource_by_id"
)
with pytest.raises(ChartInvalidError) as exc_info:
UpdateChartCommand(
1, {"datasource_id": 11, "datasource_type": datasource_type}
).validate()
assert any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
get_datasource_by_id.assert_not_called()
def test_update_chart_missing_datasource_type_keeps_required_error(
mocker: MockerFixture,
) -> None:
"""When datasource_id is given without datasource_type, the response
must keep reporting DatasourceTypeUpdateRequiredValidationError
("Datasource type is required") rather than having it overwritten by
DatasourceTypeInvalidError ("Datasource type is invalid") -- both
exceptions key their message under ``datasource_type``, and
normalized_messages() only keeps the last one written for a given key."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
mocker.patch(
"superset.commands.chart.update.compute_subjects",
side_effect=lambda model, properties, exceptions: None,
)
get_datasource_by_id = mocker.patch(
"superset.commands.chart.update.get_datasource_by_id"
)
with pytest.raises(ChartInvalidError) as exc_info:
UpdateChartCommand(1, {"datasource_id": 11}).validate()
assert any(
isinstance(ex, DatasourceTypeUpdateRequiredValidationError)
for ex in exc_info.value._exceptions
)
assert not any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
get_datasource_by_id.assert_not_called()
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
def test_update_chart_rejects_type_only_non_table_datasource(
mocker: MockerFixture, datasource_type: str
) -> None:
"""A type-only update (datasource_type given without datasource_id)
must be rejected the same way a repointing update is: leaving
datasource_id untouched while flipping datasource_type away from
``table`` would still break Slice.datasource, since its relationship
only ever resolves the ``table`` type."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
mocker.patch(
"superset.commands.chart.update.compute_subjects",
side_effect=lambda model, properties, exceptions: None,
)
get_datasource_by_id = mocker.patch(
"superset.commands.chart.update.get_datasource_by_id"
)
with pytest.raises(ChartInvalidError) as exc_info:
UpdateChartCommand(1, {"datasource_type": datasource_type}).validate()
assert any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
get_datasource_by_id.assert_not_called()