Compare commits

..
Author SHA1 Message Date
Claude Code ace0fdeb55 feat(charts): validate post-processing options against operation schemas [DRAFT]
ChartDataPostProcessingOperationSchema accepted `options` as a free-form Dict;
the per-operation option schemas (aggregate, rolling, prophet, pivot, ...)
existed only for OpenAPI docs and were never wired into validation.

Add a validates_schema hook that maps each operation to its option schema and
validates `options` against it. Validation is lenient (unknown=EXCLUDE) so it
surfaces wrong types / out-of-range values on declared fields without rejecting
payloads that carry extra keys; operations without a dedicated schema are
unaffected.

DRAFT: these option schemas were never used for validation and have latent
issues (e.g. ChartDataAggregateOptionsSchema.groupby is accidentally a tuple,
so it isn't validated). Each option schema should be audited against the real
pandas_postprocessing signatures before strict (unknown=RAISE) validation is
considered, to avoid rejecting currently-valid requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:09:21 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a183582291 chore(deps): bump markdown-to-jsx from 9.8.0 to 9.8.1 in /superset-frontend (#40316)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:33:08 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3acef94ef6 chore(deps): update zod requirement from ^4.4.1 to ^4.4.3 in /superset-frontend/plugins/plugin-chart-echarts (#40313)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:32:46 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9638eecdb1 chore(deps-dev): bump oxlint from 1.65.0 to 1.66.0 in /superset-frontend (#40318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:29:45 -07:00
7e74fc4192 fix(charts): handle PostgreSQL INTERVAL type in bar and pie charts (#34513)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-20 22:26:59 -07:00
cdca6f7fdc fix(sqllab): keep saved-query list working when Jinja dataset(id) references a deleted dataset (#39703)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:19:19 -07:00
b1ca8cac6b fix(tests): fix flaky FileHandler test by awaiting LaunchQueue consumer in afterEach (#39508)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: sadpandajoe <jcli38@gmail.com>
2026-05-20 19:31:01 -07:00
2cd5efa627 ci(deps): bump lower bound on pip dependabot PRs (#40308)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-21 08:53:57 +07:00
a273fe4d62 fix(list-view): preserve user name in filter pill after navigation (#39505)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:54:49 -07:00
d203f0de33 chore(sql-lab): finish SqlLab typed-dispatch migration for SaveDatasetModal (#40040)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 16:04:38 -07:00
a75f9b67b2 chore(superset-ui-switchboard): forward-compat fixes for TypeScript 6.0 (Phase E) (#40028)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 15:37:52 -07:00
3f0858e35d chore(sql-lab): migrate useDispatch to useAppDispatch (#40037)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 15:36:27 -07:00
Beto DealmeidaandGitHub 68c145adc3 feat(semantic layers): add metadata on additive metrics (#40279) 2026-05-20 18:29:28 -04:00
4a9aecda4a fix(dashboard-import): remap chartsInScope on import (#26338) (#40140)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 13:41:14 -07:00
46b2d7d7a9 test(dashboard-import): pin native filter scope rootPath preservation (#19944) (#40135)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 12:25:34 -07:00
f8600471fa test(datasets): regression test for Jinja not rendered on sync columns (#25839) (#40224)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 11:46:36 -07:00
b23c65e04f test(charts): regression for last-modified sort order (#27500) (#40231)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 11:43:19 -07:00
aa8255c55c test(reports): regression for alerts CSV missing chart time filters (#25538) (#40232)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 11:42:52 -07:00
10b7bfc8c1 test(helpers): regression for humanize locale activation (#28331) (#40233)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 11:42:17 -07:00
89cab1860e chore(codeowners): add @rusackas as translations maintainer (#40295)
Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 11:41:58 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b7585122c8 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.3 to 8.59.4 in /superset-websocket (#40250)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 10:45:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Claude
f2d80a183e chore(deps): bump content-disposition from 1.1.0 to 2.0.0 in /superset-frontend (#40109)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-20 10:41:34 -07:00
69adecd6a3 fix(reports): enforce server-side recipient on chart/dashboard report subscriptions (#38847)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:36:42 -07:00
fbffae0444 fix(dataset-editor): drop null warning_markdown from extra JSON serialisation (#39706)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:34:03 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>ClaudeClaude Sonnet 4.6
6ce7c2e8de chore(deps-dev): bump react-resizable and @types/react-resizable in /superset-frontend (#40110)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:32:38 -07:00
SkinnyPigeonandGitHub 105820f1f4 docs(reports): playwright setup clarification (#40168) 2026-05-20 10:32:02 -07:00
92b1b0a219 ci(docs): soft-fail badge localization on transient fetch errors (#40236)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 10:21:58 -07:00
c39a47cbac test(sql-parser): pin WITH+UNION as non-mutating across dialects (#25659) (#40138)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-20 10:08:21 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dacda71f77 chore(deps-dev): bump typescript-eslint from 8.59.3 to 8.59.4 in /superset-websocket (#40251)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 09:57:53 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
12a21c8933 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.3 to 8.59.4 in /superset-frontend (#40256)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 09:57:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
13fa3810a8 chore(deps): bump react-map-gl from 8.1.0 to 8.1.1 in /superset-frontend (#40262)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 09:56:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3356f4d3e1 chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#40265)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 09:56:17 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4a17c49d74 chore(deps): bump zod from 4.4.1 to 4.4.3 in /superset-frontend (#40272)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 09:55:49 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ea1ce7140c chore(deps-dev): bump webpack from 5.106.2 to 5.107.0 in /docs (#40291)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:35:32 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
038414ea5c chore(deps-dev): bump ts-jest from 29.4.9 to 29.4.10 in /superset-websocket (#40290)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 22:34:25 +07:00
jesperctandGitHub 5bb54cc96b fix(echarts): preserve dataZoom range across setOption(notMerge) (#40173) 2026-05-20 17:33:29 +02:00
Alexandru SoareandGitHub fb276b08dd fix(mcp): Skip misleading trend analysis for categorical ASCII charts (#39761) 2026-05-20 18:04:21 +03:00
Alexandru SoareandGitHub 6e8b3bf976 fix(mcp): raise right error (#39964) 2026-05-20 14:32:45 +03:00
Alexandru SoareandGitHub 55024e8f4d feat(mcp): Add mcp_call_id to tool responses for server log correlation (#39776) 2026-05-20 14:30:22 +03:00
Alexandru SoareandGitHub b98bd2a07a fix(mcp): Block destructive DDL (DROP, TRUNCATE, ALTER) in execute_sql (#39621) 2026-05-20 14:29:15 +03:00
Alexandru SoareandGitHub 0a3a35018c fix(mcp): changed_on_humanized null in write tool responses (generate_dashboard, generate_chart) (#39488) 2026-05-20 14:08:51 +03:00
Jean MassucattoandGitHub e6179036ec fix(sqllab): handle scientific notation in big number JSON responses (#39994) 2026-05-20 07:39:47 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
81b4d580db chore(deps-dev): bump @types/node from 25.8.0 to 25.9.1 in /superset-websocket (#40249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:54:36 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9acfac1523 chore(deps-dev): bump @typescript-eslint/parser from 8.59.3 to 8.59.4 in /superset-websocket (#40252)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:53:59 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa9af6c307 chore(deps-dev): bump typescript-eslint from 8.59.3 to 8.59.4 in /docs (#40254)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:52:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fbb3056508 chore(deps): bump baseline-browser-mapping from 2.10.30 to 2.10.31 in /docs (#40255)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:51:56 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ffbce27c9b chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#40269)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:51:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fe8b218a5f chore(deps): bump mapbox-gl from 3.23.1 to 3.24.0 in /superset-frontend (#40258)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:50:31 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f5fe9bfa26 chore(deps-dev): bump ts-jest from 29.4.9 to 29.4.10 in /superset-frontend (#40260)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:50:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7f1c47521e chore(deps-dev): bump @typescript-eslint/parser from 8.59.3 to 8.59.4 in /superset-frontend (#40263)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:50:03 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0fffa74bc6 chore(deps-dev): bump tsx from 4.22.0 to 4.22.3 in /superset-frontend (#40267)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:49:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
738ebf9cc6 chore(deps-dev): bump @types/node from 25.8.0 to 25.9.1 in /superset-frontend (#40268)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 21:49:26 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
98dff2e170 chore(deps): bump yeoman-generator from 8.1.2 to 8.2.2 in /superset-frontend (#40271)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 20:03:44 -07:00
b5ad4a7a07 test(sql-parser): pin TimescaleDB hyperfunctions parse on postgresql (#32028) (#40142)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-19 19:53:33 -07:00
1230b9091b docs: hide Component Playground top-level nav item (#40247)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-19 09:32:53 -07:00
852d0182b5 fix(roles): prevent 404 and silent user removal on large role edits (#40178)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-05-19 09:13:43 -07:00
105 changed files with 3524 additions and 1002 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
# Notify translation maintainers of changes to translations
/superset/translations/ @sfirke
/superset/translations/ @sfirke @rusackas
# Notify PMC members of changes to extension-related files
+5
View File
@@ -62,6 +62,11 @@ updates:
- package-ecosystem: "pip"
directory: "/"
open-pull-requests-limit: 10
# Bump the lower bound to the new version, not just widen the upper
# bound. Without this, a `sqlglot>=28.10.0, <29` constraint upgraded
# to `<30` would keep the stale lower bound forever, dragging
# transitively-resolved versions with it. See #40186 (review thread).
versioning-strategy: increase
schedule:
interval: "weekly"
labels:
@@ -53,7 +53,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: steps.check.outputs.superset-extensions-cli
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
file: ./coverage.xml
flags: superset-extensions-cli
+1 -1
View File
@@ -128,7 +128,7 @@ jobs:
run: npx nyc merge coverage/ merged-output/coverage-summary.json
- name: Upload Code Coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: javascript
use_oidc: true
@@ -70,7 +70,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,mysql
verbose: true
@@ -164,7 +164,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,postgres
verbose: true
@@ -219,7 +219,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,sqlite
verbose: true
@@ -79,7 +79,7 @@ jobs:
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,presto
verbose: true
@@ -150,7 +150,7 @@ jobs:
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,hive
verbose: true
@@ -56,7 +56,7 @@ jobs:
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,unit
verbose: true
@@ -36,9 +36,9 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
#### In your `Dockerfile`
You'll need to extend the Superset image to include a headless browser. Your options include:
- Use Playwright with Chrome: this is the recommended approach as of version 4.1.x or greater. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
- Use Firefox: you'll need to install geckodriver and Firefox.
- Use Chrome without Playwright: you'll need to install Chrome and set the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
- Use Playwright with Chromium: this is the recommended approach as of version 4.1.x or greater. Playwright always uses Chromium — the `WEBDRIVER_TYPE` config setting has no effect when Playwright is active. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/admin-docs/installation/docker-builds#building-your-own-production-docker-image) page. Enable the `PLAYWRIGHT_REPORTS_AND_THUMBNAILS` feature flag in your config to activate it.
- Use Firefox (Selenium): you'll need to install geckodriver and Firefox. Set `WEBDRIVER_TYPE` to `"firefox"` in your `superset_config.py`.
- Use Chrome (Selenium): you'll need to install Chrome. Set `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
In Superset versions &lt;=4.0x, users installed Firefox or Chrome and that was documented here.
@@ -1,168 +0,0 @@
---
title: Dashboard Performance
hide_title: true
sidebar_position: 5
version: 1
---
<!--
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.
-->
# Dashboard Performance
A dashboard's perceived speed is determined by three independent things: how
many charts have to render, how many queries the backend can execute
concurrently, and how quickly the underlying data warehouse can return
results. Superset gives you levers for the first two; the third belongs to
your warehouse. This page covers the dashboard-side levers and the practical
guidance around them.
## Is there a maximum chart count per dashboard?
**No hard limit is enforced** — Superset has no configuration key that
caps the number of charts on a dashboard. In practice, dashboards behave
well up to a few dozen charts. Beyond that, you'll typically feel friction
on the initial load and during cross-filter / time-range updates, even with
the lazy-loading optimizations described below.
Rough thresholds to keep in mind:
- **Under ~25 charts**: usually no perceptible problem.
- **2550 charts**: still fine, but you start to want tabs to break the
page into chunks the user actually looks at.
- **Over ~50 charts**: split into multiple dashboards or use tabs
aggressively. The bottleneck is rarely Superset itself — it's the
warehouse executing dozens of queries in parallel and the browser
rendering dozens of chart frames.
These are guidelines, not guarantees. A dashboard of 100 sparkline-style
charts hitting a fast cache behaves very differently from a dashboard of
20 heavy aggregations against a cold warehouse.
## Lazy rendering — `DASHBOARD_VIRTUALIZATION`
Superset's dashboard layout is virtualized at the row level. Charts that
are far below the user's current scroll position are not rendered (and
therefore don't fetch data) until the user scrolls them into view, and they
are unmounted again if scrolled well past. This is on by default.
**Feature flag**: `DASHBOARD_VIRTUALIZATION` (default: `True`)
The flag is `stable` and marked for path-to-deprecation — meaning the
behavior will eventually be non-optional, but the flag still exists so
operators can disable it if a specific layout misbehaves.
**Behavior** (from `superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx`):
- A chart is rendered when its row scrolls within **1 viewport height** of
the visible area.
- A chart is unmounted when its row scrolls more than **4 viewport
heights** away from the visible area.
- Tabs that aren't currently selected don't render their content at all
(see below).
- The unmounting half is skipped in **embedded** mode (so an embedded
dashboard keeps its charts mounted once they've been seen, which avoids
re-fetching on scroll-up). Both halves are skipped for **headless /
bot** rendering (so screenshot / report jobs load every chart).
## Deferred data fetch — `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
By default, `DASHBOARD_VIRTUALIZATION` controls *rendering* — but charts
that don't render also don't fetch data, because Superset's chart
components issue their data request on mount. `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
is a supplementary flag that further defers the data request itself, useful
for backends where opening a connection or compiling a query is expensive
even if the result is later thrown away.
**Feature flag**: `DASHBOARD_VIRTUALIZATION_DEFER_DATA` (default: `False`)
Enable this if you see warehouse load spike on dashboard *open* even
though most charts are off-screen.
## Per-tab lazy loading
**This is on by default and has no flag.** A tab's content is not rendered
until the user activates that tab, so charts inside an unselected tab do
not fetch data on dashboard open. When the user clicks the tab, that
tab's charts mount and fetch in the normal way.
Practically: tabs are the single most effective tool for a large
dashboard. Splitting 60 charts across 4 tabs effectively turns dashboard
open into "load ~15 charts," and the remaining ones lazy-load only if the
user goes looking.
## Is there a switch to cap concurrent chart queries?
**No.** Superset does not implement a frontend-side concurrent-request
limiter. Each chart issues its own data request when it mounts, and the
browser handles parallelism (typically ~6 in-flight HTTP requests per
origin, then the rest queue). Backend throughput is bounded by your
Gunicorn worker count for synchronous query execution, or by your Celery
worker pool when [async queries](./async-queries-celery.mdx) are enabled.
If you need to throttle warehouse load, the right place is:
1. The warehouse itself (connection pool / concurrency limits).
2. Superset's Celery configuration (smaller worker pool when async
queries are on).
3. Splitting heavy charts across tabs or separate dashboards (each
dashboard load only fetches what's visible).
## Splitting strategies
When a dashboard outgrows comfortable performance, the options in order
of effort:
**1. Move sections into tabs.** Same dashboard, but only the active tab's
charts fetch. This is the cheapest change and often the only one needed.
**2. Cache aggressively.** A Redis cache backend (see
[Caching](./cache.mdx)) means repeat dashboard loads serve from cache
rather than re-hitting the warehouse. This is especially impactful for
dashboards opened by many users in close succession.
**3. Enable async queries.** [Async query execution](./async-queries-celery.mdx)
via Celery decouples query duration from request lifetime, so a slow
chart doesn't block the page. The user sees other charts come in as
their queries complete.
**4. Split into multiple dashboards.** Group related charts into purpose-
specific dashboards rather than one mega-dashboard. Link them from a
landing dashboard or a navigation menu.
**5. Pre-aggregate at the warehouse level.** If the same expensive
aggregation appears across many charts, materialize it as a view or
scheduled table in the warehouse so each chart query is a cheap lookup.
## Operational notes
- The feature flags above are set in `superset_config.py`, e.g.:
```python
FEATURE_FLAGS = {
"DASHBOARD_VIRTUALIZATION": True,
"DASHBOARD_VIRTUALIZATION_DEFER_DATA": True,
}
```
- See [Feature Flags](./feature-flags.mdx) for the full list of supported
flags and their lifecycle stages.
- Report and screenshot jobs (alerts, scheduled reports, dashboard
exports) intentionally bypass row virtualization so the rendered
artifact includes every chart, not just the ones above the fold.
+3 -3
View File
@@ -72,7 +72,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.33",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.30",
"baseline-browser-mapping": "^2.10.31",
"caniuse-lite": "^1.0.30001793",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -109,8 +109,8 @@
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.3",
"webpack": "^5.106.2"
"typescript-eslint": "^8.59.4",
"webpack": "^5.107.0"
},
"browserslist": {
"production": [
+19 -5
View File
@@ -206,12 +206,26 @@ async function downloadBadge(url, staticDir) {
badgeCache.set(url, webPath);
return webPath;
} catch (error) {
// Fail the build on badge download failure
throw new Error(
`[remark-localize-badges] Failed to download badge: ${url}\n` +
`Error: ${error.message}\n` +
`Build cannot continue with broken badges. Please fix the badge URL or remove it.`,
// Soft fallback: keep the original remote URL in the rendered output
// so the badge still appears for readers, and the docs build continues.
// External badge services (notably img.shields.io) rate-limit CI IPs
// aggressively, and a transient fetch failure shouldn't take the whole
// docs build down with it. Set REMARK_BADGES_STRICT=true to opt back
// into hard-fail-the-build behavior (e.g. for release builds where you
// want to catch genuinely broken badge URLs).
if (process.env.REMARK_BADGES_STRICT === 'true') {
throw new Error(
`[remark-localize-badges] Failed to download badge: ${url}\n` +
`Error: ${error.message}\n` +
`Build cannot continue with broken badges (REMARK_BADGES_STRICT=true).`,
);
}
console.warn(
`[remark-localize-badges] Could not localize ${url} ` +
`(${error.message}); falling back to remote URL.`,
);
badgeCache.set(url, url);
return url;
} finally {
// Clean up the in-flight tracker
inFlightDownloads.delete(url);
+1 -1
View File
@@ -70,7 +70,7 @@
}
},
"components": {
"disabled": false,
"disabled": true,
"lastVersion": "current",
"includeCurrentVersion": true,
"onlyIncludeVersions": [
+110 -127
View File
@@ -4455,22 +4455,6 @@
dependencies:
"@types/ms" "*"
"@types/eslint-scope@^3.7.7":
version "3.7.7"
resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz"
integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==
dependencies:
"@types/eslint" "*"
"@types/estree" "*"
"@types/eslint@*":
version "9.6.1"
resolved "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz"
integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==
dependencies:
"@types/estree" "*"
"@types/json-schema" "*"
"@types/estree-jsx@^1.0.0":
version "1.0.5"
resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz"
@@ -4612,7 +4596,7 @@
resolved "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz"
integrity sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==
"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
version "7.0.15"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
@@ -4828,100 +4812,100 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.59.3", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz#5d6da7e7b236b46452fa00d3904bb6f59615bfde"
integrity sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==
"@typescript-eslint/eslint-plugin@8.59.4", "@typescript-eslint/eslint-plugin@^8.59.3":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz#c67bfee32caae9cb587dce1ac59c3bf43b659707"
integrity sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==
dependencies:
"@eslint-community/regexpp" "^4.12.2"
"@typescript-eslint/scope-manager" "8.59.3"
"@typescript-eslint/type-utils" "8.59.3"
"@typescript-eslint/utils" "8.59.3"
"@typescript-eslint/visitor-keys" "8.59.3"
"@typescript-eslint/scope-manager" "8.59.4"
"@typescript-eslint/type-utils" "8.59.4"
"@typescript-eslint/utils" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.59.3", "@typescript-eslint/parser@^8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.3.tgz#f46cbc70ae0a25119ef94eac9ecd46714788e1a1"
integrity sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==
"@typescript-eslint/parser@8.59.4", "@typescript-eslint/parser@^8.59.3":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.4.tgz#77d99e3b27663e7a22cf12c3fb769db509e5e93c"
integrity sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==
dependencies:
"@typescript-eslint/scope-manager" "8.59.3"
"@typescript-eslint/types" "8.59.3"
"@typescript-eslint/typescript-estree" "8.59.3"
"@typescript-eslint/visitor-keys" "8.59.3"
"@typescript-eslint/scope-manager" "8.59.4"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
debug "^4.4.3"
"@typescript-eslint/project-service@8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.3.tgz#1be5ae152aad987a156c9a1a9b4256e75cfbbe0c"
integrity sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==
"@typescript-eslint/project-service@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.4.tgz#5830535a0e7a3ae806e2669964f47a74c4bc6b0e"
integrity sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.59.3"
"@typescript-eslint/types" "^8.59.3"
"@typescript-eslint/tsconfig-utils" "^8.59.4"
"@typescript-eslint/types" "^8.59.4"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz#91a60f66803fe9dae0696fbab2451f5723f119d2"
integrity sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==
"@typescript-eslint/scope-manager@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz#507d1258c758147dac1adee9517a205a8ac1e046"
integrity sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==
dependencies:
"@typescript-eslint/types" "8.59.3"
"@typescript-eslint/visitor-keys" "8.59.3"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
"@typescript-eslint/tsconfig-utils@8.59.3", "@typescript-eslint/tsconfig-utils@^8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz#88ca9036b42ccdd1e630cfdafd2e042c2ca6a835"
integrity sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==
"@typescript-eslint/tsconfig-utils@8.59.4", "@typescript-eslint/tsconfig-utils@^8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz#218ba229d96dde35212e3a76a7d0a6bc831398be"
integrity sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==
"@typescript-eslint/type-utils@8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz#421fb2448bdfeb301d134a01cd02503f67fd8192"
integrity sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==
"@typescript-eslint/type-utils@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz#359fc53ba39a1f1860fddda40ebe5bfe0d87faed"
integrity sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==
dependencies:
"@typescript-eslint/types" "8.59.3"
"@typescript-eslint/typescript-estree" "8.59.3"
"@typescript-eslint/utils" "8.59.3"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/utils" "8.59.4"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@typescript-eslint/types@8.59.3", "@typescript-eslint/types@^8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.3.tgz#b7ca539c5e302fdde9a7cadb73caed107ef8f2cd"
integrity sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==
"@typescript-eslint/types@8.59.4", "@typescript-eslint/types@^8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.4.tgz#c29d5c21bfbaa8347ddc677d3ac1fcd2db0f848e"
integrity sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==
"@typescript-eslint/typescript-estree@8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz#e6bb1408e00b47e431427a40268db4e86cb121ab"
integrity sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==
"@typescript-eslint/typescript-estree@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz#d005e5e1fb425526f39685594bed34a04ad755ea"
integrity sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==
dependencies:
"@typescript-eslint/project-service" "8.59.3"
"@typescript-eslint/tsconfig-utils" "8.59.3"
"@typescript-eslint/types" "8.59.3"
"@typescript-eslint/visitor-keys" "8.59.3"
"@typescript-eslint/project-service" "8.59.4"
"@typescript-eslint/tsconfig-utils" "8.59.4"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/visitor-keys" "8.59.4"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.3.tgz#f693f979deb4dc3994de03ff8b23976d625c36c5"
integrity sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==
"@typescript-eslint/utils@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.4.tgz#8ccd2b08aecc72c7efc0d7ac6695631d199d256e"
integrity sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.59.3"
"@typescript-eslint/types" "8.59.3"
"@typescript-eslint/typescript-estree" "8.59.3"
"@typescript-eslint/scope-manager" "8.59.4"
"@typescript-eslint/types" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/visitor-keys@8.59.3":
version "8.59.3"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz#820843b1b5ca4290009cf189382abcf6fe00dfa6"
integrity sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==
"@typescript-eslint/visitor-keys@8.59.4":
version "8.59.4"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz#1ac23b747b011f5cbdb449da97769f6c5f3a9355"
integrity sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==
dependencies:
"@typescript-eslint/types" "8.59.3"
"@typescript-eslint/types" "8.59.4"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -5584,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.30, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.30"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz#58915c74388b05f3b3504026194ea9fa98f6e6b6"
integrity sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.31"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
batch@0.6.1:
version "0.6.1"
@@ -7269,13 +7253,13 @@ encodeurl@~2.0.0:
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
enhanced-resolve@^5.20.0:
version "5.20.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz#323c2a70d2aa7fb4bdfd6d3c24dfc705c581295d"
integrity sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==
enhanced-resolve@^5.21.4:
version "5.21.5"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz#8f80167d009d8f01267ad61035e59fe5c94ac3a6"
integrity sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.3.0"
tapable "^2.3.3"
entities@^2.0.0:
version "2.2.0"
@@ -7391,10 +7375,10 @@ es-iterator-helpers@^1.2.1:
iterator.prototype "^1.1.4"
safe-array-concat "^1.1.3"
es-module-lexer@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz"
integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==
es-module-lexer@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c"
integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.1"
@@ -9655,10 +9639,10 @@ liquid-json@0.3.1:
resolved "https://registry.npmjs.org/liquid-json/-/liquid-json-0.3.1.tgz"
integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==
loader-runner@^4.3.1:
version "4.3.1"
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz"
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
loader-runner@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9"
integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==
loader-utils@^1.2.3:
version "1.4.2"
@@ -14128,15 +14112,15 @@ synckit@^0.11.12:
dependencies:
"@pkgr/core" "^0.2.9"
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"
integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
terser-webpack-plugin@^5.3.17, terser-webpack-plugin@^5.3.9:
version "5.3.17"
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz#75ea98876297fbb190d2fbb395e982582b859a67"
integrity sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==
terser-webpack-plugin@^5.3.9, terser-webpack-plugin@^5.5.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz#8e7caad248183ab9e91ff08a83b0fc9f0439c3c3"
integrity sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==
dependencies:
"@jridgewell/trace-mapping" "^0.3.25"
jest-worker "^27.4.5"
@@ -14407,15 +14391,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.59.3:
version "8.59.3"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.3.tgz#4a41d9007faa539a66292189e2795eeb0b9fca29"
integrity sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==
typescript-eslint@^8.59.4:
version "8.59.4"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.4.tgz#834e3b53f4d1a764a985ceb8592c4a95d6a8da7c"
integrity sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==
dependencies:
"@typescript-eslint/eslint-plugin" "8.59.3"
"@typescript-eslint/parser" "8.59.3"
"@typescript-eslint/typescript-estree" "8.59.3"
"@typescript-eslint/utils" "8.59.3"
"@typescript-eslint/eslint-plugin" "8.59.4"
"@typescript-eslint/parser" "8.59.4"
"@typescript-eslint/typescript-estree" "8.59.4"
"@typescript-eslint/utils" "8.59.4"
typescript@~6.0.3:
version "6.0.3"
@@ -14970,22 +14954,21 @@ webpack-merge@^6.0.1:
flat "^5.0.2"
wildcard "^2.0.1"
webpack-sources@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
webpack-sources@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.4.1.tgz#009d110999ebd9fb3a6fa8d32eec6f84d940e65d"
integrity sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==
webpack-virtual-modules@^0.6.2:
version "0.6.2"
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
version "5.106.2"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.2.tgz#ca8174b4fd80f055cc5a45fcc5577d6db76c8ac5"
integrity sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==
webpack@^5.107.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.107.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.107.0.tgz#9e0d8d8baf24e76f058103f4f06ac6bb528b645a"
integrity sha512-PSxeHk/dmLYZlnTU+vL1Gej6Evg5RNtl3flhxBresfznFnzxinHMzHKloHnywM/3ouQv7/AlZCswWDIkNSggUA==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
"@webassemblyjs/ast" "^1.14.1"
@@ -14995,20 +14978,20 @@ webpack@^5.106.2, webpack@^5.88.1, webpack@^5.95.0:
acorn-import-phases "^1.0.3"
browserslist "^4.28.1"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.20.0"
es-module-lexer "^2.0.0"
enhanced-resolve "^5.21.4"
es-module-lexer "^2.1.0"
eslint-scope "5.1.1"
events "^3.2.0"
glob-to-regexp "^0.4.1"
graceful-fs "^4.2.11"
loader-runner "^4.3.1"
loader-runner "^4.3.2"
mime-db "^1.54.0"
neo-async "^2.6.2"
schema-utils "^4.3.3"
tapable "^2.3.0"
terser-webpack-plugin "^5.3.17"
terser-webpack-plugin "^5.5.0"
watchpack "^2.5.1"
webpack-sources "^3.3.4"
webpack-sources "^3.4.1"
webpackbar@^7.0.0:
version "7.0.0"
@@ -92,6 +92,26 @@ class Dimension:
grain: Grain | None = None
class AggregationType(str, enum.Enum):
"""
Aggregation function applied by a metric.
Additivity (across an arbitrary set of grouping dimensions):
* ``SUM``, ``COUNT``: fully additive — sub-group sums roll up via ``sum``.
* ``MIN``, ``MAX``: roll up via ``min`` / ``max`` of sub-group values.
* ``AVG``, ``COUNT_DISTINCT``, ``OTHER``: not safely roll-uppable from
sub-aggregates without auxiliary data.
"""
SUM = "SUM"
COUNT = "COUNT"
MIN = "MIN"
MAX = "MAX"
AVG = "AVG"
COUNT_DISTINCT = "COUNT_DISTINCT"
OTHER = "OTHER"
@dataclass(frozen=True)
class Metric:
id: str
@@ -100,6 +120,7 @@ class Metric:
definition: str
description: str | None = None
aggregation: AggregationType | None = None
@dataclass(frozen=True)
@@ -17,12 +17,8 @@
* under the License.
*/
import { PropsWithChildren, ReactNode, SyntheticEvent } from 'react';
import {
ResizableBox,
ResizableBoxProps,
ResizeCallbackData,
} from 'react-resizable';
import { ReactNode, SyntheticEvent } from 'react';
import { ResizableBox, ResizeCallbackData } from 'react-resizable';
import { styled } from '@apache-superset/core/theme';
import 'react-resizable/css/styles.css';
@@ -46,14 +42,16 @@ export type Size = ResizeCallbackData['size'];
export default function ResizablePanel({
children,
heading = undefined,
heading,
initialSize = { width: 500, height: 300 },
minConstraints = [100, 100] as [number, number],
onResize,
...props
}: PropsWithChildren<Omit<ResizableBoxProps, 'width' | 'height'>> & {
}: {
children?: ReactNode;
heading?: ReactNode;
initialSize?: Size;
minConstraints?: [number, number];
onResize?: (e: SyntheticEvent, data: ResizeCallbackData) => void;
}) {
const { width, height } = initialSize;
return (
@@ -61,16 +59,14 @@ export default function ResizablePanel({
className="panel"
width={width}
height={height}
axis="both"
minConstraints={minConstraints}
onResize={
onResize
? (e: SyntheticEvent, data: ResizeCallbackData) => {
const { size } = data;
onResize(e, { ...data, size });
}
: undefined
}
{...props}
maxConstraints={[Infinity, Infinity]}
handleSize={[20, 20]}
lockAspectRatio={false}
resizeHandles={['se']}
transformScale={1}
onResize={onResize}
>
<>
{heading ? <div className="panel-heading">{heading}</div> : null}
+242 -247
View File
@@ -85,7 +85,7 @@
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
"content-disposition": "^1.1.0",
"content-disposition": "^2.0.0",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.20",
@@ -109,8 +109,8 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.23.1",
"markdown-to-jsx": "^9.8.0",
"mapbox-gl": "^3.24.0",
"markdown-to-jsx": "^9.8.1",
"match-sorter": "^8.3.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -208,12 +208,12 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^3.0.8",
"@types/react-resizable": "^4.0.0",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
@@ -222,8 +222,8 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -269,13 +269,13 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.65.0",
"oxlint": "^1.66.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
"react-resizable": "^3.1.3",
"react-resizable": "^4.0.1",
"redux-mock-store": "^1.5.4",
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
@@ -284,9 +284,9 @@
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"ts-jest": "^29.4.9",
"ts-jest": "^29.4.10",
"tscw-config": "^1.1.2",
"tsx": "^4.22.0",
"tsx": "^4.22.3",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
@@ -8646,9 +8646,9 @@
"license": "MIT"
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.65.0.tgz",
"integrity": "sha512-jDVaGNURT5pEA9qcabh6WusIoBNybOMMDPCx+EFt+gxo6rVvoUf0+73Xy5x81+ZrxU+ewk5uRBYifjy5pgkcnA==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.66.0.tgz",
"integrity": "sha512-f7kq8N51T4phpzqfBpA2qaVTI/KrkCmNwaj3t/97I/WLTDI+UhlP5GL9eER+zVxBhtlx5rKXWByJU1/zDAvyaw==",
"cpu": [
"arm"
],
@@ -8663,9 +8663,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.65.0.tgz",
"integrity": "sha512-v0z80IWNA7c9RhUydq9YprBxCVZrQ6Ixls2tdxUC1F/1FFqSfa7xTX+EJf0mj6+BKRg2zWXqWfcbJUnETlLlIw==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.66.0.tgz",
"integrity": "sha512-xu6QO71tdDS9mjmLZ3AqhtaVHBvdmsOKkYnReNNDgh+XiwnsipeQOIxbiYOOO0iAXycJ+GK0wdMSZP/2j/AmSg==",
"cpu": [
"arm64"
],
@@ -8680,9 +8680,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.65.0.tgz",
"integrity": "sha512-pL/mG/5gMzBwp1gdc5+Cwi87F9j3XRnPxHGyVj5Zd+dCEV5YkKt0L70PB3EGmEEHxgn4H+jnMS3xLuXs6mZW/Q==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.66.0.tgz",
"integrity": "sha512-HZ24VimSOC7mxuEA99e0H2FS0C1yO3+iW13jPRAk+e2njsUs3QeAXsafCDyaIrV/MirdOVez+etQNQsJE43zNQ==",
"cpu": [
"arm64"
],
@@ -8697,9 +8697,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.65.0.tgz",
"integrity": "sha512-jVTneaeuHtqTrKYnhrdH1buhnSorinvpy1sv43ayclfWx/e/DfdRWv+h1fopJcHQbYr5WMcZMmDvnfEBkPZ+1A==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.66.0.tgz",
"integrity": "sha512-awhj8ZvJrrRSnXj7V++rpZvTmnl99L6mi0B7gg7Cp7BN6cKpzuI481bHNLvXGA9GB1/oEgA3ponuyoAc6Md12A==",
"cpu": [
"x64"
],
@@ -8714,9 +8714,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.65.0.tgz",
"integrity": "sha512-8lJQ7B6RloYDUhwVdbSpwT2eKsCN5KP1Scn18ly1tytCuhXhbs0nkfKHT4jWWZBJqmynWuzd+78bF7wILrj6pw==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.66.0.tgz",
"integrity": "sha512-KQF0oVV21/FjIqkRuL8Q1vh8ECsE5+ocdH5tcqTQ4ZnYuDVoYibQUNfqBjQaUsP6UIIda5Y75Wpm5p4RgQWiWw==",
"cpu": [
"x64"
],
@@ -8731,9 +8731,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.65.0.tgz",
"integrity": "sha512-EgmZY+DeWhLLEnNl70/49j3ltA8I6X9kxMfexupWi2Vwfp6RonGsBaHtGoedLolaU37ne7eDUgoxa3CFB95GZA==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.66.0.tgz",
"integrity": "sha512-9u1rgwZSEXWb30vbFZzQ78HVXBo0WCKNwJ3a2InRUTNMRng+PUDIoSFmA+m4HdUfBaIqftShq8J8qHc+eE/Vig==",
"cpu": [
"arm"
],
@@ -8748,9 +8748,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.65.0.tgz",
"integrity": "sha512-OJMWmAYRVBCPPxnYr3j5sXRwHPh1bAuMlTStGco1Z8q3HkvSH4h+A10E9MiRNYmLhUuli5a2P5wmfj8cagiF5Q==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.66.0.tgz",
"integrity": "sha512-Ynot2HR1bHxUaNWoC280MVTDfZuaWuP3XfSMRDhyuZrVjhzoaBCVFlw8h8qeZjWKVUBhPWFIxB7AQTlK8Z2WWg==",
"cpu": [
"arm"
],
@@ -8765,9 +8765,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.65.0.tgz",
"integrity": "sha512-D8uNi50LsYKgS0vGARZDRx05TBZeSxAVdLGddSEqQLSU7xsiqdImHPEw55xq8sKA5rCc/4au/5uS7FQALWdLCg==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.66.0.tgz",
"integrity": "sha512-xCbgzciGgo+A4aQZEknsNrNiIwY7sU5SfRuMmRjPIvZAgdF34cIHiKvwOsS5XRLjlTVSFwitmq6YclTtHTfU+g==",
"cpu": [
"arm64"
],
@@ -8782,9 +8782,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.65.0.tgz",
"integrity": "sha512-IpbA8QGbwFehQhO+YaHwmoI81f93xvywpspf8HrdPCWOIeKwYfM1dhVhO4YKfZewTRRQEPY/JFjTOXTgkwhKrA==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.66.0.tgz",
"integrity": "sha512-hmo+ZB/lHkR1HdDmnziNpzSLmulnUSu10VEqX2Yex7OwvoBAbjJQLvy4gIBRV3AAwWnCvAxKp5Nv1GE6LU1QMg==",
"cpu": [
"arm64"
],
@@ -8799,9 +8799,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.65.0.tgz",
"integrity": "sha512-ZSe8HgaZdgyHSv2+/pTG68z10+OarB18CkFKQOhRs3lmmP/p2vuigedK2e9d0ztoG2DU/duJzhxXBSjy/492HQ==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.66.0.tgz",
"integrity": "sha512-2Invd4Uyy81mVooQC5FBtfxSNrvcX1OxbMlVQ6M2erRrNI2awFYF26YNW2yFxdVFZ4ffNOWKghtMjhnUPsXsVA==",
"cpu": [
"ppc64"
],
@@ -8816,9 +8816,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.65.0.tgz",
"integrity": "sha512-DcTERf++v6HyPHukKAr0JFTRqB+YeDEvqzRgNDMaz7jITPf+tlJIwRxodlAqoXMYhNVEZhXdQM5RAAYH8/oPuw==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.66.0.tgz",
"integrity": "sha512-s0iXPDQVdgayE3RGa/N2DZF7tjgg0TwEtD1sGoDxqPDGrIXgo45H0yHknT0f9A0yteASsweYZtDyTuVlM4aSag==",
"cpu": [
"riscv64"
],
@@ -8833,9 +8833,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.65.0.tgz",
"integrity": "sha512-xjhMwuFJwRh40NOBzol4gM5gqAa0xPCJU+GQLM6BydV8TbfkIA7JeyCFNhyfbE9Q/5EWcKYTx62R0cRcjP7DAA==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.66.0.tgz",
"integrity": "sha512-OekL4XFiu7RPK0JIZi8VeHgtIXPREf42t8Cy/rKEsC+P3gcqDgNAAGiyuUOpdbG4wwbfue1q4CHcCO7spSve6w==",
"cpu": [
"riscv64"
],
@@ -8850,9 +8850,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.65.0.tgz",
"integrity": "sha512-lrWSXb8JzboPWYBG6Kunt/eemvjo2oCFXktShsm3yMToY7HjzKLjxh7CljSvGnnZH9oohNFHOKc9xYpGKCPm6w==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.66.0.tgz",
"integrity": "sha512-Ga1D0kj1SFslm34ThA/BdkUlyAYEnTsXyRC4pF0C5agZSwtGdHYWMTQWemUfBGp4RCG4QWXgdO+HmmmKqOtlBg==",
"cpu": [
"s390x"
],
@@ -8867,9 +8867,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.65.0.tgz",
"integrity": "sha512-A7xfghw250m4a1sPV+q44Mow2G5bhiC9FBvhAuIhJS6QovWnqzuL5AFQPEuwOB+PM4DhABkqxVa3Iwe3Y/nFlQ==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.66.0.tgz",
"integrity": "sha512-p5jfP1wUZe/IC3qpQO84n9DRnf9g3lKRtLBlQq23ykyrDglHcVx7sWmVTlPuU6SBw8mNnPzyOn022G3XZHnlww==",
"cpu": [
"x64"
],
@@ -8884,9 +8884,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.65.0.tgz",
"integrity": "sha512-reqOun1+pWO3fW6cv7bsa8hHG0TN3t/82qPdaoJo90FwugXiMjKhZMChmH5Z01cFNRHmxN4+543Fy8478cM/iA==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.66.0.tgz",
"integrity": "sha512-vUB/sYlYZorDL1ZD+o9mRv7zbsykrrFRtmgS6R8musZqLtrPRQn1gc1eGpuX+sfdccz42STl/AqldY6XRb2upQ==",
"cpu": [
"x64"
],
@@ -8901,9 +8901,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.65.0.tgz",
"integrity": "sha512-KQpqOb/juDBO0xyloDkVDhOVxDUgAfZ2OAAVq99TJScJDzT319xry1QzB9LQohV9QGnA7p6m/XATZkMXc84lwA==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.66.0.tgz",
"integrity": "sha512-yde+6p/F59xRkGR9H1HfngWRif1QRJjynZK349l+UI0H6w9hL3G8/AVaTHFyTtLVQ56qtNbX2/5Dc77n1ovnOg==",
"cpu": [
"arm64"
],
@@ -8918,9 +8918,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.65.0.tgz",
"integrity": "sha512-xfqcOc3nJFeAd1kDY4T9d3XeJIhr00twaaW0kOAzGPyUHkruXtNJv6zz1Ra9fRtSek5VpW2Yoj5AcwPIlT0ZiQ==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.66.0.tgz",
"integrity": "sha512-O9GLucgoTdmOrbBX+EjzNe7o/Ze5TFOvXcib6bzUOtBOmj6cV+zw18NgB+cGKAkDw1Pdqs8vGkfHbbsLuDtXWg==",
"cpu": [
"arm64"
],
@@ -8935,9 +8935,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.65.0.tgz",
"integrity": "sha512-JV+pXm45p8sdgs3c7LOPAohW23optCNZETFOXUcjn6cS4PYZhEU/RI54Z5dHdMudab3nw7T48PZILthM+Q0COQ==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.66.0.tgz",
"integrity": "sha512-m3Pjwc2MfTcom4E4gOv7DyuGyt7OfGNCbmqDHd+N7EzXmP+ppHuudm2NjcA3AjV5TSeGxaguVF4SbTKHe1USYA==",
"cpu": [
"ia32"
],
@@ -8952,9 +8952,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.65.0.tgz",
"integrity": "sha512-D7L/oBbskLss21bYrRbFuIs81AiSQV+wRzwck54dOkHIlq2qu1xjLz8u6jCqGH8Fltk8bB5DLBpVhE7v/fA8XQ==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.66.0.tgz",
"integrity": "sha512-/DbBvw8UFBhja6PqudUjV4UtfsJr0Oa7jUjWVKB0g86lj/VwnPrkngn0sFql3c9RDA0O16dh7ozsXb6GjNAzBQ==",
"cpu": [
"x64"
],
@@ -13639,9 +13639,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -13796,12 +13796,14 @@
}
},
"node_modules/@types/react-resizable": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-3.0.8.tgz",
"integrity": "sha512-Pcvt2eGA7KNXldt1hkhVhAgZ8hK41m0mp89mFgQi7LAAEZiaLgm4fHJ5zbJZ/4m2LVaAyYrrRRv1LHDcrGQanA==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-4.0.0.tgz",
"integrity": "sha512-zEMfyoFP/I6L796+dIjXeAVL2q7C4vwCXwQW2be79uPZAQLsI/pU1NOVqqfVQG3FkIxr/n96KjhzdRnWGZcTcg==",
"deprecated": "This is a stub types definition. react-resizable provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
"react-resizable": "*"
}
},
"node_modules/@types/react-router": {
@@ -14121,17 +14123,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/type-utils": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/type-utils": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -14144,20 +14146,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.3",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -14172,14 +14174,14 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14190,9 +14192,9 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14207,9 +14209,9 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14221,16 +14223,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -14249,16 +14251,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3"
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14273,13 +14275,13 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -14353,16 +14355,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -14378,14 +14380,14 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.3",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -14400,14 +14402,14 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14418,9 +14420,9 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14435,9 +14437,9 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14449,16 +14451,16 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -14477,13 +14479,13 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -14618,15 +14620,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -14643,14 +14645,14 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.3",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -14665,14 +14667,14 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14683,9 +14685,9 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14700,9 +14702,9 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14714,16 +14716,16 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -14742,16 +14744,16 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3"
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14766,13 +14768,13 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -18958,9 +18960,9 @@
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.0.tgz",
"integrity": "sha512-qqGFOrKmFP1lTfG24opOJFcTMza1BqyTSUKVbMGUP5uRsBH+C00Q1loOk+JSFshyRE0ji4HtCJeNN2WHWd6PGw==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -25494,12 +25496,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/grid-index": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==",
"license": "ISC"
},
"node_modules/h3-js": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.2.1.tgz",
@@ -32882,13 +32878,13 @@
"license": "MIT"
},
"node_modules/mapbox-gl": {
"version": "3.23.1",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.23.1.tgz",
"integrity": "sha512-J5M32GunL5KcxvV3ZyLJdr7xtcQ3afAO3VjitLW0v2UVfHjXhq9NO4tkTpn4Dknqwq/pXZG7j1WBfmddLCA26w==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.0.tgz",
"integrity": "sha512-R+FdFUB3DnoE5FYASV7lGSiRyMkSblcZ2UEy7b2pt7s5ZbCxFIUPXd0E6iAFd8OdvdA2VtbvZZVylzAZNaurjA==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
"packages/pmtiles-provider",
"plugins/mapbox-gl-pmtiles-provider",
"test/build/vite",
"test/build/webpack",
"test/build/typings"
@@ -32908,7 +32904,6 @@
"earcut": "^3.0.1",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.4",
"grid-index": "^1.1.0",
"kdbush": "^4.0.2",
"martinez-polygon-clipping": "^0.8.1",
"murmurhash-js": "^1.0.0",
@@ -33043,9 +33038,9 @@
}
},
"node_modules/markdown-to-jsx": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.0.tgz",
"integrity": "sha512-NWL0vDt6BeQgCxc2kK6g2SEPJpQuacu+NJk5j4QT4wlNmbsHdudoAbTGLCIyoPhfMiEqD1OZVBsYK0hvCky/HQ==",
"version": "9.8.1",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.8.1.tgz",
"integrity": "sha512-yq70dLPkBnE2LYFtGTLfRes4qyBDS+a4wDttAA/b/BzVGrbs2e0TfCeSFrMkapCg1lsxYi+42BowuBDxLP9k4Q==",
"license": "MIT",
"engines": {
"node": ">= 18"
@@ -37475,9 +37470,9 @@
}
},
"node_modules/oxlint": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.65.0.tgz",
"integrity": "sha512-ChUuE3Q7XnAbscvT4XLMsH7HFJmLgLVv9lu+RRgFL5wSXnDqUOzTp5IS8qWDBGd/ZDSzQ2tbX8fjAmijlGLC7A==",
"version": "1.66.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.66.0.tgz",
"integrity": "sha512-N4LLxYLd94KEBqXDMDM5f+2PUpItTjDLreXe2Gn5KhjhCK4Qp2YUXaBi8Yu325ryOgKwt22m45fpD7nPOn69Yw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -37490,25 +37485,25 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.65.0",
"@oxlint/binding-android-arm64": "1.65.0",
"@oxlint/binding-darwin-arm64": "1.65.0",
"@oxlint/binding-darwin-x64": "1.65.0",
"@oxlint/binding-freebsd-x64": "1.65.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.65.0",
"@oxlint/binding-linux-arm-musleabihf": "1.65.0",
"@oxlint/binding-linux-arm64-gnu": "1.65.0",
"@oxlint/binding-linux-arm64-musl": "1.65.0",
"@oxlint/binding-linux-ppc64-gnu": "1.65.0",
"@oxlint/binding-linux-riscv64-gnu": "1.65.0",
"@oxlint/binding-linux-riscv64-musl": "1.65.0",
"@oxlint/binding-linux-s390x-gnu": "1.65.0",
"@oxlint/binding-linux-x64-gnu": "1.65.0",
"@oxlint/binding-linux-x64-musl": "1.65.0",
"@oxlint/binding-openharmony-arm64": "1.65.0",
"@oxlint/binding-win32-arm64-msvc": "1.65.0",
"@oxlint/binding-win32-ia32-msvc": "1.65.0",
"@oxlint/binding-win32-x64-msvc": "1.65.0"
"@oxlint/binding-android-arm-eabi": "1.66.0",
"@oxlint/binding-android-arm64": "1.66.0",
"@oxlint/binding-darwin-arm64": "1.66.0",
"@oxlint/binding-darwin-x64": "1.66.0",
"@oxlint/binding-freebsd-x64": "1.66.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.66.0",
"@oxlint/binding-linux-arm-musleabihf": "1.66.0",
"@oxlint/binding-linux-arm64-gnu": "1.66.0",
"@oxlint/binding-linux-arm64-musl": "1.66.0",
"@oxlint/binding-linux-ppc64-gnu": "1.66.0",
"@oxlint/binding-linux-riscv64-gnu": "1.66.0",
"@oxlint/binding-linux-riscv64-musl": "1.66.0",
"@oxlint/binding-linux-s390x-gnu": "1.66.0",
"@oxlint/binding-linux-x64-gnu": "1.66.0",
"@oxlint/binding-linux-x64-musl": "1.66.0",
"@oxlint/binding-openharmony-arm64": "1.66.0",
"@oxlint/binding-win32-arm64-msvc": "1.66.0",
"@oxlint/binding-win32-ia32-msvc": "1.66.0",
"@oxlint/binding-win32-x64-msvc": "1.66.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.22.1"
@@ -40811,9 +40806,9 @@
}
},
"node_modules/react-resizable": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
"integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.1.tgz",
"integrity": "sha512-FR25Rcfxpi1iKiC7taIrqc1Tz6VnslqM94/IrA1LxoM5C3ap2EqaKLnCit/aKrcn3H4wfzO0nFBadFBc+SzEWA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -42585,9 +42580,9 @@
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -45228,9 +45223,9 @@
}
},
"node_modules/ts-jest": {
"version": "29.4.9",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
"integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
"version": "29.4.10",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -45240,7 +45235,7 @@
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.4",
"semver": "^7.8.0",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
@@ -45441,9 +45436,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -48915,7 +48910,7 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"yeoman-generator": "^8.2.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -49388,7 +49383,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -49408,7 +49403,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -49988,7 +49983,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.3"
"zod": "^4.4.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -50098,9 +50093,9 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.23.1",
"mapbox-gl": "^3.24.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.0",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"
},
"peerDependencies": {
+11 -11
View File
@@ -166,7 +166,7 @@
"antd": "^5.26.0",
"chrono-node": "^2.9.1",
"classnames": "^2.2.5",
"content-disposition": "^1.1.0",
"content-disposition": "^2.0.0",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.20",
@@ -190,8 +190,8 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.23.1",
"markdown-to-jsx": "^9.8.0",
"mapbox-gl": "^3.24.0",
"markdown-to-jsx": "^9.8.1",
"match-sorter": "^8.3.0",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -289,12 +289,12 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^3.0.8",
"@types/react-resizable": "^4.0.0",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
@@ -303,8 +303,8 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -350,13 +350,13 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.65.0",
"oxlint": "^1.66.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
"react-resizable": "^3.1.3",
"react-resizable": "^4.0.1",
"redux-mock-store": "^1.5.4",
"source-map": "^0.7.6",
"source-map-support": "^0.5.21",
@@ -365,9 +365,9 @@
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.0",
"ts-jest": "^29.4.9",
"ts-jest": "^29.4.10",
"tscw-config": "^1.1.2",
"tsx": "^4.22.0",
"tsx": "^4.22.3",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
@@ -30,7 +30,7 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.1.2",
"yeoman-generator": "^8.2.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -76,7 +76,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.0",
"@types/lodash": "^4.17.24",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -63,7 +63,8 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
) {
return BigInt(value);
// toFixed() avoids scientific notation, which BigInt() rejects.
return BigInt(value.toFixed());
}
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
// // TODO: clean up after json-bigint>1.0.1 is released
@@ -183,6 +183,26 @@ describe('parseResponse()', () => {
expect(responseBigNumber.json.constructor).toEqual('constructor');
});
test('handles big numbers in scientific notation when `parseMethod=json-bigint`', async () => {
const mockScientificUrl = '/mock/get/scientific';
const mockScientificPayload =
'{ "big_double": 4.799703045723905e+32, "negative_big": -4.799703045723905e+32, "small": 1 }';
fetchMock.get(mockScientificUrl, mockScientificPayload);
const responseBigNumber = await parseResponse(
callApi({ url: mockScientificUrl, method: 'GET' }),
'json-bigint',
);
expect(`${responseBigNumber.json.big_double}`).toEqual(
'479970304572390500000000000000000',
);
expect(`${responseBigNumber.json.negative_big}`).toEqual(
'-479970304572390500000000000000000',
);
expect(responseBigNumber.json.small).toEqual(1);
});
test('rejects if request.ok=false', async () => {
expect.assertions(3);
const mockNotOkayUrl = '/mock/notokay/url';
@@ -95,8 +95,11 @@ class FakeMessageChannel {
const port2 = new FakeMessagePort();
port1.otherPort = port2;
port2.otherPort = port1;
this.port1 = port1;
this.port2 = port2;
// FakeMessagePort only implements the subset of MessagePort that
// Switchboard exercises; cast at the boundary so the fake satisfies
// the consumer signature without weakening the production type.
this.port1 = port1 as unknown as MessagePort;
this.port2 = port2 as unknown as MessagePort;
}
}
@@ -88,7 +88,7 @@ function isError(message: Message): message is ErrorMessage {
* Calling methods on the switchboard causes messages to be sent through the channel.
*/
export class Switchboard {
port: MessagePort;
port!: MessagePort;
name = '';
@@ -97,9 +97,9 @@ export class Switchboard {
// used to make unique ids
incrementor = 1;
debugMode: boolean;
debugMode = false;
private isInitialised: boolean;
private isInitialised = false;
constructor(params?: Params) {
if (!params) {
@@ -56,6 +56,7 @@ import {
VisualMapComponent,
LegendComponent,
DataZoomComponent,
type DataZoomComponentOption,
ToolboxComponent,
GraphicComponent,
AriaComponent,
@@ -280,12 +281,56 @@ function Echart(
const notMerge = !isDashboardRefreshing;
chartRef.current?.dispatchAction({ type: 'hideTip' });
// setOption(notMerge:true) replaces the dataZoom config, dropping any
// range the user has engaged. Preserve it across the call.
const previousZoom = notMerge
? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] })
?.dataZoom
: undefined;
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
// lazyUpdate defers render, causing tooltip crashes on stale shapes (#39247)
lazyUpdate: false,
});
if (previousZoom?.length) {
// Skip restore when the new option reshapes dataZoom (different count
// means index-based restore could land on the wrong component).
const newZoom = (
chartRef.current?.getOption() as {
dataZoom?: DataZoomComponentOption[];
}
)?.dataZoom;
if (newZoom?.length === previousZoom.length) {
const batch = previousZoom
.map((dz, dataZoomIndex) => ({
dataZoomIndex,
start: dz.start,
end: dz.end,
startValue: dz.startValue,
endValue: dz.endValue,
}))
.filter(b => {
const hasAny =
b.start !== undefined ||
b.end !== undefined ||
b.startValue !== undefined ||
b.endValue !== undefined;
if (!hasAny) return false;
// Default full-range zoom is functionally identical to the
// fresh state setOption already produces — skip the dispatch.
const isDefaultRange =
b.start === 0 &&
b.end === 100 &&
b.startValue === undefined &&
b.endValue === undefined;
return !isDefaultRange;
});
if (batch.length) {
chartRef.current?.dispatchAction({ type: 'dataZoom', batch });
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
@@ -0,0 +1,192 @@
/**
* 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 type { EChartsCoreOption } from 'echarts/core';
import { render, waitFor } from 'spec/helpers/testing-library';
const setOption = jest.fn();
const on = jest.fn();
const off = jest.fn();
const resize = jest.fn();
const dispose = jest.fn();
const dispatchAction = jest.fn();
const getOption = jest.fn();
const mockInstance = {
setOption,
on,
off,
resize,
dispose,
dispatchAction,
getOption,
getZr: () => ({ on: jest.fn(), off: jest.fn() }),
};
jest.mock('echarts/core', () => ({
__esModule: true,
use: jest.fn(),
init: jest.fn(() => mockInstance),
registerLocale: jest.fn(),
}));
jest.mock('echarts/charts', () => ({}));
jest.mock('echarts/renderers', () => ({}));
jest.mock('echarts/components', () => ({}));
jest.mock('echarts/features', () => ({}));
// eslint-disable-next-line import/first
import Echart from '../../src/components/Echart';
const renderEchart = (echartOptions: EChartsCoreOption) => {
const refs = { divRef: undefined };
return render(
<Echart
width={400}
height={300}
echartOptions={echartOptions}
refs={refs}
/>,
{ useRedux: true, useTheme: true },
);
};
beforeEach(() => {
setOption.mockClear();
on.mockClear();
off.mockClear();
resize.mockClear();
dispatchAction.mockClear();
getOption.mockReset();
});
test('preserves user dataZoom range across setOption(notMerge)', async () => {
// After the user has zoomed, ECharts reports the current dataZoom range
// via getOption().dataZoom. We expect Echart to capture this before
// setOption replaces the option payload, then restore it via dispatchAction.
getOption.mockReturnValue({
dataZoom: [{ start: 12, end: 48 }],
});
const { rerender } = renderEchart({ xAxis: {}, series: [] });
// Trigger another setOption call by changing the echartOptions reference
rerender(
<Echart
width={400}
height={300}
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
refs={{ divRef: undefined }}
/>,
);
await waitFor(() =>
expect(dispatchAction).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dataZoom',
batch: [
expect.objectContaining({ dataZoomIndex: 0, start: 12, end: 48 }),
],
}),
),
);
});
test('does not restore when no prior zoom range exists', async () => {
// Fresh chart with no engaged zoom: dataZoom config has no start/end.
getOption.mockReturnValue({
dataZoom: [{ type: 'slider', show: true }],
});
const { rerender } = renderEchart({ xAxis: {}, series: [] });
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
rerender(
<Echart
width={400}
height={300}
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
refs={{ divRef: undefined }}
/>,
);
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
const dataZoomCalls = dispatchAction.mock.calls.filter(
([action]) => action?.type === 'dataZoom',
);
expect(dataZoomCalls).toHaveLength(0);
});
test('does not restore when prior zoom is at default full range', async () => {
// ECharts populates start:0/end:100 on slider dataZoom by default, so
// every untouched timeseries would otherwise dispatch a redundant action
// on each re-render. Skip the dispatch when the range is just the default.
getOption.mockReturnValue({
dataZoom: [{ type: 'slider', show: true, start: 0, end: 100 }],
});
const { rerender } = renderEchart({ xAxis: {}, series: [] });
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
rerender(
<Echart
width={400}
height={300}
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
refs={{ divRef: undefined }}
/>,
);
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
const dataZoomCalls = dispatchAction.mock.calls.filter(
([action]) => action?.type === 'dataZoom',
);
expect(dataZoomCalls).toHaveLength(0);
});
test('does not restore when the new option reshapes dataZoom', async () => {
// 1st render starts with no engaged zoom; 2nd render captures an engaged
// range but the post-setOption dataZoom has a different count, so
// index-based restore could write to the wrong component. Skip in that case.
getOption
// 1st render: previousZoom + newZoom (no engaged values, nothing to dispatch)
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
.mockReturnValueOnce({ dataZoom: [{ type: 'slider' }] })
// 2nd render: previousZoom has user range, but newZoom has 2 entries
.mockReturnValueOnce({ dataZoom: [{ start: 12, end: 48 }] })
.mockReturnValueOnce({
dataZoom: [{ start: 12, end: 48 }, { type: 'inside' }],
});
const { rerender } = renderEchart({ xAxis: {}, series: [] });
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(1));
rerender(
<Echart
width={400}
height={300}
echartOptions={{ xAxis: {}, series: [{ type: 'line' }] }}
refs={{ divRef: undefined }}
/>,
);
await waitFor(() => expect(setOption).toHaveBeenCalledTimes(2));
const dataZoomCalls = dispatchAction.mock.calls.filter(
([action]) => action?.type === 'dataZoom',
);
expect(dataZoomCalls).toHaveLength(0);
});
@@ -27,9 +27,9 @@
],
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.23.1",
"mapbox-gl": "^3.24.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.0",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"
},
"peerDependencies": {
@@ -97,7 +97,7 @@ export function createWrapper(options?: Options) {
}
if (useDnd) {
// @ts-expect-error react-dnd types not updated for React 18
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
}
+12 -6
View File
@@ -1671,7 +1671,7 @@ export interface VizOptions {
export function createDatasource(
vizOptions: VizOptions,
): SqlLabThunkAction<Promise<unknown>> {
): SqlLabThunkAction<Promise<{ id: number }>> {
return (dispatch: AppDispatch) => {
dispatch(createDatasourceStarted());
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
@@ -1691,9 +1691,10 @@ export function createDatasource(
}),
})
.then(({ json }) => {
dispatch(createDatasourceSuccess(json as { id: number }));
const result = json as { id: number };
dispatch(createDatasourceSuccess(result));
return Promise.resolve(json);
return result;
})
.catch(error => {
getClientErrorObject(error).then(e => {
@@ -1712,7 +1713,7 @@ export function createDatasource(
export function createCtasDatasource(
vizOptions: Record<string, unknown>,
): SqlLabThunkAction<Promise<{ id: number }>> {
): SqlLabThunkAction<Promise<{ table_id: number }>> {
return (dispatch: AppDispatch) => {
dispatch(createDatasourceStarted());
return SupersetClient.post({
@@ -1720,9 +1721,14 @@ export function createCtasDatasource(
jsonPayload: vizOptions,
})
.then(({ json }) => {
dispatch(createDatasourceSuccess(json.result));
const result = json.result as { table_id: number };
// The endpoint's `result.table_id` IS the dataset id; normalize so
// createDatasourceSuccess's `${data.id}__table` resolves correctly.
// Without this, the CTAS Explore button silently produced
// `"undefined__table"` because `result.id` doesn't exist.
dispatch(createDatasourceSuccess({ id: result.table_id }));
return json.result;
return result;
})
.catch(() => {
const errorMsg = t('An error occurred while creating the data source');
@@ -19,7 +19,8 @@
import { useRef, useEffect, FC, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { logging } from '@apache-superset/core/utils';
import {
SqlLabRootState,
@@ -86,7 +87,7 @@ const EditorAutoSync: FC = () => {
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
state => state.sqlLab.editorTabLastUpdatedAt,
);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
const currentQueryEditorId = useSelector<SqlLabRootState, string>(
@@ -17,7 +17,8 @@
* under the License.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { usePrevious } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { Global } from '@emotion/react';
@@ -136,7 +137,7 @@ const EditorWrapper = ({
height,
hotkeys,
}: EditorWrapperProps) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const queryEditor = useQueryEditor(queryEditorId, [
'id',
'dbId',
@@ -17,7 +17,8 @@
* under the License.
*/
import { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useStore } from 'react-redux';
import { useStore } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry } from '@superset-ui/core';
@@ -68,7 +69,7 @@ export function useKeywords(
catalog,
schema,
});
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const hasFetchedKeywords = useRef(false);
// skipFetch is used to prevent re-evaluating memoized keywords
// due to updated api results by skip flag
@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import { JsonObject, VizType } from '@superset-ui/core';
import { VizType } from '@superset-ui/core';
import {
createCtasDatasource,
addInfoToast,
@@ -45,7 +46,7 @@ const ExploreCtasResultsButton = ({
const errorMessage = useSelector(
(state: SqlLabRootState) => state.sqlLab.errorMessage,
);
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
const dispatch = useAppDispatch();
const buildVizOptions = {
table_name: table,
@@ -56,7 +57,7 @@ const ExploreCtasResultsButton = ({
const visualize = () => {
dispatch(createCtasDatasource(buildVizOptions))
.then((data: { table_id: number }) => {
.then(data => {
const formData = {
datasource: `${data.table_id}__table`,
metrics: ['count'],
@@ -17,7 +17,8 @@
* under the License.
*/
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import URI from 'urijs';
import { pick } from 'lodash';
import { useComponentDidUpdate } from '@superset-ui/core';
@@ -49,7 +50,7 @@ const PopEditorTab: React.FC<{ children?: React.ReactNode }> = ({
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
);
const [updatedUrl, setUpdatedUrl] = useState<string>(SQL_LAB_URL);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
useComponentDidUpdate(() => {
setQueryEditorId(assigned => assigned ?? activeQueryEditorId);
if (activeQueryEditorId) {
@@ -17,7 +17,8 @@
* under the License.
*/
import { useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { isObject } from 'lodash';
import rison from 'rison';
import {
@@ -82,7 +83,7 @@ function QueryAutoRefresh({
.map(({ id }) => id),
),
);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const checkForRefresh = () => {
const shouldRequestChecking = shouldCheckForQueries(queries);
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useDispatch } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import { Dropdown, Button } from '@superset-ui/core/components';
import { Menu } from '@superset-ui/core/components/Menu';
@@ -75,7 +75,7 @@ const QueryLimitSelect = ({
maxRow,
defaultQueryLimit,
}: QueryLimitSelectProps) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const queryEditor = useQueryEditor(queryEditorId, ['id', 'queryLimit']);
const queryLimit = queryEditor.queryLimit || defaultQueryLimit;
@@ -30,7 +30,8 @@ import ProgressBar from '@superset-ui/core/components/ProgressBar';
import { t } from '@apache-superset/core/translation';
import { QueryResponse, QueryState } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import {
queryEditorSetSql,
@@ -92,7 +93,7 @@ const QueryTable = ({
latestQueryId,
}: QueryTableProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const [selectedQuery, setSelectedQuery] = useState<QueryResponse | null>(
null,
);
@@ -27,7 +27,8 @@ import {
} from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useHistory } from 'react-router-dom';
import { pick } from 'lodash';
import {
@@ -231,7 +232,7 @@ const ResultSet = ({
canCopyClipboardSqlLab: canCopyClipboard,
} = usePermissions();
const history = useHistory();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
const { showConfirm, ConfirmModal } = useConfirmModal();
@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import * as reactRedux from 'react-redux';
import { act } from 'react';
import { act, type ComponentProps } from 'react';
import {
cleanup,
fireEvent,
@@ -40,6 +39,19 @@ const mockedProps = {
datasource: testQuery,
};
// Render with the SqlLab user fixture preloaded into the mock store so the
// component's useSelector(state => state.user) returns a useful value.
// Previously this test used jest.spyOn(reactRedux, 'useSelector') to inject
// the user directly, which can't intercept calls routed through the typed
// useAppSelector hook.
const renderModal = (
props: Partial<ComponentProps<typeof SaveDatasetModal>> = {},
) =>
render(<SaveDatasetModal {...mockedProps} {...props} />, {
useRedux: true,
initialState: { user },
});
fetchMock.get('glob:*/api/v1/dataset/?*', {
result: mockdatasets,
dataset_count: 3,
@@ -47,17 +59,17 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
jest.useFakeTimers({ advanceTimers: true });
// Mock the user
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
beforeEach(() => {
useSelectorMock.mockClear();
cleanup();
});
// Mock the createDatasource action
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
// Mock createDatasource to return a thunk that resolves with the dataset's
// new id. The test's mock store includes redux-thunk middleware (from RTK's
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
// the thunk and the production code's .then((data) => clearDatasetCache(data.id))
// chain receives `{ id: 123 }`. Individual tests can override per-call as needed.
jest.mock('src/SqlLab/actions/sqlLab', () => ({
createDatasource: jest.fn(),
createDatasource: jest.fn(() => () => Promise.resolve({ id: 123 })),
}));
jest.mock('src/explore/exploreUtils/formData', () => ({
postFormData: jest.fn(),
@@ -70,7 +82,7 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SaveDatasetModal', () => {
test('renders a "Save as new" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const saveRadioBtn = screen.getByRole('radio', {
name: /save as new/i,
@@ -87,7 +99,7 @@ describe('SaveDatasetModal', () => {
});
test('renders an "Overwrite existing" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const overwriteRadioBtn = screen.getByRole('radio', {
name: /overwrite existing/i,
@@ -103,20 +115,20 @@ describe('SaveDatasetModal', () => {
});
test('renders a close button', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
test('renders a save button when "Save as new" is selected', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// "Save as new" is selected when the modal opens by default
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
test('renders an overwrite button when "Overwrite existing" is selected', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// Click the overwrite radio button to reveal the overwrite confirmation and back buttons
const overwriteRadioBtn = screen.getByRole('radio', {
@@ -130,8 +142,7 @@ describe('SaveDatasetModal', () => {
});
test('renders the overwrite button as disabled until an existing dataset is selected', async () => {
useSelectorMock.mockReturnValue({ ...user });
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// Click the overwrite radio button
const overwriteRadioBtn = screen.getByRole('radio', {
@@ -168,8 +179,7 @@ describe('SaveDatasetModal', () => {
});
test('renders a confirm overwrite screen when overwrite is clicked', async () => {
useSelectorMock.mockReturnValue({ ...user });
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// Click the overwrite radio button
const overwriteRadioBtn = screen.getByRole('radio', {
@@ -215,11 +225,7 @@ describe('SaveDatasetModal', () => {
});
test('sends the schema when creating the dataset', async () => {
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -240,17 +246,9 @@ describe('SaveDatasetModal', () => {
});
test('sends the catalog when creating the dataset', async () => {
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
render(
<SaveDatasetModal
{...mockedProps}
datasource={{ ...mockedProps.datasource, catalog: 'public' }}
/>,
{ useRedux: true },
);
renderModal({
datasource: { ...mockedProps.datasource, catalog: 'public' },
});
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -271,7 +269,7 @@ describe('SaveDatasetModal', () => {
});
test('does not renders a checkbox button when template processing is disabled', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
@@ -280,7 +278,7 @@ describe('SaveDatasetModal', () => {
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
@@ -289,15 +287,11 @@ describe('SaveDatasetModal', () => {
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -324,15 +318,11 @@ describe('SaveDatasetModal', () => {
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -393,19 +383,11 @@ describe('SaveDatasetModal', () => {
.spyOn(SupersetClient, 'put')
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
// Check the "Include Template Parameters" checkbox
@@ -443,19 +425,11 @@ describe('SaveDatasetModal', () => {
.spyOn(SupersetClient, 'put')
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
// Do NOT check the "Include Template Parameters" checkbox
@@ -489,12 +463,9 @@ describe('SaveDatasetModal', () => {
'postFormData',
);
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
postFormData.mockResolvedValue('chart_key_123');
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -34,7 +34,6 @@ import { t } from '@apache-superset/core/translation';
import {
SupersetClient,
JsonResponse,
JsonObject,
QueryResponse,
QueryFormData,
VizType,
@@ -44,16 +43,14 @@ import {
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import { useSelector, useDispatch } from 'react-redux';
import { useAppDispatch, useAppSelector } from 'src/views/store';
import rison from 'rison';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
import {
DatasetRadioState,
EXPLORE_CHART_DEFAULT,
DatasetOwner,
SqlLabRootState,
} from 'src/SqlLab/types';
import { mountExploreUrl } from 'src/explore/exploreUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
@@ -221,7 +218,7 @@ export const SaveDatasetModal = ({
openWindow = true,
formData = {},
}: SaveDatasetModalProps) => {
const defaultVizType = useSelector<SqlLabRootState, string>(
const defaultVizType = useAppSelector(
state => state.common?.conf?.DEFAULT_VIZ_TYPE || VizType.Table,
);
@@ -240,8 +237,8 @@ export const SaveDatasetModal = ({
>(undefined);
const [loading, setLoading] = useState<boolean>(false);
const user = useSelector<SqlLabRootState, User>(state => state.user);
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
const user = useAppSelector(state => state.user);
const dispatch = useAppDispatch();
const [includeTemplateParameters, setIncludeTemplateParameters] =
useState(false);
@@ -17,7 +17,8 @@
* under the License.
*/
import { createRef, useCallback, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { nanoid } from 'nanoid';
import Tabs from '@superset-ui/core/components/Tabs';
import { t } from '@apache-superset/core/translation';
@@ -105,7 +106,7 @@ const SouthPane = ({
const { id, tabViewId } = useQueryEditor(queryEditorId, ['tabViewId']);
const editorId = tabViewId ?? id;
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
const { offline, tables } = useSelector(
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({
@@ -30,7 +30,8 @@ import {
import type { editors } from '@apache-superset/core';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import AutoSizer from 'react-virtualized-auto-sizer';
import { t } from '@apache-superset/core/translation';
import {
@@ -237,7 +238,7 @@ const SqlEditor: FC<Props> = ({
scheduleQueryWarning,
}) => {
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
useSelector<
@@ -17,7 +17,7 @@
* under the License.
*/
import { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { resetState } from 'src/SqlLab/actions/sqlLab';
import {
@@ -69,7 +69,7 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
dbSelectorProps;
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const shouldShowReset = window.location.search === '?reset=1';
// Modal state for Database/Catalog/Schema selector
@@ -19,7 +19,8 @@
import { useMemo, FC } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { MenuDotsDropdown } from '@superset-ui/core/components';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';
@@ -90,7 +91,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
);
const StatusIcon = queryState ? STATE_ICONS[queryState] : STATE_ICONS.running;
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const actions = useMemo(
() =>
bindActionCreators(
@@ -17,7 +17,8 @@
* under the License.
*/
import { useEffect, useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { SqlLabRootState } from 'src/SqlLab/types';
import {
@@ -41,7 +42,7 @@ export default function useDatabaseSelector(queryEditorId: string) {
SqlLabRootState,
SqlLabRootState['sqlLab']['databases']
>(({ sqlLab }) => sqlLab.databases);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const queryEditor = useQueryEditor(queryEditorId, [
'dbId',
'catalog',
@@ -17,7 +17,8 @@
* under the License.
*/
import { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
import {
ButtonGroup,
@@ -75,7 +76,7 @@ const Fade = styled.div`
const TableElement = ({ table, ...props }: TableElementProps) => {
const { dbId, catalog, schema, name, expanded, id } = table;
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const {
currentData: tableMetadata,
isSuccess: isMetadataSuccess,
@@ -25,7 +25,8 @@ import {
type ChangeEvent,
useMemo,
} from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import AutoSizer from 'react-virtualized-auto-sizer';
@@ -163,7 +164,7 @@ const savePinnedSchemasToStorage = (
};
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const theme = useTheme();
const treeRef = useRef<TreeApi<TreeNodeData>>(null);
const tables = useSelector(
@@ -17,7 +17,7 @@
* under the License.
*/
import { useMemo, useReducer, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import {
Table,
@@ -130,7 +130,7 @@ const useTreeData = ({
catalog,
pinnedTables,
}: UseTreeDataParams): UseTreeDataResult => {
const reduxDispatch = useDispatch();
const reduxDispatch = useAppDispatch();
// Schema data from API
const {
currentData: schemaData,
@@ -17,7 +17,8 @@
* under the License.
*/
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core/translation';
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';
@@ -110,7 +111,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
};
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const theme = useTheme();
const [databaseName, backend, disableDataPreview] = useSelector<
SqlLabRootState,
@@ -28,7 +28,7 @@ import fetchMock from 'fetch-mock';
import { SupersetClient } from '@superset-ui/core';
import mockDatasource from 'spec/fixtures/mockDatasource';
import React from 'react';
import DatasourceModalComponent from '.';
import DatasourceModalComponent, { buildExtraJsonObject } from '.';
// Cast to accept partial mock props in tests
const DatasourceModal = DatasourceModalComponent as unknown as React.FC<
@@ -309,3 +309,35 @@ describe('DatasourceModal', () => {
});
});
});
describe('buildExtraJsonObject', () => {
test('returns "{}" for an item with no warning and no certification', () => {
expect(buildExtraJsonObject({} as any)).toBe('{}');
});
test('drops warning_markdown when its value is null', () => {
expect(buildExtraJsonObject({ warning_markdown: null } as any)).toBe('{}');
});
test('drops warning_markdown when its value is an empty string', () => {
expect(buildExtraJsonObject({ warning_markdown: '' } as any)).toBe('{}');
});
test('preserves a non-empty warning_markdown verbatim', () => {
expect(buildExtraJsonObject({ warning_markdown: '⚠ caveat' } as any)).toBe(
'{"warning_markdown":"⚠ caveat"}',
);
});
test('preserves certification and drops null warning_markdown', () => {
expect(
buildExtraJsonObject({
certified_by: 'data-team',
certification_details: 'verified',
warning_markdown: null,
} as any),
).toBe(
'{"certification":{"certified_by":"data-team","details":"verified"}}',
);
});
});
@@ -71,7 +71,7 @@ const StyledDatasourceModal = styled(Modal)`
}
`;
function buildExtraJsonObject(
export function buildExtraJsonObject(
item: DatasetObject['metrics'][0] | DatasetObject['columns'][0],
) {
const certification =
@@ -83,7 +83,7 @@ function buildExtraJsonObject(
: undefined;
return JSON.stringify({
certification,
warning_markdown: item?.warning_markdown,
warning_markdown: item?.warning_markdown || undefined,
});
}
@@ -0,0 +1,267 @@
/**
* 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 { createRef } from 'react';
import {
render,
screen,
selectOption,
waitFor,
} from 'spec/helpers/testing-library';
import { ListViewFilterOperator } from '../types';
import UIFilters from './index';
import SelectFilter from './Select';
import type { FilterHandler } from './types';
const mockUpdateFilterValue = jest.fn();
beforeEach(() => {
mockUpdateFilterValue.mockClear();
});
test('select filter with ReactNode label uses option title when serializing selection', async () => {
// Regression for sc-104554: the chart-list Owner filter renders options
// with ReactNode labels (name + email). The value passed to
// updateFilterValue is serialized into URL / filter state and re-used to
// render the filter pill on return. It must carry the plain-text name
// (from `title`) and not fall back to the numeric user id.
const ReactNodeLabel = (
<div>
<span>John Doe</span>
<span>john@example.com</span>
</div>
);
const fetchSelects = jest.fn().mockResolvedValue({
data: [
{
label: ReactNodeLabel,
value: 42,
title: 'John Doe',
},
],
totalCount: 1,
});
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
fetchSelects,
paginate: true,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('John Doe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'John Doe',
value: 42,
});
});
});
test('select filter falls back to stringified value when no string label or title is available', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [
{
label: <span>123</span>,
value: 123,
},
],
totalCount: 1,
});
const filters = [
{
Header: 'Something',
key: 'something',
id: 'something',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
unfilteredLabel: 'All',
fetchSelects,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('123', 'Something');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: '123',
value: 123,
});
});
});
test('plain select with string label passes label through unchanged', async () => {
// Happy-path coverage for the typeof-string branch in onChange, exercised
// through the non-async Select wrapper (selects array, no fetchSelects).
const filters = [
{
Header: 'Status',
key: 'status',
id: 'status',
input: 'select' as const,
operator: ListViewFilterOperator.Equals,
unfilteredLabel: 'All',
selects: [
{ label: 'Published', value: 7 },
{ label: 'Draft', value: 8 },
],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('Published', 'Status');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'Published',
value: 7,
});
});
});
test('plain select with ReactNode label uses option title when serializing selection', async () => {
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
// the non-async Select wrapper. Guards against the two wrappers ever
// diverging on antd's two-arg onChange shape.
const ReactNodeLabel = (
<div>
<span>Jane Roe</span>
<span>jane@example.com</span>
</div>
);
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('Jane Roe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'Jane Roe',
value: 99,
});
});
});
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
// The isClear flag is what allows the parent (Filters/index) to suppress
// onFilterUpdate side-effects when the user clears the filter rather than
// picking a new value. Lock that contract in.
const mockOnSelect = jest.fn();
const ref = createRef<FilterHandler>();
render(
<SelectFilter
Header="Owner"
initialValue={{ label: 'John Doe', value: 42 }}
onSelect={mockOnSelect}
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
ref={ref}
/>,
);
ref.current?.clearFilter();
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
});
test('rehydrates filter pill from initialValue with plain-string label', async () => {
// The user-visible regression: after URL/state rehydration the filter pill
// must render the human-readable name, not the numeric user id. The fix
// ensures the persisted label is a string; this test asserts that string
// is what surfaces in the rendered combobox selection.
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
paginate: true,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owners',
operator: ListViewFilterOperator.RelationManyMany,
value: { label: 'John Doe', value: 42 },
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
@@ -58,14 +58,22 @@ function SelectFilter(
) {
const [selectedOption, setSelectedOption] = useState(initialValue);
const onChange = (selected: SelectOption) => {
const onChange = (selected: SelectOption, option?: SelectOption) => {
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
// labeled-value as the first arg and the full option (which carries
// `title` and any other fields) as the second. Options may supply a
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
// filter). Since this object is serialized into the URL and rehydrated
// as the filter pill on return, we need a plain string. Prefer `title`
// (set by callers to the human-readable name) before falling back to
// the value.
onSelect(
selected
? {
label:
typeof selected.label === 'string'
? selected.label
: String(selected.value),
: (option?.title ?? String(selected.value)),
value: selected.value,
}
: undefined,
@@ -26,6 +26,10 @@ export interface SortColumn {
export interface SelectOption {
label: ReactNode;
value: any;
// Plain-text representation of the option. Callers should set this when
// `label` is a ReactNode so that the option can be serialized (e.g. into
// URL filter state) without losing the human-readable name.
title?: string;
[key: string]: unknown;
}
@@ -25,7 +25,7 @@ import {
SupersetClient,
} from '@superset-ui/core';
import { MenuItem } from '@superset-ui/core/components/Menu';
import contentDisposition from 'content-disposition';
import { parse as parseContentDisposition } from 'content-disposition';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { MenuKeys } from 'src/dashboard/types';
import downloadAsPdf from 'src/utils/downloadAsPdf';
@@ -122,7 +122,7 @@ export const useDownloadMenuItems = (
if (disposition) {
try {
const parsed = contentDisposition.parse(disposition);
const parsed = parseContentDisposition(disposition);
if (parsed?.parameters?.filename) {
fileName = parsed.parameters.filename;
}
@@ -21,7 +21,7 @@ import { useSelector } from 'react-redux';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { last } from 'lodash';
import rison from 'rison';
import contentDisposition from 'content-disposition';
import { parse as parseContentDisposition } from 'content-disposition';
import { t } from '@apache-superset/core/translation';
import { SupersetClient, SupersetApiError } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
@@ -105,7 +105,7 @@ export const useDownloadScreenshot = (
if (disposition) {
try {
const parsed = contentDisposition.parse(disposition);
const parsed = parseContentDisposition(disposition);
if (parsed?.parameters?.filename) {
fileName = parsed.parameters.filename;
}
@@ -103,10 +103,12 @@ test('does not allow user to create a report without a name', () => {
});
test('creates a new email report via modal Add button', async () => {
// The modal now calls POST /api/v1/report/subscribe; creation_method, owners, and
// recipients are derived server-side — the client payload intentionally omits them.
fetchMock.post(
REPORT_ENDPOINT,
'glob:*/api/v1/report/subscribe',
{ id: 1, result: {} },
{ name: 'post-report' },
{ name: 'post-subscribe' },
);
render(<ReportModal {...defaultProps} />, { useRedux: true });
@@ -114,22 +116,22 @@ test('creates a new email report via modal Add button', async () => {
const addButton = screen.getByRole('button', { name: /add/i });
await waitFor(() => userEvent.click(addButton));
// Verify exactly one POST from the modal submit path
// Verify exactly one POST to the subscribe endpoint
await waitFor(() => {
const postCalls = fetchMock.callHistory.calls('post-report');
const postCalls = fetchMock.callHistory.calls('post-subscribe');
expect(postCalls).toHaveLength(1);
});
const postCalls = fetchMock.callHistory.calls('post-report');
const postCalls = fetchMock.callHistory.calls('post-subscribe');
const body = JSON.parse(postCalls[0].options.body as string);
expect(body.name).toBe('Weekly Report');
expect(body.type).toBe('Report');
expect(body.creation_method).toBe('dashboards');
expect(body.crontab).toBeDefined();
expect(body.recipients).toBeDefined();
expect(body.recipients[0].type).toBe('Email');
// creation_method, owners, and recipients are set server-side; not in the client payload
expect(body.creation_method).toBeUndefined();
expect(body.recipients).toBeUndefined();
fetchMock.removeRoute('post-report');
fetchMock.removeRoute('post-subscribe');
});
test('text-based chart hides screenshot width and shows message content', () => {
@@ -174,6 +174,28 @@ export const addReport =
throw err;
});
export const SUBSCRIBE_REPORT = 'SUBSCRIBE_REPORT' as const;
export interface SubscribeReportAction {
type: typeof SUBSCRIBE_REPORT;
json: ReportApiJsonResponse;
}
export const subscribeReport =
(report: Partial<ReportObject>) => (dispatch: Dispatch<AnyAction>) =>
SupersetClient.post({
endpoint: `/api/v1/report/subscribe`,
jsonPayload: report,
})
.then(({ json }) => {
dispatch({ type: SUBSCRIBE_REPORT, json } as SubscribeReportAction);
dispatch(addSuccessToast(t('The report has been created')));
})
.catch(err => {
dispatch(addDangerToast(t('Failed to create report')));
throw err;
});
export const EDIT_REPORT = 'EDIT_REPORT' as const;
export interface EditReportAction {
@@ -255,5 +277,6 @@ export function deleteActiveReport(report: DeletableReport) {
export type ReportAction =
| SetReportAction
| AddReportAction
| SubscribeReportAction
| EditReportAction
| DeleteReportAction;
@@ -31,8 +31,8 @@ import { Alert } from '@apache-superset/core/components';
import { SupersetTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
import {
addReport,
editReport,
subscribeReport,
} from 'src/features/reports/ReportModal/actions';
import {
Input,
@@ -179,26 +179,13 @@ function ReportModal({
}, [isEditMode, report]);
const onSave = async () => {
// Create new Report
const newReportValues: Partial<ReportObject> = {
const commonFields: Partial<ReportObject> = {
type: 'Report',
active: true,
force_screenshot: false,
custom_width: currentReport.custom_width,
creation_method: creationMethod,
dashboard: dashboardId,
chart: chart?.id,
owners: [userId],
recipients: [
{
recipient_config_json: {
target: userEmail,
ccTarget: ccEmail,
bccTarget: bccEmail,
},
type: 'Email',
},
],
name: currentReport.name,
description: currentReport.description,
crontab: currentReport.crontab,
@@ -209,12 +196,27 @@ function ReportModal({
setCurrentReport({ isSubmitting: true, error: undefined });
try {
if (isEditMode && currentReport.id) {
// Edit path: include all fields, PUT endpoint accepts recipients/owners directly
await dispatch(
editReport(currentReport.id, newReportValues as ReportObject),
editReport(currentReport.id, {
...commonFields,
creation_method: creationMethod,
owners: [userId],
recipients: [
{
recipient_config_json: {
target: userEmail,
ccTarget: ccEmail,
bccTarget: bccEmail,
},
type: 'Email',
},
],
} as ReportObject),
);
} else {
// Create new report (either not in edit mode, or edit mode without valid ID)
await dispatch(addReport(newReportValues as ReportObject));
// Subscribe path: creation_method, owners, and recipients are set server-side.
await dispatch(subscribeReport(commonFields as ReportObject));
}
onHide();
} catch (e) {
@@ -21,11 +21,13 @@ import { omit } from 'lodash';
import {
SET_REPORT,
ADD_REPORT,
SUBSCRIBE_REPORT,
EDIT_REPORT,
DELETE_REPORT,
ReportAction,
SetReportAction,
AddReportAction,
SubscribeReportAction,
EditReportAction,
DeleteReportAction,
} from './actions';
@@ -105,6 +107,25 @@ export default function reportsReducer(
};
},
[SUBSCRIBE_REPORT]() {
const { result, id } = (action as SubscribeReportAction).json;
const report: ReportObject = { ...result, id } as ReportObject;
const creationMethod = report.creation_method as ReportCreationMethod;
const key = report.dashboard ?? report.chart;
if (key === undefined) {
return state;
}
return {
...state,
[creationMethod]: {
...state[creationMethod],
[key]: report,
},
};
},
[EDIT_REPORT]() {
const actionTyped = action as EditReportAction;
const report: ReportObject = {
@@ -169,6 +169,7 @@ describe('RoleListEditModal', () => {
true,
);
// updateRoleUsers is called with the hydrated user IDs
const userArg = mockUpdateRoleUsers.mock.calls[0][1];
expect(userArg).toEqual([5, 7]);
expect(userArg.every((id: unknown) => typeof id === 'number')).toBe(true);
@@ -225,6 +226,8 @@ describe('RoleListEditModal', () => {
expect(decodedQuery).toEqual({
page_size: 100,
page: 0,
order_column: 'id',
order_direction: 'asc',
filters: [
{
col: 'roles',
@@ -129,8 +129,18 @@ function RoleListEditModal({
fetchPaginatedData({
endpoint: `/api/v1/security/users/`,
pageSize: 100,
setData: setRoleUsers,
setData: (users: UserObject[]) => {
const seen = new Set<number>();
setRoleUsers(
users.filter(u => {
if (seen.has(u.id)) return false;
seen.add(u.id);
return true;
}),
);
},
filters,
orderBy: { column: 'id', direction: 'asc' },
setLoadingState: (loading: boolean) => setLoadingRoleUsers(loading),
loadingKey: 'roleUsers',
addDangerToast,
@@ -218,7 +228,6 @@ function RoleListEditModal({
value: user.id,
label: user.username,
}));
formRef.current.setFieldsValue({
roleUsers: userOptions,
});
@@ -279,8 +288,8 @@ function RoleListEditModal({
const handleFormSubmit = async (values: RoleForm) => {
try {
const userIds = values.roleUsers?.map(user => user.value) || [];
const permissionIds = mapSelectedIds(values.rolePermissions);
const userIds = mapSelectedIds(values.roleUsers);
const groupIds = mapSelectedIds(values.roleGroups);
await Promise.all([
updateRoleName(id, values.roleName),
@@ -117,6 +117,7 @@ type LaunchQueue = {
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
const consumerPromises: Promise<void>[] = [];
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
// the component's useEffect — calling it inline deadlocks Jest because the
@@ -131,7 +132,11 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
if (fileHandle) {
const id = setTimeout(() => {
pendingTimerIds.delete(id);
consumer({ files: [fileHandle] });
consumerPromises.push(
Promise.resolve(consumer({ files: [fileHandle] })).then(
() => undefined,
),
);
}, 0);
pendingTimerIds.add(id);
}
@@ -165,9 +170,19 @@ beforeEach(() => {
.launchQueue;
});
afterEach(() => {
afterEach(async () => {
pendingTimerIds.forEach(id => clearTimeout(id));
pendingTimerIds.clear();
if (consumerPromises.length > 0) {
const results = await Promise.allSettled(consumerPromises);
results.forEach(r => {
if (r.status === 'rejected') {
// eslint-disable-next-line no-console
console.warn('LaunchQueue consumer rejected:', r.reason);
}
});
consumerPromises.length = 0;
}
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
+8 -5
View File
@@ -18,7 +18,7 @@
*/
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import contentDisposition from 'content-disposition';
import { parse as parseContentDisposition } from 'content-disposition';
import handleResourceExport from './export';
// Mock dependencies
@@ -35,7 +35,10 @@ jest.mock('@apache-superset/core/utils', () => ({
},
}));
jest.mock('content-disposition');
jest.mock('content-disposition', () => ({
parse: jest.fn(),
__esModule: true,
}));
// Default no-op mock for pathUtils; specific tests customize ensureAppRoot to simulate app root prefixing
jest.mock('./pathUtils', () => ({
@@ -156,7 +159,7 @@ test('uses default filename when Content-Disposition is missing', async () => {
});
test('handles Content-Disposition parsing errors gracefully', async () => {
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
throw new Error('Invalid header');
});
@@ -208,7 +211,7 @@ test('exports multiple resources with correct IDs', async () => {
});
test('parses filename from Content-Disposition with quotes', async () => {
(contentDisposition.parse as jest.Mock).mockReturnValueOnce({
(parseContentDisposition as jest.Mock).mockReturnValueOnce({
type: 'attachment',
parameters: { filename: 'my_custom_export.zip' },
});
@@ -360,7 +363,7 @@ test('handles malformed Content-Disposition header', async () => {
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
throw new Error('Parse error');
});
+2 -2
View File
@@ -19,7 +19,7 @@
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import rison from 'rison';
import contentDisposition from 'content-disposition';
import { parse as parseContentDisposition } from 'content-disposition';
// Maximum blob size for in-memory downloads (100MB)
const MAX_BLOB_SIZE = 100 * 1024 * 1024;
@@ -75,7 +75,7 @@ export default async function handleResourceExport(
if (disposition) {
try {
const parsed = contentDisposition.parse(disposition);
const parsed = parseContentDisposition(disposition);
if (parsed?.parameters?.filename) {
fileName = parsed.parameters.filename;
}
@@ -28,6 +28,7 @@ interface FetchPaginatedOptions {
setData: (data: any[]) => void;
setLoadingState: Dispatch<SetStateAction<any>>;
filters?: SupersetFilter[];
orderBy?: { column: string; direction: 'asc' | 'desc' };
loadingKey: string;
addDangerToast: (message: string) => void;
errorMessage?: string;
@@ -38,6 +39,8 @@ interface QueryObj {
page_size: number;
page: number;
filters?: SupersetFilter[];
order_column?: string;
order_direction?: 'asc' | 'desc';
}
interface SupersetFilter {
@@ -51,6 +54,7 @@ export const fetchPaginatedData = async ({
pageSize = 100,
setData,
filters,
orderBy,
setLoadingState,
loadingKey,
addDangerToast,
@@ -66,6 +70,10 @@ export const fetchPaginatedData = async ({
if (filters) {
queryObj.filters = filters;
}
if (orderBy) {
queryObj.order_column = orderBy.column;
queryObj.order_direction = orderBy.direction;
}
const encodedQuery = rison.encode(queryObj);
const response = await SupersetClient.get({
+11 -2
View File
@@ -22,12 +22,13 @@ import {
createListenerMiddleware,
StoreEnhancer,
} from '@reduxjs/toolkit';
import type { AnyAction } from 'redux';
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from 'react-redux';
import thunk from 'redux-thunk';
import thunk, { type ThunkDispatch } from 'redux-thunk';
import { api } from 'src/hooks/apiResources/queryApi';
import messageToastReducer from 'src/components/MessageToasts/reducers';
import charts from 'src/components/Chart/chartReducer';
@@ -188,6 +189,14 @@ export type RootState = ReturnType<typeof store.getState>;
// thunks resolve correctly), and `useAppSelector` infers `RootState` without
// callers having to annotate every selector. Required ahead of the
// react-redux v8+ bump, which tightens dispatch typing — see #39927.
export type AppDispatch = typeof store.dispatch;
//
// AppDispatch is declared as ThunkDispatch & store.dispatch rather than
// `typeof store.dispatch` because Superset annotates getMiddleware as
// ConfigureStoreOptions['middleware'], which erases the middleware tuple type
// and leaves store.dispatch typed as Dispatch<AnyAction>. The intersection
// restores thunk support without requiring a wider refactor of the middleware
// setup.
export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction> &
typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+146 -146
View File
@@ -23,21 +23,21 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.24",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.6.0",
"jest": "^29.7.0",
"prettier": "^3.8.3",
"ts-jest": "^29.4.9",
"ts-jest": "^29.4.10",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.3"
"typescript-eslint": "^8.59.4"
},
"engines": {
"node": "^22.22.0",
@@ -1798,9 +1798,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1844,17 +1844,17 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/type-utils": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/type-utils": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -1867,7 +1867,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -1883,16 +1883,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -1908,14 +1908,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.3",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
},
"engines": {
@@ -1930,14 +1930,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1948,9 +1948,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1965,15 +1965,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1990,9 +1990,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2004,16 +2004,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -2071,16 +2071,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3"
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2095,13 +2095,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -5695,9 +5695,9 @@
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -6016,9 +6016,9 @@
}
},
"node_modules/ts-jest": {
"version": "29.4.9",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
"integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
"version": "29.4.10",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6028,7 +6028,7 @@
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.4",
"semver": "^7.8.0",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
@@ -6199,16 +6199,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.59.3",
"@typescript-eslint/parser": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3"
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7894,9 +7894,9 @@
"dev": true
},
"@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"dev": true,
"requires": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -7938,16 +7938,16 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/type-utils": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/type-utils": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -7962,75 +7962,75 @@
}
},
"@typescript-eslint/parser": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3"
}
},
"@typescript-eslint/project-service": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.59.3",
"@typescript-eslint/types": "^8.59.3",
"@typescript-eslint/tsconfig-utils": "^8.59.4",
"@typescript-eslint/types": "^8.59.4",
"debug": "^4.4.3"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3"
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
"dev": true,
"requires": {}
},
"@typescript-eslint/type-utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
}
},
"@typescript-eslint/types": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.59.3",
"@typescript-eslint/tsconfig-utils": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/visitor-keys": "8.59.3",
"@typescript-eslint/project-service": "8.59.4",
"@typescript-eslint/tsconfig-utils": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/visitor-keys": "8.59.4",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -8065,24 +8065,24 @@
}
},
"@typescript-eslint/utils": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3"
"@typescript-eslint/scope-manager": "8.59.4",
"@typescript-eslint/types": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.59.3",
"@typescript-eslint/types": "8.59.4",
"eslint-visitor-keys": "^5.0.0"
},
"dependencies": {
@@ -10730,9 +10730,9 @@
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
},
"semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="
},
"shebang-command": {
"version": "2.0.0",
@@ -10954,9 +10954,9 @@
"requires": {}
},
"ts-jest": {
"version": "29.4.9",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
"integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
"version": "29.4.10",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.10.tgz",
"integrity": "sha512-vMTlTTtvz5aKZgzOoc7DQ5TzAL2fCzl8JnG1+ZpwjQa/g0xLlwE44yQ+1Cao9ZP1xVv9y5g34IFXEiqGOGFBUA==",
"dev": true,
"requires": {
"bs-logger": "^0.2.6",
@@ -10965,7 +10965,7 @@
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.4",
"semver": "^7.8.0",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
}
@@ -11044,15 +11044,15 @@
"dev": true
},
"typescript-eslint": {
"version": "8.59.3",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
"version": "8.59.4",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
"dev": true,
"requires": {
"@typescript-eslint/eslint-plugin": "8.59.3",
"@typescript-eslint/parser": "8.59.3",
"@typescript-eslint/typescript-estree": "8.59.3",
"@typescript-eslint/utils": "8.59.3"
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"@typescript-eslint/typescript-estree": "8.59.4",
"@typescript-eslint/utils": "8.59.4"
}
},
"uglify-js": {
+5 -5
View File
@@ -31,21 +31,21 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.24",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^17.6.0",
"jest": "^29.7.0",
"prettier": "^3.8.3",
"ts-jest": "^29.4.9",
"ts-jest": "^29.4.10",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.3"
"typescript-eslint": "^8.59.4"
},
"engines": {
"node": "^22.22.0",
+45 -1
View File
@@ -22,7 +22,15 @@ from typing import Any, TYPE_CHECKING
from flask import current_app
from flask_babel import gettext as _
from marshmallow import EXCLUDE, fields, post_load, Schema, validate
from marshmallow import (
EXCLUDE,
fields,
post_load,
Schema,
validate,
validates_schema,
ValidationError,
)
from marshmallow.validate import Length, Range
from marshmallow_union import Union
@@ -937,6 +945,42 @@ class ChartDataPostProcessingOperationSchema(Schema):
},
)
# Map post-processing operation -> its options schema, for operations that
# declare one. Operations without a dedicated schema are not structurally
# validated here.
_OPTIONS_SCHEMAS: dict[str, type[Schema]] = {
"aggregate": ChartDataAggregateOptionsSchema,
"rolling": ChartDataRollingOptionsSchema,
"select": ChartDataSelectOptionsSchema,
"sort": ChartDataSortOptionsSchema,
"contribution": ChartDataContributionOptionsSchema,
"prophet": ChartDataProphetOptionsSchema,
"boxplot": ChartDataBoxplotOptionsSchema,
"pivot": ChartDataPivotOptionsSchema,
"geohash_decode": ChartDataGeohashDecodeOptionsSchema,
"geohash_encode": ChartDataGeohashEncodeOptionsSchema,
"geodetic_parse": ChartDataGeodeticParseOptionsSchema,
}
@validates_schema
def validate_options(self, data: dict[str, Any], **kwargs: Any) -> None:
"""Validate ``options`` against the operation's option schema.
Validation is lenient (unknown keys are ignored) so it surfaces wrong
types / out-of-range values on declared fields without rejecting
payloads that carry extra keys.
"""
operation = data.get("operation")
options = data.get("options")
if not isinstance(operation, str) or not isinstance(options, dict):
return
schema_cls = self._OPTIONS_SCHEMAS.get(operation)
if schema_cls is None:
return
errors = schema_cls(unknown=EXCLUDE).validate(options)
if errors:
raise ValidationError({"options": errors})
class ChartDataFilterSchema(Schema):
col = fields.Raw(
@@ -62,6 +62,21 @@ def build_uuid_to_id_map(position: dict[str, Any]) -> dict[str, int]:
}
def _remap_charts_in_scope(container: dict[str, Any], id_map: dict[int, int]) -> None:
"""Remap source-env chart IDs in ``container["chartsInScope"]`` in place.
``chartsInScope`` is a denormalized cache of the charts a filter (native
or cross-filter) currently applies to. Both surfaces share this contract,
so they share this remap. Unresolvable IDs are dropped rather than
passed through, matching the convention used for ``scope.excluded``.
"""
charts_in_scope = container.get("chartsInScope")
if isinstance(charts_in_scope, list):
container["chartsInScope"] = [
id_map[old_id] for old_id in charts_in_scope if old_id in id_map
]
def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
config: dict[str, Any],
chart_ids: dict[str, int],
@@ -145,6 +160,8 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
id_map[old_id] for old_id in scope_excluded if old_id in id_map
]
_remap_charts_in_scope(native_filter, id_map)
# fix display control dataset references
for customization in (
fixed.get("metadata", {}).get("chart_customization_config") or []
@@ -170,7 +187,7 @@ def update_id_refs( # pylint: disable=too-many-locals # noqa: C901
return fixed
def update_cross_filter_scoping(
def update_cross_filter_scoping( # noqa: C901
config: dict[str, Any], id_map: dict[int, int]
) -> dict[str, Any]:
# fix cross filter references
@@ -185,6 +202,9 @@ def update_cross_filter_scoping(
id_map[old_id] for old_id in scope_excluded if old_id in id_map
]
# Global cross-filter chartsInScope mirrors the native-filter case.
_remap_charts_in_scope(cross_filter_global_config, id_map)
if "chart_configuration" in (metadata := fixed.get("metadata", {})):
# Build remapped configuration in a single pass for clarity/readability.
new_chart_configuration: dict[str, Any] = {}
@@ -212,6 +232,11 @@ def update_cross_filter_scoping(
if old_id in id_map
]
# Cross-filter chartsInScope mirrors the native-filter case.
cross_filters = chart_config.get("crossFilters")
if isinstance(cross_filters, dict):
_remap_charts_in_scope(cross_filters, id_map)
new_chart_configuration[str(new_id)] = chart_config
metadata["chart_configuration"] = new_chart_configuration
+4 -1
View File
@@ -34,7 +34,10 @@ from superset.commands.report.exceptions import (
)
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.reports.models import ReportCreationMethod, ReportScheduleType
from superset.reports.models import (
ReportCreationMethod,
ReportScheduleType,
)
from superset.reports.types import ReportScheduleExtra
from superset.utils import json
+29 -1
View File
@@ -33,12 +33,20 @@ from superset.commands.report.exceptions import (
ReportScheduleNameUniquenessValidationError,
ReportScheduleNotFoundError,
ReportScheduleUpdateFailedError,
ReportScheduleUserEmailNotFoundError,
)
from superset.daos.database import DatabaseDAO
from superset.daos.report import ReportScheduleDAO
from superset.exceptions import SupersetSecurityException
from superset.reports.models import ReportSchedule, ReportScheduleType, ReportState
from superset.reports.models import (
ReportCreationMethod,
ReportRecipientType,
ReportSchedule,
ReportScheduleType,
ReportState,
)
from superset.utils import json
from superset.utils.core import get_user_email
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -89,6 +97,26 @@ class UpdateReportScheduleCommand(UpdateMixin, BaseReportScheduleCommand):
):
self._properties["last_state"] = ReportState.NOOP
# For reports created from charts or dashboards the recipient must always
# be the requesting user's own email address.
if (
self._model.creation_method
in (
ReportCreationMethod.CHARTS,
ReportCreationMethod.DASHBOARDS,
)
and "recipients" in self._properties
):
if user_email := get_user_email():
self._properties["recipients"] = [
{
"type": ReportRecipientType.EMAIL,
"recipient_config_json": {"target": user_email},
}
]
else:
exceptions.append(ReportScheduleUserEmailNotFoundError())
# Validate name/type uniqueness if either is changing
if name != self._model.name or report_type != self._model.type:
if not ReportScheduleDAO.validate_update_uniqueness(
+4 -1
View File
@@ -2011,7 +2011,10 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=1).total_seconds())
# For workspaces with 10k+ channels, consider increasing to 10
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
# The webdriver to use for generating reports. Use one of the following
# The webdriver to use for generating reports when using Selenium (not Playwright).
# This setting is ignored when PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled, as
# Playwright always uses Chromium regardless of this value.
# Use one of the following:
# firefox
# Requires: geckodriver and firefox installations
# Limitations: can be buggy at times
+40 -2
View File
@@ -21,10 +21,11 @@ import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, Optional, TYPE_CHECKING
from typing import Any, Callable, Optional, TYPE_CHECKING
from flask_babel import gettext as __
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
from sqlalchemy import types
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
from sqlalchemy.dialects.postgresql.base import PGInspector
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
@@ -135,6 +136,34 @@ def parse_options(connect_args: dict[str, Any]) -> dict[str, str]:
return {token[0]: token[1] for token in tokens}
def _normalize_interval(v: Any) -> Optional[float]:
"""Convert PostgreSQL INTERVAL values to milliseconds.
psycopg2 and psycopg3 always return INTERVAL values as datetime.timedelta
objects. We convert to milliseconds so users can apply the built-in
"DURATION" number format for human-readable display (e.g.,
"1d 2h 30m 45s") and so the values participate cleanly in numeric
aggregations in bar/pie charts.
Returns None for the NULL case (preserves NULL semantics) and for any
unexpected non-timedelta type (avoids producing a mixed-type column
when an unfamiliar driver surfaces something other than timedelta).
"""
if v is None:
return None
if hasattr(v, "total_seconds"):
return v.total_seconds() * 1000
# Defensive: psycopg2/3 should always hand us a timedelta. If a future
# driver doesn't, surface the surprise in the logs rather than silently
# dropping the value so operators can diagnose it.
logger.warning(
"Cannot normalize PostgreSQL INTERVAL value of type %s to numeric; "
"returning None.",
type(v).__name__,
)
return None
class PostgresBaseEngineSpec(BaseEngineSpec):
"""Abstract class for Postgres 'like' databases"""
@@ -526,8 +555,17 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
ENUM(),
GenericDataType.STRING,
),
(
re.compile(r"^interval", re.IGNORECASE),
INTERVAL(),
GenericDataType.NUMERIC,
),
)
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
INTERVAL: _normalize_interval,
}
@classmethod
def get_schema_from_engine_params(
cls,
+42 -23
View File
@@ -251,7 +251,7 @@ def _generate_ascii_line_chart(data: list[Any], width: int, height: int) -> str:
lines.append("" * min(width, 60))
# Extract values and labels for plotting
values, labels = _extract_time_series_data(data)
values, labels, is_temporal = _extract_time_series_data(data)
if not values:
return "No numeric data found for line chart"
@@ -265,7 +265,7 @@ def _generate_ascii_line_chart(data: list[Any], width: int, height: int) -> str:
lines.extend(sparkline_data)
# Add trend analysis
trend_analysis = _analyze_trend(values)
trend_analysis = _analyze_trend(values, is_temporal=is_temporal)
lines.append("")
lines.append("📊 Trend Analysis:")
lines.extend(trend_analysis)
@@ -273,10 +273,17 @@ def _generate_ascii_line_chart(data: list[Any], width: int, height: int) -> str:
return "\n".join(lines)
def _extract_time_series_data(data: list[Any]) -> tuple[list[float], list[str]]:
"""Extract time series data with labels."""
def _extract_time_series_data(
data: list[Any],
) -> tuple[list[float], list[str], bool]:
"""Extract time series data with labels.
Returns a tuple of (values, labels, is_temporal) where is_temporal indicates
whether the label column appears to represent temporal data based on its name.
"""
values = []
labels = []
is_temporal = False
for row in data[:20]: # Limit points for readability
if isinstance(row, dict):
@@ -299,6 +306,7 @@ def _extract_time_series_data(data: list[Any]) -> tuple[list[float], list[str]]:
for date_word in ["date", "time", "month", "day", "year"]
):
label_val = str(val)[:10] # Truncate long dates
is_temporal = True
else:
label_val = str(val)[:8] # Truncate long strings
@@ -306,7 +314,7 @@ def _extract_time_series_data(data: list[Any]) -> tuple[list[float], list[str]]:
values.append(numeric_val)
labels.append(label_val or f"P{len(values)}")
return values, labels
return values, labels, is_temporal
def _create_enhanced_line_chart(
@@ -407,8 +415,15 @@ def _draw_line_segment(
grid[y][x] = ""
def _analyze_trend(values: list[float]) -> list[str]:
"""Analyze trend in the data."""
def _analyze_trend(values: list[float], *, is_temporal: bool = True) -> list[str]:
"""Analyze trend in the data.
Args:
values: Numeric data points to analyze.
is_temporal: Whether the data represents a time series. When False,
directional trend analysis is skipped because the ordering of
categorical data is arbitrary.
"""
if len(values) < 2:
return ["• Insufficient data for trend analysis"]
@@ -421,24 +436,28 @@ def _analyze_trend(values: list[float]) -> list[str]:
max_val = max(values)
avg_val = sum(values) / len(values)
# Overall trend
if last_val > first_val * 1.1:
trend_icon = "📈"
trend_desc = "Strong upward trend"
elif last_val > first_val * 1.05:
trend_icon = "📊"
trend_desc = "Moderate upward trend"
elif last_val < first_val * 0.9:
trend_icon = "📉"
trend_desc = "Strong downward trend"
elif last_val < first_val * 0.95:
trend_icon = "📊"
trend_desc = "Moderate downward trend"
if is_temporal:
# Overall trend (only meaningful for temporally ordered data)
if last_val > first_val * 1.1:
trend_icon = "📈"
trend_desc = "Strong upward trend"
elif last_val > first_val * 1.05:
trend_icon = "📊"
trend_desc = "Moderate upward trend"
elif last_val < first_val * 0.9:
trend_icon = "📉"
trend_desc = "Strong downward trend"
elif last_val < first_val * 0.95:
trend_icon = "📊"
trend_desc = "Moderate downward trend"
else:
trend_icon = "➡️"
trend_desc = "Relatively stable"
analysis.append(f"{trend_icon} {trend_desc}")
else:
trend_icon = "➡️"
trend_desc = "Relatively stable"
analysis.append("• ⚠️ Data is categorical — directional trend not meaningful")
analysis.append(f"{trend_icon} {trend_desc}")
analysis.append(f"• Range: {min_val:.1f} to {max_val:.1f} (avg: {avg_val:.1f})")
# Volatility
+101
View File
@@ -36,7 +36,10 @@ import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal
from sqlalchemy.exc import SQLAlchemyError
from superset.commands.exceptions import CommandException
from superset.errors import SupersetErrorType
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
from superset.mcp_service.common.error_schemas import (
ChartGenerationError,
@@ -46,6 +49,31 @@ from superset.mcp_service.common.error_schemas import (
logger = logging.getLogger(__name__)
# Error types from db_engine_spec.extract_errors() that indicate a database
# connectivity or authentication issue rather than a query/config problem.
#
# GENERIC_DB_ENGINE_ERROR is included because many engines (BigQuery,
# Snowflake, Athena, Databricks, Trino) lack specific CONNECTION_* regex
# patterns in their engine specs — all their connection failures fall back
# to this generic type. This is safe here because _compile_chart only runs
# after Tier 1 schema validation has already verified columns, metrics, and
# filters against the dataset. At that point the SQL is auto-generated by
# Superset's query builder, so genuine SQL/config errors are very unlikely.
_CONNECTION_ERROR_TYPES = {
SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR,
SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR,
SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
SupersetErrorType.CONNECTION_DATABASE_TIMEOUT,
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
}
@dataclass
class CompileResult:
@@ -186,6 +214,19 @@ def _compile_chart(
return CompileResult(success=True, warnings=warnings, row_count=row_count)
except (ChartDataQueryFailedError, ChartDataCacheLoadError) as exc:
if _classify_as_database_error(exc, dataset_id):
logger.warning(
"Database connection error during chart compile check: %s: %s",
type(exc).__name__,
str(exc),
)
return CompileResult(
success=False,
error=f"Database connection error: {exc}",
error_code="CHART_COMPILE_FAILED",
tier="compile",
error_obj=_build_database_error(str(exc)),
)
return CompileResult(
success=False,
error=str(exc),
@@ -201,6 +242,19 @@ def _compile_chart(
tier="compile",
error_obj=_build_compile_error(str(exc)),
)
except SQLAlchemyError as exc:
logger.warning(
"Database connection error during chart compile check: %s: %s",
type(exc).__name__,
str(exc),
)
return CompileResult(
success=False,
error=f"Database connection error: {exc}",
error_code="CHART_COMPILE_FAILED",
tier="compile",
error_obj=_build_database_error(str(exc)),
)
def _adhoc_filter_column_valid(
@@ -278,6 +332,53 @@ def _validate_adhoc_filter_columns(
)
def _classify_as_database_error(exc: BaseException, dataset_id: int) -> bool:
"""Use the dataset's DB engine spec to classify the error.
Walks the ``__cause__`` chain for direct ``SQLAlchemyError`` instances,
then falls back to the engine spec's ``extract_errors`` regex patterns —
the same classification the Superset UI uses.
"""
# Direct SQLAlchemy errors (unwrapped or in cause chain)
current: BaseException | None = exc
while current is not None:
if isinstance(current, SQLAlchemyError):
return True
current = current.__cause__
# Use the dataset's engine spec to classify (same as the UI)
try:
from superset.daos.dataset import DatasetDAO
dataset = DatasetDAO.find_by_id(dataset_id)
if dataset and dataset.database and isinstance(exc, Exception):
errors = dataset.database.db_engine_spec.extract_errors(exc)
return any(e.error_type in _CONNECTION_ERROR_TYPES for e in errors)
except Exception: # pylint: disable=broad-except
logger.debug(
"Failed to classify error via engine spec for dataset %s: %s",
dataset_id,
exc,
)
return False
def _build_database_error(message: str) -> ChartGenerationError:
"""Wrap a database connection failure in the structured response envelope."""
return ChartGenerationError(
error_type="database_connection_error",
message="Unable to connect to the database.",
details=message or "",
suggestions=[
"Check that the database is online and reachable",
"Verify database credentials and connection settings",
"Contact your administrator if the issue persists",
],
error_code="DATABASE_CONNECTION_ERROR",
)
def _build_compile_error(message: str) -> ChartGenerationError:
"""Wrap a raw compile-failure string in the structured response envelope."""
return ChartGenerationError(
@@ -352,6 +352,20 @@ async def generate_chart( # noqa: C901
"Chart creation failed - no chart ID returned"
)
# Reload server-generated timestamps (created_on,
# changed_on) so the serializer sees real values.
from superset import db
try:
db.session.refresh(chart)
except SQLAlchemyError:
logger.warning(
"Chart %s created but refresh failed; "
"continuing with current values",
chart.id,
exc_info=True,
)
await ctx.info(
"Chart created successfully: chart_id=%s, chart_name=%s"
% (
@@ -402,7 +416,7 @@ async def generate_chart( # noqa: C901
)
execution_time = int((time.time() - start_time) * 1000)
error = ChartGenerationError(
error = compile_result.error_obj or ChartGenerationError(
error_type="compile_error",
message=(
"Chart query failed to execute. The chart was not saved."
@@ -532,7 +546,7 @@ async def generate_chart( # noqa: C901
)
execution_time = int((time.time() - start_time) * 1000)
error = ChartGenerationError(
error = compile_result.error_obj or ChartGenerationError(
error_type="compile_error",
message=(
"Chart query failed to execute. "
@@ -335,6 +335,15 @@ def generate_dashboard( # noqa: C901
db.session.add(dashboard)
db.session.commit() # pylint: disable=consider-using-transaction
try:
db.session.refresh(dashboard)
except SQLAlchemyError:
logger.warning(
"Dashboard %s created but refresh failed; "
"continuing with current values",
dashboard.id,
exc_info=True,
)
except SQLAlchemyError as db_err:
try:
db.session.rollback() # pylint: disable=consider-using-transaction
@@ -405,6 +414,7 @@ def generate_dashboard( # noqa: C901
# Convert to our response format
from superset.mcp_service.dashboard.schemas import (
_humanize_timestamp,
serialize_chart_summary,
serialize_tag_object,
)
@@ -418,6 +428,10 @@ def generate_dashboard( # noqa: C901
published=dashboard.published,
created_on=dashboard.created_on,
changed_on=dashboard.changed_on,
created_on_humanized=_humanize_timestamp(dashboard.created_on),
changed_on_humanized=_humanize_timestamp(dashboard.changed_on),
created_by=dashboard.created_by_name or None,
changed_by=dashboard.changed_by_name or None,
uuid=str(dashboard.uuid) if dashboard.uuid else None,
url=f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/",
chart_count=len(request.chart_ids),
+15 -1
View File
@@ -16,6 +16,7 @@
# under the License.
import logging
import secrets
import time
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, Protocol, Sequence
@@ -245,11 +246,20 @@ class LoggingMiddleware(Middleware):
)
tool_name = getattr(context.message, "name", None)
mcp_call_id = secrets.token_hex(16)
context.mcp_call_id = mcp_call_id
start_time = time.time()
success = False
try:
result = await call_next(context)
success = not self._is_error_response(result)
if isinstance(result, ToolResult):
existing_meta = result.meta or {}
result = ToolResult(
content=result.content,
meta={**existing_meta, "mcp_call_id": mcp_call_id},
structured_content=result.structured_content,
)
return result
except Exception:
success = False
@@ -265,6 +275,7 @@ class LoggingMiddleware(Middleware):
slice_id=slice_id,
referrer=None,
curated_payload={
"mcp_call_id": mcp_call_id,
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
@@ -278,7 +289,7 @@ class LoggingMiddleware(Middleware):
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
"success=%s",
"success=%s, mcp_call_id=%s",
tool_name,
agent_id,
user_id,
@@ -288,6 +299,7 @@ class LoggingMiddleware(Middleware):
dataset_id,
duration_ms,
success,
mcp_call_id,
)
async def on_message(
@@ -391,8 +403,10 @@ class StructuredContentStripperMiddleware(Middleware):
# unhandled exception — including ToolError from
# GlobalErrorHandlerMiddleware, ValueError, TypeError, etc. —
# will cause encoding failures on the wire.
mcp_call_id = getattr(context, "mcp_call_id", None)
return ToolResult(
content=[mt.TextContent(type="text", text=f"Error: {e}")],
meta={"mcp_call_id": mcp_call_id} if mcp_call_id else None,
)
if isinstance(result, ToolResult) and result.structured_content is not None:
result = ToolResult(content=result.content, meta=result.meta)
@@ -50,6 +50,7 @@ from superset.mcp_service.utils.oauth2_utils import (
build_oauth2_redirect_message,
OAUTH2_CONFIG_ERROR_MESSAGE,
)
from superset.sql.parse import SQLScript
logger = logging.getLogger(__name__)
@@ -117,7 +118,50 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
error_type=SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR.value,
)
# 2. Build QueryOptions and execute query
# 2. Block destructive DDL (DROP, TRUNCATE, ALTER)
# Fail-closed: if parsing fails, block the query rather than
# allowing potentially destructive SQL to bypass the check.
# Render Jinja2 templates first so templated SQL can be parsed.
with event_logger.log_context(action="mcp.execute_sql.ddl_check"):
try:
sql_to_check = request.sql
if request.template_params:
from superset.jinja_context import get_template_processor
tp = get_template_processor(database=database)
sql_to_check = tp.process_template(
request.sql, **request.template_params
)
script = SQLScript(sql_to_check, database.db_engine_spec.engine)
if script.has_destructive():
await ctx.error(
"Destructive DDL blocked: sql_preview=%r" % sql_preview
)
return ExecuteSqlResponse(
success=False,
error=(
"Destructive DDL statements (DROP, TRUNCATE, ALTER) "
"are not allowed through MCP. Use the Superset SQL "
"Lab UI for administrative database operations."
),
error_type=SupersetErrorType.DML_NOT_ALLOWED_ERROR.value,
)
except Exception as parse_err:
await ctx.error(
"DDL pre-check failed to parse SQL, blocking query: %s"
% str(parse_err)
)
return ExecuteSqlResponse(
success=False,
error=(
"SQL could not be parsed for security validation. "
"Please check your SQL syntax and try again."
),
error_type=SupersetErrorType.INVALID_SQL_ERROR.value,
)
# 3. Build QueryOptions and execute query
cache_opts = CacheOptions(force_refresh=True) if request.force_refresh else None
options = QueryOptions(
catalog=request.catalog,
@@ -129,11 +173,11 @@ async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlRes
cache=cache_opts,
)
# 3. Execute query
# 4. Execute query
with event_logger.log_context(action="mcp.execute_sql.query_execution"):
result = database.execute(request.sql, options)
# 4. Convert to MCP response format
# 5. Convert to MCP response format
with event_logger.log_context(action="mcp.execute_sql.response_conversion"):
response = _convert_to_response(result)
+8
View File
@@ -241,6 +241,14 @@ class GenerateBugReportRequest(BaseModel):
"PII and secrets are sanitized before being written to the report."
),
)
mcp_call_id: str | None = Field(
None,
description=(
"Optional MCP call ID from a previous tool invocation. "
"When provided, it will be included in the bug report "
"for server-side log correlation."
),
)
class GenerateBugReportResponse(BaseModel):
@@ -212,6 +212,7 @@ def _format_report(
environment: dict[str, str],
user_context: dict[str, Any],
timestamp: str,
mcp_call_id: str | None = None,
) -> str:
"""Render the final markdown report."""
lines: list[str] = [
@@ -224,6 +225,10 @@ def _format_report(
f"- **Platform:** {environment['platform']}",
f"- **User ID:** {user_context['user_id']}",
f"- **Roles:** {', '.join(user_context['roles']) or 'none'}",
]
if mcp_call_id:
lines.append(f"- **MCP Call ID:** {mcp_call_id}")
lines += [
"",
"## What the user was doing",
f"- **MCP tool:** {sanitized.get('tool_name') or 'not provided'}",
@@ -316,6 +321,7 @@ async def generate_bug_report(
environment=environment,
user_context=user_context,
timestamp=timestamp,
mcp_call_id=request.mcp_call_id,
)
return GenerateBugReportResponse(
+13 -1
View File
@@ -53,7 +53,11 @@ from superset_core.queries.models import (
)
from superset import security_manager
from superset.exceptions import SupersetParseError, SupersetSecurityException
from superset.exceptions import (
SupersetException,
SupersetParseError,
SupersetSecurityException,
)
from superset.explorables.base import TimeGrainDict
from superset.jinja_context import BaseTemplateProcessor, get_template_processor
from superset.models.helpers import (
@@ -99,6 +103,14 @@ class SqlTablesMixin: # pylint: disable=too-few-public-methods
)
except (SupersetSecurityException, SupersetParseError, TemplateError):
return []
except SupersetException as ex:
# Jinja macros such as ``{{ dataset(id) }}`` or ``{{ metric(...) }}``
# may reference resources that no longer exist (e.g. a deleted
# dataset). Surfacing the failure here would break list endpoints
# that include ``sql_tables`` in their payload, hiding every saved
# query from the user. Treat it as a parse failure instead.
logger.warning("Unable to extract tables from SQL via Jinja: %s", ex)
return []
class Query(
+78 -1
View File
@@ -49,13 +49,14 @@ from superset.databases.filters import DatabaseFilter
from superset.exceptions import SupersetException
from superset.extensions import event_logger
from superset.reports.filters import ReportScheduleAllTextFilter, ReportScheduleFilter
from superset.reports.models import ReportSchedule
from superset.reports.models import ReportCreationMethod, ReportSchedule
from superset.reports.schemas import (
get_delete_ids_schema,
get_slack_channels_schema,
openapi_spec_methods_override,
ReportSchedulePostSchema,
ReportSchedulePutSchema,
ReportScheduleSubscribeSchema,
)
from superset.utils.slack import get_channels_with_search
from superset.views.base_api import (
@@ -82,6 +83,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED,
"bulk_delete",
"slack_channels", # not using RouteMethod since locally defined
"subscribe",
}
class_permission_name = "ReportSchedule"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -198,6 +200,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
edit_columns = add_columns
add_model_schema = ReportSchedulePostSchema()
edit_model_schema = ReportSchedulePutSchema()
subscribe_schema = ReportScheduleSubscribeSchema()
order_columns = [
"active",
@@ -318,6 +321,80 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
)
return self.response_422(message=str(ex))
@expose("/subscribe", methods=("POST",))
@protect()
@statsd_metrics
@permission_name("subscribe")
@requires_json
def subscribe(self) -> Response:
"""Subscribe the current user to a chart or dashboard report.
---
post:
summary: Subscribe to a chart or dashboard report
description: >-
Creates a report schedule locked to the authenticated user's email.
``creation_method`` is derived server-side from the payload
(chart charts, dashboard dashboards). ``recipients`` are not
accepted and are always set to the requesting user's email address.
requestBody:
description: Report schedule subscription schema
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
responses:
201:
description: Report schedule subscription created
content:
application/json:
schema:
type: object
properties:
id:
type: number
result:
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
try:
item = self.subscribe_schema.load(request.json)
except ValidationError as error:
return self.response_400(message=error.messages)
# Derive creation_method server-side from the payload
if item.get("dashboard") is not None:
item["creation_method"] = ReportCreationMethod.DASHBOARDS
elif item.get("chart") is not None:
item["creation_method"] = ReportCreationMethod.CHARTS
else:
return self.response_400(
message={"_schema": ["Either chart or dashboard is required"]}
)
try:
new_model = CreateReportScheduleCommand(item).run()
return self.response(201, id=new_model.id, result=item)
except ReportScheduleNotFoundError as ex:
return self.response_400(message=str(ex))
except ReportScheduleInvalidError as ex:
return self.response_422(message=ex.normalized_messages())
except ReportScheduleCreateFailedError as ex:
logger.error(
"Error creating report schedule %s: %s",
self.__class__.__name__,
str(ex),
exc_info=True,
)
return self.response_422(message=str(ex))
@expose("/", methods=("POST",))
@protect()
@statsd_metrics
+24
View File
@@ -305,6 +305,30 @@ class ReportSchedulePostSchema(Schema):
)
class ReportScheduleSubscribeSchema(ReportSchedulePostSchema):
"""Schema for creating a chart/dashboard subscription.
``recipients`` and ``creation_method`` are excluded both are set
server-side: recipients are locked to the authenticated user's email,
and creation_method is derived from the presence of ``chart`` or
``dashboard`` in the payload.
``type`` is restricted to ``Report`` alert schedules cannot be
created through the subscribe endpoint.
"""
type = fields.String(
metadata={"description": type_description},
allow_none=False,
required=True,
validate=validate.OneOf(choices=[ReportScheduleType.REPORT.value]),
)
class Meta:
exclude = ("recipients", "creation_method", "owners")
unknown = EXCLUDE
class ReportSchedulePutSchema(Schema):
type = fields.String(
metadata={"description": type_description},
+53
View File
@@ -439,6 +439,14 @@ class BaseSQLStatement(Generic[InternalRepresentation]):
"""
raise NotImplementedError()
def is_destructive(self) -> bool:
"""
Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
:return: True if the statement is destructive DDL.
"""
raise NotImplementedError()
def optimize(self) -> BaseSQLStatement[InternalRepresentation]:
"""
Return optimized statement.
@@ -719,6 +727,31 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
return False
def is_destructive(self) -> bool:
"""
Check if the statement is destructive DDL (DROP, TRUNCATE, ALTER).
Unlike ``is_mutating()``, this excludes non-destructive DML
(INSERT, UPDATE, DELETE, MERGE) and CREATE.
:return: True if the statement is destructive DDL.
"""
destructive_nodes = (
exp.Drop,
exp.TruncateTable,
exp.Alter,
)
for node_type in destructive_nodes:
if self._parsed.find(node_type):
return True
# Handle ALTER parsed as Command (Oracle, MS SQL dialects)
if isinstance(self._parsed, exp.Command) and self._parsed.name == "ALTER":
return True # pragma: no cover
return False
def format(self, comments: bool = True) -> str:
"""
Pretty-format the SQL statement.
@@ -1175,6 +1208,18 @@ class KustoKQLStatement(BaseSQLStatement[str]):
"""
return self._parsed.startswith(".") and not self._parsed.startswith(".show")
def is_destructive(self) -> bool:
"""
Check if the statement is destructive DDL.
Kusto KQL uses dot-commands for management operations. Destructive
operations start with ``.drop`` or ``.alter``.
:return: True if the statement is destructive DDL.
"""
lower = self._parsed.lower()
return lower.startswith(".drop") or lower.startswith(".alter")
def optimize(self) -> KustoKQLStatement:
"""
Return optimized statement.
@@ -1321,6 +1366,14 @@ class SQLScript:
"""
return any(statement.is_mutating() for statement in self.statements)
def has_destructive(self) -> bool:
"""
Check if the script contains destructive DDL (DROP, TRUNCATE, ALTER).
:return: True if any statement is destructive DDL.
"""
return any(statement.is_destructive() for statement in self.statements)
def optimize(self) -> SQLScript:
"""
Return optimized script.
@@ -1237,6 +1237,76 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
chart["id"] for chart in data_by_name["result"]
), set(chart.id for chart in expected_charts) # noqa: C401
def test_get_charts_changed_on_delta_humanized_sort_monotonic(self):
"""Regression for #27500: sorting the chart list by
`changed_on_delta_humanized` desc must yield results whose underlying
`changed_on` timestamps are monotonically non-increasing. The original
report shows the humanized column visually out of order, suggesting
the sort key didn't actually reflect the timestamp."""
from datetime import datetime, timedelta
admin = self.get_user("admin")
# Insert two charts with distinct changed_on timestamps. Use raw UPDATE
# to force the values since assignment alone can be overridden by the
# before-update hook.
chart_older = self.insert_chart(
"regression_27500_older", [admin.id], 1, description="z"
)
chart_newer = self.insert_chart(
"regression_27500_newer", [admin.id], 1, description="z"
)
# Use timestamps whose humanized strings sort DIFFERENTLY from their
# real timestamps under a naive lexical sort. "3 hours ago" vs
# "5 hours ago": lexical-desc puts "5..." first (older), but
# timestamp-desc must put "3..." first (newer). If the API ever
# accidentally sorts by the humanized text instead of the column,
# this test fails. (Pairs like "now"/"2 days ago" don't discriminate
# because 'n' > '2' lexically agrees with newest-first.)
now = datetime.utcnow()
chart_older.changed_on = now - timedelta(hours=5)
chart_newer.changed_on = now - timedelta(hours=3)
db.session.commit()
try:
self.login(ADMIN_USERNAME)
arguments = {
"order_column": "changed_on_delta_humanized",
"order_direction": "desc",
"filters": [
{
"col": "slice_name",
"opr": "sw",
"value": "regression_27500_",
}
],
}
uri = f"api/v1/chart/?q={rison.dumps(arguments)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
results = data["result"]
assert len(results) >= 2, f"expected at least 2 results, got {results}"
# The two inserted charts should appear in newest-first order.
indices = {
row["slice_name"]: i
for i, row in enumerate(results)
if row["slice_name"].startswith("regression_27500_")
}
ordering = [
row["slice_name"]
for row in results
if row["slice_name"].startswith("regression_27500_")
]
assert (
indices["regression_27500_newer"] < indices["regression_27500_older"]
), f"changed_on_delta_humanized desc sort is wrong: {ordering}; see #27500"
finally:
db.session.delete(chart_older)
db.session.delete(chart_newer)
db.session.commit()
def test_get_charts_changed_on(self):
"""
Dashboard API: Test get charts changed on
+2 -1
View File
@@ -337,7 +337,8 @@ class TestReportSchedulesApi(SupersetTestCase):
assert rv.status_code == 200
assert "can_read" in data["permissions"]
assert "can_write" in data["permissions"]
assert len(data["permissions"]) == 2
assert "can_subscribe" in data["permissions"]
assert len(data["permissions"]) == 3
@pytest.mark.usefixtures("create_report_schedules")
def test_get_report_schedule_not_found(self):
+50
View File
@@ -152,3 +152,53 @@ def test_time_grain_validation_with_config_addons(app_context: None) -> None:
}
result = schema.load(custom_data)
assert result["time_grain"] == "PT10M"
def test_post_processing_operation_validates_options(app_context: None) -> None:
"""options are validated against the operation's option schema (leniently)."""
from superset.charts.schemas import ChartDataPostProcessingOperationSchema
schema = ChartDataPostProcessingOperationSchema()
# Valid prophet options load.
schema.load(
{
"operation": "prophet",
"options": {
"time_grain": "P1D",
"periods": 7,
"confidence_interval": 0.8,
},
}
)
# Out-of-range confidence_interval (must be 0-1) on a declared field is
# rejected.
with pytest.raises(ValidationError) as exc_info:
schema.load(
{
"operation": "prophet",
"options": {
"time_grain": "P1D",
"periods": 7,
"confidence_interval": 2.0,
},
}
)
assert "options" in exc_info.value.messages
# Extra/unknown keys are tolerated (lenient validation).
schema.load(
{
"operation": "prophet",
"options": {
"time_grain": "P1D",
"periods": 7,
"confidence_interval": 0.8,
"some_future_option": True,
},
}
)
# An operation without a dedicated schema accepts arbitrary options.
schema.load({"operation": "flatten", "options": {"anything": [1, 2, 3]}})
@@ -1478,3 +1478,46 @@ def test_success_state_report_sends_and_logs_success(
ReportState.SUCCESS,
error_message=None,
)
def test_get_url_for_csv_uses_post_processed_type(
app: SupersetApp,
mocker: MockerFixture,
) -> None:
"""Regression for #25538: when an alert/report generates a CSV for a
chart, the URL must request type=POST_PROCESSED so the chart's saved
filters (including time-range filters) are applied. The original report
described a chart with a "last 30 days" filter that returned only 14
rows in the UI, but the alert CSV came back with 219 rows (the entire
unfiltered table).
POST_PROCESSED is the marker that propagates the chart's query_context
-- including its time filter -- through to the CSV renderer. A
regression that switched to FULL or RAW would replicate the original
bug.
"""
from datetime import datetime
from uuid import UUID
from superset.commands.report.execute import BaseReportState
from superset.common.chart_data import ChartDataResultFormat
app.config.update({"ALERT_REPORTS_EXECUTORS": {}})
report_schedule = create_report_schedule(mocker)
report_schedule.force_screenshot = False
report_schedule.chart_id = report_schedule.chart.id
state = BaseReportState(
report_schedule=report_schedule,
scheduled_dttm=datetime.now(),
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
)
url = state._get_url(result_format=ChartDataResultFormat.CSV)
assert "format=csv" in url.lower(), f"expected csv format in URL: {url}"
assert "type=post_processed" in url.lower(), (
f"CSV report URL must use type=post_processed so chart filters "
f"(incl. time filters) are applied; got: {url}; see issue #25538"
)
+145 -3
View File
@@ -14,11 +14,11 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for UpdateReportScheduleCommand.validate() database invariants."""
"""Unit tests for UpdateReportScheduleCommand.validate()."""
from __future__ import annotations
from unittest.mock import Mock
from unittest.mock import Mock, patch
import pytest
from pytest_mock import MockerFixture
@@ -30,7 +30,11 @@ from superset.commands.report.exceptions import (
from superset.commands.report.update import UpdateReportScheduleCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.reports.models import ReportScheduleType, ReportState
from superset.reports.models import (
ReportCreationMethod,
ReportScheduleType,
ReportState,
)
def _make_model(
@@ -38,10 +42,12 @@ def _make_model(
*,
model_type: ReportScheduleType | str,
database_id: int | None,
creation_method: ReportCreationMethod = ReportCreationMethod.ALERTS_REPORTS,
) -> Mock:
model = mocker.Mock()
model.type = model_type
model.database_id = database_id
model.creation_method = creation_method
model.name = "test_schedule"
model.crontab = "0 9 * * *"
model.last_state = "noop"
@@ -257,6 +263,142 @@ def test_report_to_alert_with_db_accepted(mocker: MockerFixture) -> None:
cmd.validate() # should not raise
# --- Recipient enforcement for chart/dashboard reports ---
_PATCH_GET_USER_EMAIL = "superset.commands.report.update.get_user_email"
def test_chart_report_update_recipient_overridden_with_owner_email(
mocker: MockerFixture,
) -> None:
"""Updating recipients on a chart report always locks them to the owner's email."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.CHARTS,
)
_setup_mocks(mocker, model)
data = {
"recipients": [
{
"type": "Email",
"recipient_config_json": {"target": "other@example.com"},
}
]
}
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
recipients = cmd._properties["recipients"]
assert len(recipients) == 1
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
def test_dashboard_report_update_recipient_overridden_with_owner_email(
mocker: MockerFixture,
) -> None:
"""Updating recipients on a dashboard report locks them to the owner's email."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.DASHBOARDS,
)
_setup_mocks(mocker, model)
data = {
"recipients": [
{
"type": "Email",
"recipient_config_json": {"target": "other@example.com"},
}
]
}
cmd = UpdateReportScheduleCommand(model_id=1, data=data)
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
recipients = cmd._properties["recipients"]
assert len(recipients) == 1
assert recipients[0]["recipient_config_json"]["target"] == "owner@example.com"
def test_alerts_reports_update_recipient_not_overridden(
mocker: MockerFixture,
) -> None:
"""Recipients on admin-created alerts/reports are not modified on update."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.ALERTS_REPORTS,
)
_setup_mocks(mocker, model)
original_recipient = {
"type": "Email",
"recipient_config_json": {"target": "team@example.com"},
}
cmd = UpdateReportScheduleCommand(
model_id=1, data={"recipients": [original_recipient]}
)
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
assert (
cmd._properties["recipients"][0]["recipient_config_json"]["target"]
== "team@example.com"
)
def test_chart_report_update_no_recipients_in_payload_unchanged(
mocker: MockerFixture,
) -> None:
"""If recipients are not in the update payload, nothing is changed."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.CHARTS,
)
_setup_mocks(mocker, model)
cmd = UpdateReportScheduleCommand(model_id=1, data={"name": "new name"})
with patch(_PATCH_GET_USER_EMAIL, return_value="owner@example.com"):
cmd.validate()
assert "recipients" not in cmd._properties
def test_chart_report_update_no_user_email_raises(mocker: MockerFixture) -> None:
"""Update fails with a validation error when the user has no email address."""
model = _make_model(
mocker,
model_type=ReportScheduleType.REPORT,
database_id=None,
creation_method=ReportCreationMethod.CHARTS,
)
_setup_mocks(mocker, model)
cmd = UpdateReportScheduleCommand(
model_id=1,
data={
"recipients": [
{"type": "Email", "recipient_config_json": {"target": "x@y.com"}}
]
},
)
with patch(_PATCH_GET_USER_EMAIL, return_value=None):
with pytest.raises(ReportScheduleInvalidError) as exc_info:
cmd.validate()
messages = _get_validation_messages(exc_info)
assert "recipients" in messages
# --- Deactivation state reset ---
@@ -137,3 +137,51 @@ def test_get_virtual_table_metadata_multiple(mocker: MockerFixture) -> None:
with pytest.raises(SupersetSecurityException) as excinfo:
get_virtual_table_metadata(dataset)
assert str(excinfo.value) == "Only single queries supported"
def test_get_virtual_table_metadata_renders_jinja(mocker: MockerFixture) -> None:
"""Regression for #25839: Jinja templates in a virtual dataset's SQL must
be rendered via the template processor before SQL parsing. Otherwise the
raw Jinja tokens reach sqlglot and the parser rejects them as a syntax
error (the user-visible symptom is "Invalid SQL" when clicking
"SYNC COLUMNS FROM SOURCE" on a dataset that uses {{ from_dttm }} etc.).
"""
mock_get_columns_description = mocker.patch(
"superset.connectors.sqla.utils.get_columns_description",
return_value=[{"name": "rendered_col", "type": "INTEGER"}],
)
raw_sql = "SELECT * FROM tbl WHERE ts > '{{ from_dttm }}'"
rendered_sql = "SELECT * FROM tbl WHERE ts > '2024-01-01 00:00:00'"
dataset = mocker.MagicMock(sql=raw_sql)
dataset.database.db_engine_spec.engine = "postgresql"
dataset.template_params_dict = {}
dataset.get_template_processor().process_template.return_value = rendered_sql
# If Jinja rendering is skipped, sqlglot tries to parse the raw {{ ... }}
# and raises SupersetGenericDBErrorException / SupersetParseError.
assert get_virtual_table_metadata(dataset) == [
{"name": "rendered_col", "type": "INTEGER"}
]
# The template processor MUST have been called with the raw SQL (the
# whole point of the bug fix). A future regression that re-introduces
# the "Jinja not rendered" path would either skip this call or call it
# with the wrong input.
dataset.get_template_processor().process_template.assert_any_call(
raw_sql, **dataset.template_params_dict
)
# End-to-end guard: the rendered SQL must reach get_columns_description,
# not the raw Jinja string. A regression where rendering is used for
# parsing only and the raw SQL leaks downstream would pass the
# process_template assertion above but fail this one.
call_args = mock_get_columns_description.call_args
assert call_args is not None, "get_columns_description was never called"
passed_query = call_args.kwargs.get("query")
if passed_query is None and call_args.args:
passed_query = call_args.args[-1]
assert passed_query == rendered_sql, (
f"get_columns_description received unrendered SQL: {passed_query!r}"
)
@@ -123,6 +123,251 @@ def test_update_native_filter_config_scope_excluded():
}
def test_update_native_filter_config_preserves_rootpath_and_remaps_excluded():
"""
Regression guard for #19944: a native filter's ``scope`` has two parts that
must both survive an export/import roundtrip:
- ``rootPath`` controls *which* dashboard sections (tabs/rows) the filter
applies to. It uses position keys (``ROOT_ID``, ``TAB-xxx``), not chart
IDs, so ``update_id_refs`` must leave it untouched.
- ``excluded`` is a list of chart IDs the filter does NOT apply to within
its rootPath. Those IDs must be remapped to destination-env IDs.
The original bug report "filters are automatically applied to all charts,
even if a different scoping was defined before the export" — describes a
rootPath silently being collapsed back to ``["ROOT_ID"]`` (i.e. "apply
everywhere"). This test pins the post-refactor contract: the import path
must not mutate or drop ``rootPath``.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs
config: dict[str, Any] = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
"CHART2": {
"id": "CHART2",
"meta": {"chartId": 102, "uuid": "uuid2"},
"type": "CHART",
},
"CHART3": {
"id": "CHART3",
"meta": {"chartId": 103, "uuid": "uuid3"},
"type": "CHART",
},
},
"metadata": {
"native_filter_configuration": [
{
"id": "NATIVE_FILTER-region",
"name": "Region",
"scope": {
# Filter applies only to charts under TAB-revenue,
# except chart 102 which is explicitly excluded.
"rootPath": ["TAB-revenue"],
"excluded": [102],
},
},
{
"id": "NATIVE_FILTER-product",
"name": "Product",
"scope": {
# Different filter, different rootPath; must not be
# cross-contaminated with the first filter's scope.
"rootPath": ["TAB-inventory", "TAB-revenue"],
"excluded": [101, 103],
},
},
],
},
}
chart_ids = {"uuid1": 1, "uuid2": 2, "uuid3": 3}
dataset_info: dict[str, dict[str, Any]] = {}
fixed = update_id_refs(config, chart_ids, dataset_info)
filters = fixed["metadata"]["native_filter_configuration"]
# rootPath uses position keys, not chart IDs — must pass through unchanged.
assert filters[0]["scope"]["rootPath"] == ["TAB-revenue"]
assert filters[1]["scope"]["rootPath"] == ["TAB-inventory", "TAB-revenue"]
# excluded uses chart IDs — must be remapped to destination-env IDs.
assert filters[0]["scope"]["excluded"] == [2]
assert filters[1]["scope"]["excluded"] == [1, 3]
def test_update_native_filter_config_default_rootpath_preserved():
"""
The "apply everywhere" default ``rootPath: ["ROOT_ID"]`` must also
survive untouched. A regression that special-cased this value (e.g. by
deleting it) would silently change "apply everywhere" into "apply nowhere"
on import, since downstream consumers treat a missing rootPath as empty
rather than as the default.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs
config: dict[str, Any] = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
},
"metadata": {
"native_filter_configuration": [
{
"id": "NATIVE_FILTER-global",
"name": "Global",
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
}
],
},
}
chart_ids = {"uuid1": 1}
dataset_info: dict[str, dict[str, Any]] = {}
fixed = update_id_refs(config, chart_ids, dataset_info)
scope = fixed["metadata"]["native_filter_configuration"][0]["scope"]
assert scope["rootPath"] == ["ROOT_ID"]
assert scope["excluded"] == []
def test_update_id_refs_remaps_charts_in_scope():
"""
Regression for #26338: ``chartsInScope`` on a native filter holds chart
IDs and must be remapped from source-env IDs to destination-env IDs
during import.
The export side already converts ``chartsInScope`` IDs to UUIDs (see
``export_example.py:325``). The import side must symmetrically convert
them back to the destination environment's chart IDs. Without that
remap, the field carries stale source IDs into the imported dashboard
and breaks ``filtersInScope`` / ``filtersOutScope`` computation
filters end up applied to the wrong charts (or none at all).
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs
config: dict[str, Any] = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
"CHART2": {
"id": "CHART2",
"meta": {"chartId": 102, "uuid": "uuid2"},
"type": "CHART",
},
},
"metadata": {
"native_filter_configuration": [
{
"id": "NATIVE_FILTER-region",
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
# chartsInScope contains source-env chart IDs.
"chartsInScope": [101, 102, 103],
}
],
},
}
chart_ids = {"uuid1": 1, "uuid2": 2}
dataset_info: dict[str, dict[str, Any]] = {}
fixed = update_id_refs(config, chart_ids, dataset_info)
filter_config = fixed["metadata"]["native_filter_configuration"][0]
# Resolved IDs are remapped; unknown IDs (103) are dropped rather than
# left to silently bind to whatever chart owns that integer in the
# destination environment.
assert filter_config["chartsInScope"] == [1, 2]
def test_update_id_refs_remaps_cross_filter_charts_in_scope():
"""
Companion to test_update_id_refs_remaps_charts_in_scope. Cross-filter
config also stores ``chartsInScope`` (under ``crossFilters`` per chart)
and must be remapped on import for the same reason.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs
config: dict[str, Any] = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
"CHART2": {
"id": "CHART2",
"meta": {"chartId": 102, "uuid": "uuid2"},
"type": "CHART",
},
},
"metadata": {
"chart_configuration": {
"101": {
"id": 101,
"crossFilters": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
"chartsInScope": [101, 102, 103],
},
},
},
},
}
chart_ids = {"uuid1": 1, "uuid2": 2}
dataset_info: dict[str, dict[str, Any]] = {}
fixed = update_id_refs(config, chart_ids, dataset_info)
cross_filters = fixed["metadata"]["chart_configuration"]["1"]["crossFilters"]
assert cross_filters["chartsInScope"] == [1, 2]
def test_update_id_refs_remaps_global_chart_configuration_charts_in_scope():
"""
Per-chart and native-filter ``chartsInScope`` are remapped by their own
branches; ``global_chart_configuration.chartsInScope`` lives next to
``global_chart_configuration.scope.excluded`` and needs the same treatment
so the global cross-filter scope cache doesn't keep stale source-env IDs.
"""
from superset.commands.dashboard.importers.v1.utils import update_id_refs
config: dict[str, Any] = {
"position": {
"CHART1": {
"id": "CHART1",
"meta": {"chartId": 101, "uuid": "uuid1"},
"type": "CHART",
},
"CHART2": {
"id": "CHART2",
"meta": {"chartId": 102, "uuid": "uuid2"},
"type": "CHART",
},
},
"metadata": {
"global_chart_configuration": {
"scope": {"rootPath": ["ROOT_ID"], "excluded": []},
"chartsInScope": [101, 102, 103],
},
},
}
chart_ids = {"uuid1": 1, "uuid2": 2}
dataset_info: dict[str, dict[str, Any]] = {}
fixed = update_id_refs(config, chart_ids, dataset_info)
assert fixed["metadata"]["global_chart_configuration"]["chartsInScope"] == [1, 2]
def test_update_id_refs_cross_filter_chart_configuration_key_and_excluded_mapping():
from superset.commands.dashboard.importers.v1.utils import update_id_refs
@@ -15,14 +15,14 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import column, types
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, JSON
from sqlalchemy.dialects.postgresql import DOUBLE_PRECISION, ENUM, INTERVAL, JSON
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.engine.url import make_url
@@ -87,6 +87,8 @@ def test_convert_dttm(
("TIME", types.Time, None, GenericDataType.TEMPORAL, True),
# Boolean
("BOOLEAN", types.Boolean, None, GenericDataType.BOOLEAN, False),
# Interval (mapped to NUMERIC for chart rendering)
("INTERVAL", INTERVAL, None, GenericDataType.NUMERIC, False),
],
)
def test_get_column_spec(
@@ -366,3 +368,38 @@ class TestRedshiftDetection:
spec.update_params_from_encrypted_extra(database, params)
assert "pool_events" not in params
def test_interval_type_mutator() -> None:
"""
DB Eng Specs (postgres): Test INTERVAL type mutator
INTERVAL values are converted to milliseconds so users can apply
the built-in "DURATION" number format for human-readable display.
"""
mutator = spec.column_type_mutators[INTERVAL]
# Timedelta conversion — the only path psycopg2/psycopg3 actually
# exercises. Result is in milliseconds for compatibility with the
# DURATION formatter.
td = timedelta(days=1, hours=2, minutes=30, seconds=45)
assert mutator(td) == 95445000.0 # (1*86400 + 2*3600 + 30*60 + 45) * 1000
# Zero duration
assert mutator(timedelta(0)) == 0.0
# Negative interval
assert mutator(timedelta(days=-1)) == -86400000.0
# None preserves NULL semantics (not converted to 0)
assert mutator(None) is None
# Unexpected non-timedelta types fall through to the defensive
# `return None` (and emit a warning) rather than producing a
# mixed-type column.
assert mutator("1 day 02:30:45") is None
assert mutator("P1DT2H30M45S") is None
assert mutator(12345) is None
assert mutator(True) is None
assert mutator([1, 2, 3]) is None
assert mutator({"days": 1}) is None
@@ -15,9 +15,88 @@
# specific language governing permissions and limitations
# under the License.
"""Unit tests for ascii_charts.py NaN/null value handling."""
"""Unit tests for ASCII charts: trend analysis, NaN/null handling,
and boolean skipping."""
from superset.mcp_service.chart.ascii_charts import generate_ascii_chart
from superset.mcp_service.chart.ascii_charts import (
_analyze_trend,
_extract_time_series_data,
generate_ascii_chart,
)
def test_analyze_trend_temporal_upward():
values = [100.0, 110.0, 120.0, 130.0, 150.0]
result = _analyze_trend(values, is_temporal=True)
trend_line = result[0]
assert "upward trend" in trend_line.lower()
assert "categorical" not in trend_line.lower()
def test_analyze_trend_temporal_downward():
values = [200.0, 180.0, 150.0, 120.0, 100.0]
result = _analyze_trend(values, is_temporal=True)
trend_line = result[0]
assert "downward trend" in trend_line.lower()
def test_analyze_trend_temporal_stable():
values = [100.0, 101.0, 100.5, 100.2, 100.8]
result = _analyze_trend(values, is_temporal=True)
trend_line = result[0]
assert "stable" in trend_line.lower()
def test_analyze_trend_categorical_skips_direction():
values = [200.0, 180.0, 150.0, 120.0, 100.0]
result = _analyze_trend(values, is_temporal=False)
trend_line = result[0]
assert "categorical" in trend_line.lower()
assert "downward" not in trend_line.lower()
assert "upward" not in trend_line.lower()
def test_analyze_trend_categorical_keeps_range_and_volatility():
values = [200.0, 180.0, 150.0, 120.0, 100.0]
result = _analyze_trend(values, is_temporal=False)
full_text = "\n".join(result)
assert "Range:" in full_text
assert "Volatility:" in full_text
def test_analyze_trend_defaults_to_temporal():
values = [100.0, 150.0, 200.0]
result = _analyze_trend(values)
trend_line = result[0]
assert "categorical" not in trend_line.lower()
assert "upward trend" in trend_line.lower()
def test_extract_time_series_data_temporal_column():
data = [
{"date": "2024-01-01", "value": 100},
{"date": "2024-02-01", "value": 200},
]
values, labels, is_temporal = _extract_time_series_data(data)
assert values == [100, 200]
assert is_temporal is True
def test_extract_time_series_data_categorical_column():
data = [
{"country": "Argentina", "value": 500},
{"country": "Brazil", "value": 300},
]
values, labels, is_temporal = _extract_time_series_data(data)
assert values == [500, 300]
assert is_temporal is False
def test_extract_time_series_data_time_keyword_variants():
for key in ["timestamp", "created_time", "month_name", "day_of_week", "year_end"]:
data = [{key: "val", "metric": 42}]
_, _, is_temporal = _extract_time_series_data(data)
assert is_temporal is True, f"Expected temporal for column '{key}'"
def test_bar_chart_with_null_values_does_not_raise() -> None:
@@ -423,6 +423,93 @@ class TestValidateAndCompileTier2:
assert result.error_code == "DATASET_NOT_FOUND"
@patch("superset.daos.dataset.DatasetDAO")
@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
@patch("superset.common.query_context_factory.QueryContextFactory")
def test_compile_chart_returns_database_error_when_wrapped_in_query_failed(
mock_factory, mock_cmd_cls, mock_dataset_dao
):
"""ChartDataCommand converts OperationalError to a string inside
ChartDataQueryFailedError (no __cause__ set). _classify_as_database_error
should use db_engine_spec.extract_errors() to detect the DB error."""
from superset.commands.chart.exceptions import ChartDataQueryFailedError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.mcp_service.chart.compile import _compile_chart
mock_factory.return_value.create.return_value = Mock()
mock_cmd_cls.return_value.validate.return_value = None
# Real scenario: __cause__ is NOT set, error is just a string
wrapped = ChartDataQueryFailedError(
"Error: (psycopg2.OperationalError) connection to server at '10.0.0.1',"
" port 5432 failed: FATAL: tenant not found"
)
mock_cmd_cls.return_value.run.side_effect = wrapped
# Mock the dataset's db_engine_spec to return GENERIC_DB_ENGINE_ERROR
mock_db = Mock()
mock_db.db_engine_spec.extract_errors.return_value = [
SupersetError(
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
message="connection to server failed",
level=ErrorLevel.ERROR,
extra={"engine_name": "PostgreSQL"},
)
]
mock_dataset = Mock()
mock_dataset.database = mock_db
mock_dataset_dao.find_by_id.return_value = mock_dataset
result = _compile_chart(
form_data={
"metrics": [{"label": "count", "expressionType": "SIMPLE"}],
"adhoc_filters": [],
},
dataset_id=1,
)
assert not result.success
assert "Database connection error" in result.error
assert result.error_obj is not None
assert result.error_obj.error_type == "database_connection_error"
assert result.error_obj.error_code == "DATABASE_CONNECTION_ERROR"
mock_db.db_engine_spec.extract_errors.assert_called_once()
@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
@patch("superset.common.query_context_factory.QueryContextFactory")
def test_compile_chart_returns_database_error_on_raw_sqlalchemy_error(
mock_factory, mock_cmd_cls
):
"""When SQLAlchemyError escapes unwrapped, _compile_chart should
catch it and return a database_connection_error."""
from sqlalchemy.exc import OperationalError
from superset.mcp_service.chart.compile import _compile_chart
mock_factory.return_value.create.return_value = Mock()
mock_cmd_cls.return_value.validate.return_value = None
mock_cmd_cls.return_value.run.side_effect = OperationalError(
"connection to server at '10.0.0.1', port 5432 failed: Connection timed out",
None,
None,
)
result = _compile_chart(
form_data={
"metrics": [{"label": "count", "expressionType": "SIMPLE"}],
"adhoc_filters": [],
},
dataset_id=1,
)
assert not result.success
assert "Database connection error" in result.error
assert result.error_obj is not None
assert result.error_obj.error_type == "database_connection_error"
assert result.error_obj.error_code == "DATABASE_CONNECTION_ERROR"
@pytest.mark.parametrize(
"config_factory",
[
@@ -20,6 +20,7 @@ Unit tests for dashboard generation MCP tools
"""
import logging
from datetime import datetime
from importlib import import_module
from unittest.mock import Mock, patch
@@ -117,8 +118,8 @@ def _mock_dashboard(id: int = 1, title: str = "Test Dashboard") -> Mock:
dashboard.slug = f"test-dashboard-{id}"
dashboard.description = "Test dashboard description"
dashboard.published = True
dashboard.created_on = "2024-01-01"
dashboard.changed_on = "2024-01-01"
dashboard.created_on = datetime(2024, 1, 1)
dashboard.changed_on = datetime(2024, 1, 1)
dashboard.created_by = Mock()
dashboard.created_by.username = "test_user"
dashboard.changed_by = Mock()
@@ -1317,3 +1317,153 @@ class TestColumnInfoIsNullable:
{"name": "c", "type": "int", "is_nullable": "UNKNOWN"}
)
assert col.is_nullable is None
class TestDestructiveDDLBlocking:
"""Tests for destructive DDL blocking in execute_sql."""
@pytest.fixture
def ddl_mocks(self):
"""Common mock wiring for DDL blocking tests."""
with (
patch("superset.db") as mock_db,
patch("superset.security_manager") as mock_sm,
):
mock_database = _mock_database()
mock_database.db_engine_spec.engine = "postgresql"
query_chain = mock_db.session.query.return_value
query_chain.filter_by.return_value.first.return_value = mock_database
mock_sm.can_access_database.return_value = True
yield mock_database
@pytest.mark.asyncio
async def test_drop_table_blocked(self, ddl_mocks, mcp_server):
"""DROP TABLE is blocked before reaching the executor."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{"request": {"database_id": 1, "sql": "DROP TABLE birth_names"}},
)
data = result.structured_content
assert data["success"] is False
assert "Destructive DDL" in data["error"]
assert "DROP" in data["error"]
ddl_mocks.execute.assert_not_called()
@pytest.mark.asyncio
async def test_truncate_blocked(self, ddl_mocks, mcp_server):
"""TRUNCATE TABLE is blocked before reaching the executor."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{"request": {"database_id": 1, "sql": "TRUNCATE TABLE birth_names"}},
)
data = result.structured_content
assert data["success"] is False
assert "Destructive DDL" in data["error"]
ddl_mocks.execute.assert_not_called()
@pytest.mark.asyncio
async def test_alter_table_blocked(self, ddl_mocks, mcp_server):
"""ALTER TABLE is blocked before reaching the executor."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{
"request": {
"database_id": 1,
"sql": "ALTER TABLE birth_names ADD COLUMN x INT",
}
},
)
data = result.structured_content
assert data["success"] is False
assert "Destructive DDL" in data["error"]
ddl_mocks.execute.assert_not_called()
@pytest.mark.asyncio
async def test_drop_in_multi_statement_blocked(self, ddl_mocks, mcp_server):
"""DROP TABLE hidden in a multi-statement query is blocked."""
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{
"request": {
"database_id": 1,
"sql": "DROP TABLE birth_names; SELECT 1",
}
},
)
data = result.structured_content
assert data["success"] is False
assert "Destructive DDL" in data["error"]
ddl_mocks.execute.assert_not_called()
@pytest.mark.asyncio
async def test_select_allowed(self, ddl_mocks, mcp_server):
"""SELECT queries pass through the DDL check."""
ddl_mocks.execute.return_value = _create_select_result(
rows=[{"x": 1}], columns=["x"], original_sql="SELECT 1"
)
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{"request": {"database_id": 1, "sql": "SELECT 1"}},
)
data = result.structured_content
assert data["success"] is True
ddl_mocks.execute.assert_called_once()
@pytest.mark.asyncio
async def test_insert_allowed(self, ddl_mocks, mcp_server):
"""INSERT queries pass through the DDL check (DML is allowed)."""
ddl_mocks.execute.return_value = _create_dml_result(
affected_rows=1,
original_sql="INSERT INTO t VALUES (1)",
)
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{"request": {"database_id": 1, "sql": "INSERT INTO t VALUES (1)"}},
)
data = result.structured_content
assert data["success"] is True
ddl_mocks.execute.assert_called_once()
@pytest.mark.asyncio
async def test_parse_failure_blocks_query(self, ddl_mocks, mcp_server):
"""When SQL parsing fails, the query is blocked (fail-closed)."""
import sys
execute_sql_mod = sys.modules["superset.mcp_service.sql_lab.tool.execute_sql"]
with patch.object(
execute_sql_mod,
"SQLScript",
side_effect=Exception("parse error"),
):
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{"request": {"database_id": 1, "sql": "DROP TABLE birth_names"}},
)
data = result.structured_content
assert data["success"] is False
assert "could not be parsed" in data["error"]
ddl_mocks.execute.assert_not_called()
@pytest.mark.asyncio
async def test_drop_table_blocked_mysql(self, ddl_mocks, mcp_server):
"""DROP TABLE is blocked for non-PostgreSQL dialects too."""
ddl_mocks.db_engine_spec.engine = "mysql"
async with Client(mcp_server) as client:
result = await client.call_tool(
"execute_sql",
{"request": {"database_id": 1, "sql": "DROP TABLE users"}},
)
data = result.structured_content
assert data["success"] is False
assert "Destructive DDL" in data["error"]
ddl_mocks.execute.assert_not_called()
@@ -346,6 +346,42 @@ class TestGenerateBugReportViaMCP:
finally:
app.config.pop("MCP_BUG_REPORT_CONTACT", None)
@pytest.mark.asyncio
async def test_generate_bug_report_includes_mcp_call_id(self, mcp_server):
"""Bug report includes mcp_call_id when provided."""
with patch("flask.g") as mock_g:
mock_g.user = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"generate_bug_report",
{
"request": {
"tool_name": "health_check",
"mcp_call_id": "abc123def456",
}
},
)
data = json.loads(result.content[0].text)
assert "abc123def456" in data["report"]
assert "MCP Call ID" in data["report"]
@pytest.mark.asyncio
async def test_generate_bug_report_omits_mcp_call_id_when_absent(self, mcp_server):
"""Bug report omits MCP Call ID line when not provided."""
with patch("flask.g") as mock_g:
mock_g.user = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"generate_bug_report",
{"request": {"tool_name": "health_check"}},
)
data = json.loads(result.content[0].text)
assert "MCP Call ID" not in data["report"]
# ---------------------------------------------------------------------------
# Schema-level tests: max_length caps
@@ -24,10 +24,14 @@ Tests verify that:
- _extract_context_info() extracts entity IDs from params
"""
from functools import partial
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.tools.tool import ToolResult
from mcp import types as mt
from superset.mcp_service.middleware import LoggingMiddleware
@@ -115,8 +119,6 @@ class TestLoggingMiddlewareOnCallTool:
sits between GlobalErrorHandler and StructuredContentStripper, it
catches the ToolError directly.
"""
from fastmcp.exceptions import ToolError
middleware = LoggingMiddleware()
ctx = _make_context(name="get_chart_info")
call_next = AsyncMock(side_effect=ToolError("Chart 999999 not found"))
@@ -130,6 +132,62 @@ class TestLoggingMiddlewareOnCallTool:
assert call_kwargs["curated_payload"]["tool"] == "get_chart_info"
assert call_kwargs["duration_ms"] >= 0
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_includes_mcp_call_id_in_curated_payload(
self, mock_get_user_id, mock_event_logger
):
"""on_call_tool adds mcp_call_id to curated_payload."""
middleware = LoggingMiddleware()
ctx = _make_context(name="list_charts")
call_next = AsyncMock(return_value="tool_result")
await middleware.on_call_tool(ctx, call_next)
call_kwargs = mock_event_logger.log.call_args[1]
mcp_call_id = call_kwargs["curated_payload"]["mcp_call_id"]
assert isinstance(mcp_call_id, str)
assert len(mcp_call_id) == 32
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_injects_mcp_call_id_into_tool_result_meta(
self, mock_get_user_id, mock_event_logger
):
"""on_call_tool injects mcp_call_id into ToolResult.meta."""
middleware = LoggingMiddleware()
ctx = _make_context(name="list_charts")
original_result = ToolResult(content=[mt.TextContent(type="text", text="ok")])
call_next = AsyncMock(return_value=original_result)
result = await middleware.on_call_tool(ctx, call_next)
assert isinstance(result, ToolResult)
assert "mcp_call_id" in result.meta
assert len(result.meta["mcp_call_id"]) == 32
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_on_call_tool_preserves_existing_meta(
self, mock_get_user_id, mock_event_logger
):
"""on_call_tool merges mcp_call_id with existing ToolResult.meta."""
middleware = LoggingMiddleware()
ctx = _make_context(name="list_charts")
original_result = ToolResult(
content=[mt.TextContent(type="text", text="ok")],
meta={"existing_key": "existing_value"},
)
call_next = AsyncMock(return_value=original_result)
result = await middleware.on_call_tool(ctx, call_next)
assert result.meta["existing_key"] == "existing_value"
assert "mcp_call_id" in result.meta
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
@@ -241,9 +299,6 @@ class TestIsErrorResponse:
def test_detects_error_schema_response(self):
"""Detects ToolResult containing a serialized error schema
(ChartError, DashboardError, etc.) via "error_type" field."""
from fastmcp.tools.tool import ToolResult
from mcp import types as mt
middleware = LoggingMiddleware()
error_json = (
'{"error": "Chart 999 not found",'
@@ -255,9 +310,6 @@ class TestIsErrorResponse:
def test_success_response_not_detected_as_error(self):
"""Normal ToolResult is not detected as error."""
from fastmcp.tools.tool import ToolResult
from mcp import types as mt
middleware = LoggingMiddleware()
result = ToolResult(
content=[mt.TextContent(type="text", text="Successfully retrieved data")]
@@ -266,8 +318,6 @@ class TestIsErrorResponse:
def test_empty_content_not_detected_as_error(self):
"""ToolResult with empty content is not detected as error."""
from fastmcp.tools.tool import ToolResult
middleware = LoggingMiddleware()
assert middleware._is_error_response(ToolResult(content=[])) is False
@@ -279,9 +329,6 @@ class TestIsErrorResponse:
):
"""on_call_tool logs success=False when tool returns an
error schema (e.g. ChartError)."""
from fastmcp.tools.tool import ToolResult
from mcp import types as mt
middleware = LoggingMiddleware()
ctx = _make_context(name="get_chart_info")
@@ -297,7 +344,9 @@ class TestIsErrorResponse:
result = await middleware.on_call_tool(ctx, call_next)
assert result == error_result
assert isinstance(result, ToolResult)
assert result.content == error_result.content
assert "mcp_call_id" in result.meta
mock_event_logger.log.assert_called_once()
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["curated_payload"]["success"] is False
@@ -320,10 +369,6 @@ class TestMiddlewareChainOrder:
):
"""Tool exception is logged as success=False through the
real middleware chain from build_middleware_list()."""
from functools import partial
from fastmcp.tools.tool import ToolResult
from superset.mcp_service.server import build_middleware_list
middleware_list = build_middleware_list()
@@ -362,3 +407,31 @@ class TestMiddlewareChainOrder:
"them. Ensure StructuredContentStripper is outermost "
"(first added) in build_middleware_list()."
)
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
async def test_real_middleware_chain_error_result_has_mcp_call_id(
self, mock_get_user_id, mock_event_logger
):
"""When a tool raises, the error ToolResult from
StructuredContentStripper still carries mcp_call_id in meta."""
from superset.mcp_service.server import build_middleware_list
middleware_list = build_middleware_list()
async def failing_tool(context: Any) -> Any:
raise ValueError("chart not found")
chain = failing_tool
for mw in reversed(middleware_list):
chain = partial(mw, call_next=chain)
ctx = _make_context(name="get_chart_info")
result = await chain(ctx)
assert isinstance(result, ToolResult)
assert result.content[0].text.startswith("Error:")
assert result.meta is not None
assert "mcp_call_id" in result.meta
assert len(result.meta["mcp_call_id"]) == 32

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