Compare commits

...
Author SHA1 Message Date
Beto Dealmeida 7e917a18e7 WIP 2025-11-07 14:03:39 -05:00
Beto Dealmeida 76fd626d57 Working on exporable 2025-11-05 16:59:06 -05:00
Beto Dealmeida df2cad1aed WIP 2025-11-05 15:34:00 -05:00
Beto Dealmeida 278e982ab0 WIP 2025-11-05 13:19:18 -05:00
Beto Dealmeida e6beebc88c WIP 2025-11-05 13:19:12 -05:00
Beto Dealmeida 8a9cca546b DAOS/commands 2025-11-04 15:19:34 -05:00
Beto Dealmeida 9997cdeb62 SQLAlchemy models 2025-11-04 11:41:21 -05:00
Beto Dealmeida ff20d991ab Fix lint 2025-11-04 10:36:38 -05:00
Beto Dealmeida 97a0eb5ffa Cleaning up 2025-10-30 18:24:59 -04:00
Beto Dealmeida 3891cfeeb3 Split tests 2025-10-30 14:58:22 -04:00
Beto Dealmeida 97e52d9485 Split snowflake 2025-10-30 13:54:57 -04:00
Beto Dealmeida debcde2057 Rearrange mapper 2025-10-29 15:41:14 -04:00
Beto Dealmeida f6be0b4dea Fix tests 2025-10-29 15:36:30 -04:00
Beto Dealmeida 836dddafc6 Add tests 2025-10-29 15:21:31 -04:00
Beto Dealmeida fb39bcbde3 Return with queries 2025-10-29 14:35:08 -04:00
Beto Dealmeida 0348fe93bd QueryObject to df 2025-10-29 14:25:45 -04:00
Beto Dealmeida 9215d3f064 Unit tests for Snowflake SL 2025-10-28 22:00:12 -04:00
Beto Dealmeida 4aa4985562 WIP 2025-10-28 18:50:05 -04:00
Beto Dealmeida d07e209a9d WIP 2025-10-28 15:11:33 -04:00
Beto Dealmeida 29e335aa3e WIP 2025-10-28 15:08:35 -04:00
Beto Dealmeida e3dec47a5e More Snowflake tests 2025-10-27 16:25:20 -04:00
Beto Dealmeida 0cc1f46516 WIP Snowflake tests 2025-10-27 14:56:52 -04:00
Beto Dealmeida 7aa9c63b66 More tests 2025-10-27 13:42:47 -04:00
Beto Dealmeida 5ba6db46a7 Add unit tests 2025-10-27 12:50:06 -04:00
Beto Dealmeida d525b05d71 WIP fix tuple 2025-10-23 12:20:28 -04:00
Beto Dealmeida d14bcba501 WIP filters inner 2025-10-22 17:04:32 -04:00
Beto Dealmeida 15d286aacf WIP filters 2025-10-22 16:59:18 -04:00
Beto Dealmeida 9e16d111fb WIP 2025-10-22 16:43:26 -04:00
Beto Dealmeida 297bd1e732 Add protocols 2025-10-22 10:10:47 -04:00
Beto Dealmeida bfa930a3ac Adhoc order by 2025-10-21 15:41:28 -04:00
Beto Dealmeida befcf96027 WIP 2025-10-20 19:00:58 -04:00
Beto Dealmeida 6f6567d5c9 Working on mapper 2025-10-20 18:38:46 -04:00
Beto Dealmeida 3fb58b996a Improve response to include queries 2025-10-20 18:38:46 -04:00
Beto Dealmeida ffae2063e2 Fix lint 2025-10-20 18:38:46 -04:00
Beto Dealmeida e1899f1014 GroupFilter 2025-10-20 18:38:46 -04:00
Beto Dealmeida dfc6aad5f0 Working on GroupFilter 2025-10-20 18:38:46 -04:00
Beto Dealmeida 837ea2a07f Improving dataframe method 2025-10-20 18:38:46 -04:00
Beto Dealmeida b83596893a WIP 2025-10-20 18:38:46 -04:00
Beto Dealmeida a7e446d2ff Dynamic configuration 2025-10-20 18:38:46 -04:00
Beto Dealmeida ccbdc2359e Add docs 2025-10-20 18:38:46 -04:00
Beto Dealmeida 7e40403287 Adding get_dataframe 2025-10-20 18:38:45 -04:00
Beto Dealmeida 4d83840f81 Working on get_values 2025-10-20 18:38:45 -04:00
Beto Dealmeida 4c77a527c5 Add types 2025-10-20 18:38:45 -04:00
Beto Dealmeida 52b1530666 WIP 2025-10-20 18:38:45 -04:00
Beto Dealmeida 70fd9ff617 WIP 2025-10-20 18:38:45 -04:00
Beto Dealmeida ae415b93d5 WIP 2025-10-20 18:38:45 -04:00
Beto Dealmeida 15bfab6b1e WIP 2025-10-20 18:38:45 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8e31c93119 chore(deps): bump @docusaurus/preset-classic from 3.9.1 to 3.9.2 in /docs (#35744)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 13:29:16 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4974c08f7d chore(deps): bump @docusaurus/theme-mermaid from 3.9.1 to 3.9.2 in /docs (#35741)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 13:09:24 -07:00
Michael S. Molina fa90ba976c fix: Extension events not fired in SQL Lab tabs (#35753) 2025-10-20 16:41:24 -03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 35c3d8dfbc chore(deps): bump @docusaurus/plugin-client-redirects from 3.9.1 to 3.9.2 in /docs (#35739)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 12:11:29 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ee23815aff chore(deps): bump @docusaurus/core from 3.9.1 to 3.9.2 in /docs (#35738)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 11:39:53 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7c946ae3db chore(deps-dev): bump @docusaurus/tsconfig from 3.9.1 to 3.9.2 in /docs (#35733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 11:37:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3926f5c55c chore(deps-dev): bump @babel/eslint-parser from 7.26.5 to 7.28.4 in /superset-frontend (#35746)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 11:35:48 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fdc03d4bf3 chore(deps): update xss requirement from ^1.0.14 to ^1.0.15 in /superset-frontend/packages/superset-ui-core (#35747)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 11:35:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 24f0aed8a7 chore(deps): bump swagger-ui-react from 5.29.4 to 5.29.5 in /docs (#35734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 10:54:06 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 00d2f577df chore(deps-dev): bump eslint from 9.37.0 to 9.38.0 in /superset-websocket (#35735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 10:53:41 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c35fc71bc5 chore(deps-dev): bump @eslint/js from 9.37.0 to 9.38.0 in /docs (#35736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-20 10:53:02 -07:00
Elizabeth ThompsonandClaude 1b6d57c3f3 test(tasks): Add tests for log_task_failure signal handler (#35721)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-20 09:23:18 -07:00
Erkka TahvanainenandErkka Tahvanainen d089a96163 fix(playwright): Download dashboard correctly (#35484)
Co-authored-by: Erkka Tahvanainen <erkka.tahvanainen@confidently.fi>
2025-10-20 16:57:01 +02:00
Elizabeth Thompson 0b3fe3d60c test: add comprehensive unhappy path tests for export (#35718) 2025-10-17 16:41:00 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 0eeb184b6a chore(deps): bump @luma.gl/constants from 9.1.9 to 9.2.2 in /superset-frontend (#35696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 13:28:25 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8e7edce616 chore(deps-dev): bump @types/node from 24.6.2 to 24.8.1 in /superset-frontend (#35705)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 12:59:06 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 754201b3d0 chore(deps): bump dayjs from 1.11.13 to 1.11.18 in /superset-frontend (#35697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:29:46 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 925401b4e1 chore(deps-dev): bump @types/node from 24.7.2 to 24.8.1 in /superset-websocket (#35698)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:29:13 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8368ea4094 chore(deps-dev): bump eslint-plugin-prettier from 5.2.3 to 5.5.4 in /superset-frontend (#35700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:28:45 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e8a6fb24ae chore(deps): bump react-reverse-portal from 2.1.2 to 2.3.0 in /superset-frontend (#35706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:27:41 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 311b7a72dc chore(deps-dev): bump copy-webpack-plugin from 13.0.0 to 13.0.1 in /superset-frontend (#35707)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:27:11 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aa496def53 chore(deps-dev): bump @babel/cli from 7.27.2 to 7.28.3 in /superset-frontend (#35708)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:26:47 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> aea4375255 chore(deps): update @fontsource/fira-code requirement from ^5.2.6 to ^5.2.7 in /superset-frontend/packages/superset-ui-core (#35709)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:26:15 -07:00
yousophandClaude 9ab0a0179d fix(charts): update axis title labels to sentence case (#35694)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-17 09:42:04 -07:00
Fabian Halkivaha 3db613dab5 chore(docs): switch to markdown headlines for available macros (#35685) 2025-10-17 10:16:49 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> de5ca79805 chore(deps): update chalk requirement from ^5.4.1 to ^5.6.2 in /superset-frontend/packages/generator-superset (#35391)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 14:08:40 -07:00
Sam Firke aede3bb5ba fix(auth): redirect anonymous attempts to view dashboard with next (#35345) 2025-10-16 16:33:37 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 408f84aea6 chore(deps): update dompurify requirement from ^3.2.7 to ^3.3.0 in /superset-frontend/plugins/legacy-preset-chart-nvd3 (#35632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 12:59:53 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 92c07aaf54 chore(deps-dev): bump @playwright/test from 1.55.0 to 1.56.0 in /superset-frontend (#35676)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 12:58:35 -07:00
Beto DealmeidaandClaude f405174fcf fix(dataset): render default URL description properly in settings (#35669)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-16 14:27:24 -04:00
Richard Fogaca Nienkotter 8c125d2553 feat: use in deck.gl custom tooltip instead of SafeMarkdown (#35665) 2025-10-16 11:13:38 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fb8fca4c64 chore(deps): bump antd from 5.27.4 to 5.27.5 in /docs (#35654)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 10:05:35 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dc0c055518 chore(deps-dev): bump webpack from 5.102.0 to 5.102.1 in /superset-frontend (#35675)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 10:04:08 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 09349cb1e7 chore(deps): bump caniuse-lite from 1.0.30001750 to 1.0.30001751 in /docs (#35677)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 09:39:02 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ca29adb0cb chore(deps): bump fuse.js from 7.0.0 to 7.1.0 in /superset-frontend (#35678)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 09:35:07 -07:00
sha174nandSam Firke 1617bbbe71 docs: Add Production Security Hardening Guide (#35190)
Co-authored-by: Sam Firke <sfirke@users.noreply.github.com>
2025-10-15 18:18:39 -07:00
Gabriel Torres Ruiz de1dd53186 fix(theme-crud): enable overwrite confirmation UI for theme imports (#35558) 2025-10-15 18:15:57 -07:00
Gabriel Torres Ruiz 58672dfab6 fix(table-chart): fix page size label visibility and improve header control wrapping (#35648) 2025-10-15 18:15:02 -07:00
Rafael Benitez 4b5629d1c8 fix(theme): align "Clear local theme" option with other theme menu items (#35651) 2025-10-15 18:14:20 -07:00
Joe LiandClaude 4ddc3f14ed refactor(frontend): convert DatasourceEditor tests to TypeScript (#35606)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-15 16:42:17 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 400a8aec89 chore(deps-dev): bump typescript-eslint from 8.46.0 to 8.46.1 in /superset-websocket (#35630)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 13:44:53 -07:00
Ville BrofeldtandVille Brofeldt 51489a75ce chore: bump pretty-ms to 9.3.0 (#35667)
Co-authored-by: Ville Brofeldt <v_brofeldt@apple.com>
2025-10-15 12:25:34 -07:00
Quentin Leroy 09772eeda0 fix(config.py): reset HTML_SANITIZATION to True by default (#35603) 2025-10-15 12:03:51 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 78907d08cd chore(deps): bump caniuse-lite from 1.0.30001749 to 1.0.30001750 in /docs (#35614)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:58:39 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d0a0d280a1 chore(deps-dev): bump ts-jest from 29.4.4 to 29.4.5 in /superset-websocket (#35615)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:58:10 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5d77ed3677 chore(deps-dev): bump @types/node from 24.7.1 to 24.7.2 in /superset-websocket (#35616)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:57:43 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f68ee6ba67 chore(deps): bump swagger-ui-react from 5.29.3 to 5.29.4 in /docs (#35617)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:57:12 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a01560cfa1 chore(deps-dev): bump typescript-eslint from 8.46.0 to 8.46.1 in /docs (#35628)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:56:38 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7e06ce8eeb chore(deps-dev): bump @typescript-eslint/parser from 8.46.0 to 8.46.1 in /superset-websocket (#35631)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:55:12 -07:00
Elizabeth ThompsonandClaude ccc0e3dbb2 fix: Log Celery task failures with a signal handler (#35595)
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-14 13:01:29 -07:00
Ville BrofeldtandVille Brofeldt bd48e87eeb fix: no fs logging of extensions unless flag is set (#35612)
Co-authored-by: Ville Brofeldt <v_brofeldt@apple.com>
2025-10-14 12:11:43 -07:00
107 changed files with 13499 additions and 1193 deletions
+359
View File
@@ -0,0 +1,359 @@
# Chart Data Request Flow in Apache Superset
This document traces the complete path of a chart data request through the Superset backend, from API endpoint to database query and back.
## Overview
When a client requests chart data (e.g., loading a histogram chart), the request flows through multiple layers:
1. API Endpoint
2. Schema Validation/Parsing
3. Command Pattern (Business Logic)
4. Query Context Processing
5. Database Execution
6. Post-Processing
7. Response Formatting
## Detailed Flow
### 1. Entry Point: API Endpoint
**File**: `superset/charts/data/api.py:187`
**Endpoint**: `POST /api/v1/chart/data`
The request hits `ChartDataRestApi.data()` method which:
- Parses the JSON body from the request
- Creates a `QueryContext` object from the form data via `ChartDataQueryContextSchema`
- Creates a `ChartDataCommand` to execute the query
- Validates and executes the command
```python
def data(self) -> Response:
json_body = request.json
query_context = self._create_query_context_from_form(json_body)
command = ChartDataCommand(query_context)
command.validate()
return self._get_data_response(command, ...)
```
### 2. Schema Layer: Request Parsing
**File**: `superset/charts/schemas.py:1384`
`ChartDataQueryContextSchema.load()` deserializes the request into:
**QueryContext object** (the main container):
- datasource: Database table/query info
- queries: List of query objects
- result_format: JSON/CSV/XLSX
- result_type: FULL/SAMPLES/QUERY/etc
- force: Whether to bypass cache
**List of QueryObject instances** (one per query in the request):
- columns: Columns to select (e.g., ["age"])
- metrics: Aggregations to compute
- filters: WHERE clause filters
- post_processing: Client-side transformations (e.g., histogram with bins=25)
### 3. Command Pattern: Business Logic
**File**: `superset/commands/chart/data/get_data_command.py:39`
`ChartDataCommand.run()` orchestrates the execution:
```python
def run(self, **kwargs: Any) -> dict[str, Any]:
payload = self._query_context.get_payload(
cache_query_context=cache_query_context,
force_cached=force_cached
)
for query in payload["queries"]:
if query.get("error"):
raise ChartDataQueryFailedError(query["error"])
return {
"query_context": self._query_context,
"queries": payload["queries"]
}
```
### 4. Query Context Processor: Core Execution
**File**: `superset/common/query_context_processor.py:1052`
`QueryContextProcessor.get_payload()`:
- Iterates through each `QueryObject` in `query_context.queries`
- For each query, calls `get_query_results()` which routes based on result_type:
- `FULL``_get_full()``get_df_payload()`
- `SAMPLES``_get_samples()`
- `QUERY``_get_query()`
**File**: `superset/common/query_context_processor.py:128`
`QueryContextProcessor.get_df_payload()`:
1. **Generate cache key** from query object
2. **Check cache** using `QueryCacheManager`
3. **If cache miss**:
- Validate columns exist in datasource
- Call `get_query_result(query_obj)` to execute SQL
- Get annotation data if needed
- Cache the result with appropriate timeout
4. **Return payload** with DataFrame and metadata
```python
def get_df_payload(self, query_obj, force_cached=False):
cache_key = self.query_cache_key(query_obj)
timeout = self.get_cache_timeout()
cache = QueryCacheManager.get(key=cache_key, ...)
if not cache.is_loaded:
query_result = self.get_query_result(query_obj)
annotation_data = self.get_annotation_data(query_obj)
cache.set_query_result(...)
return {
"cache_key": cache_key,
"df": cache.df,
"query": cache.query,
"is_cached": cache.is_cached,
...
}
```
### 5. Database Query Execution
**File**: `superset/common/query_context_processor.py:267`
`QueryContextProcessor.get_query_result()`:
```python
def get_query_result(self, query_object: QueryObject) -> QueryResult:
# Execute SQL query on the datasource
result = query_context.datasource.query(query_object.to_dict())
df = result.df
# Normalize timestamps to pandas datetime format
if not df.empty:
df = self.normalize_df(df, query_object)
# Handle time offset comparisons if specified
if query_object.time_offsets:
time_offsets = self.processing_time_offsets(df, query_object)
df = time_offsets["df"]
# Apply post-processing operations
df = query_object.exec_post_processing(df)
result.df = df
return result
```
The `datasource.query()` call goes to your database connector (e.g., `SqlaTable.query()`) which:
- Converts the QueryObject dict to SQL using SQLAlchemy
- Executes the query via database engine
- Returns a `QueryResult` with a pandas DataFrame
### 6. Post-Processing
**File**: `superset/common/query_object.py:484`
`QueryObject.exec_post_processing()`:
- Applies operations from `post_processing` list in sequence
- Each operation is a pandas transformation (e.g., pivot, aggregate, histogram)
- Uses functions from `superset.utils.pandas_postprocessing`
Example for histogram:
```python
def exec_post_processing(self, df: DataFrame) -> DataFrame:
for post_process in self.post_processing:
operation = post_process.get("operation") # "histogram"
options = post_process.get("options", {}) # {column: "age", bins: 25}
df = getattr(pandas_postprocessing, operation)(df, **options)
return df
```
### 7. Response Formatting
**File**: `superset/charts/data/api.py:346`
`ChartDataRestApi._send_chart_response()`:
- Takes the result dict from command
- Formats based on `result_format`:
- **JSON**: Converts DataFrame to list of dicts
- **CSV**: Converts to CSV string
- **XLSX**: Converts to Excel binary
- Returns Flask Response with appropriate headers
```python
def _send_chart_response(self, result, form_data=None, datasource=None):
result_format = result["query_context"].result_format
if result_format == ChartDataResultFormat.JSON:
queries = result["queries"]
response_data = json.dumps(
{"result": queries},
default=json.json_int_dttm_ser,
ignore_nan=True,
)
resp = make_response(response_data, 200)
resp.headers["Content-Type"] = "application/json; charset=utf-8"
return resp
```
## Key Objects and Data Structures
### QueryContext
**File**: `superset/common/query_context.py:41`
The main container for a chart data request.
```python
{
datasource: BaseDatasource, # Dataset (e.g., id=19, type="table")
queries: list[QueryObject], # List of queries to execute
result_type: ChartDataResultType, # "full", "samples", "query", etc.
result_format: ChartDataResultFormat, # "json", "csv", "xlsx"
force: bool, # Bypass cache flag
form_data: dict, # Original form_data from client
custom_cache_timeout: int | None # Override cache timeout
}
```
### QueryObject
**File**: `superset/common/query_object.py:79`
Represents a single database query.
```python
{
columns: list[Column], # Columns to select ["age"]
metrics: list[Metric] | None, # Aggregations to compute
filters: list[FilterClause], # WHERE clause filters
extras: dict[str, Any], # Additional query options
post_processing: list[dict], # Client-side transformations
row_limit: int | None, # LIMIT clause
row_offset: int, # OFFSET clause
order_desc: bool, # Sort direction
time_range: str | None, # Time filter range
granularity: str | None, # Temporal grouping column
annotation_layers: list[dict], # Annotations to overlay
from_dttm: datetime | None, # Computed time range start
to_dttm: datetime | None # Computed time range end
}
```
### QueryResult
**File**: `superset/models/helpers.py`
Returned from `datasource.query()`.
```python
{
df: pd.DataFrame, # The data from database
query: str, # Executed SQL query
from_dttm: datetime, # Time range start
to_dttm: datetime, # Time range end
error: str | None, # Error message if failed
status: QueryStatus # success, failed, etc.
}
```
## Example Request Flow
For a histogram chart request like:
```bash
curl 'https://example.com/api/v1/chart/data' \
-H 'content-type: application/json' \
--data-raw '{
"datasource":{"id":19,"type":"table"},
"queries":[{
"columns":["age"],
"filters":[{
"col":"time_start",
"op":"TEMPORAL_RANGE",
"val":"No filter"
}],
"row_limit":10000,
"post_processing":[{
"operation":"histogram",
"options":{"column":"age","bins":25}
}]
}],
"result_format":"json",
"result_type":"full"
}'
```
### Flow Summary
```
Client Request (curl)
ChartDataRestApi.data()
↓ (parses JSON)
ChartDataQueryContextSchema.load()
↓ (creates objects)
QueryContext + [QueryObject]
ChartDataCommand.run()
QueryContextProcessor.get_payload()
↓ (for each QueryObject)
get_query_results() → _get_full()
get_df_payload()
├→ Check Cache (QueryCacheManager)
└→ get_query_result()
├→ datasource.query() → Build SQL → Execute → pandas DataFrame
├→ normalize_df() → Timestamp normalization
└→ exec_post_processing() → Apply histogram operation
Return payload {df, query, metadata}
_send_chart_response()
↓ (format as JSON)
Flask Response → Client
```
## Architecture Patterns
The codebase follows clean separation of concerns:
1. **API Layer** (`superset/charts/data/api.py`): Handles HTTP requests/responses
2. **Schema Layer** (`superset/charts/schemas.py`): Validates and deserializes input
3. **Command Layer** (`superset/commands/`): Orchestrates business logic
4. **Query Context/Processor** (`superset/common/`): Manages execution and caching
5. **Query Object**: Represents individual database queries
6. **Datasource Layer** (`superset/connectors/`): Database abstraction and SQL generation
### Key Benefits
- **Caching**: Results cached at multiple levels (query result, query context)
- **Security**: Access control enforced via `raise_for_access()`
- **Flexibility**: Supports multiple result types and formats
- **Post-processing**: Client-side transformations without re-querying database
- **Time Comparison**: Built-in support for time offset queries
- **Annotations**: Overlay additional data layers on charts
## Caching Strategy
**File**: `superset/common/utils/query_cache_manager.py`
Cache keys are generated from:
- Query object (columns, metrics, filters, etc.)
- Datasource UID
- RLS (Row Level Security) rules
- User context (if per-user caching enabled)
- Time range (using relative time strings, not absolute timestamps)
This ensures that:
- Same query returns cached results
- Different users see appropriate cached data
- Time-relative queries (e.g., "Last 7 days") cache correctly
@@ -19,6 +19,7 @@
# Import all settings from the main config first
from flask_caching.backends.filesystemcache import FileSystemCache
from superset_config import * # noqa: F403
# Override caching to use simple in-memory cache instead of Redis
+14 -14
View File
@@ -182,7 +182,7 @@ The available validators and names can be found in
In this section, we'll walkthrough the pre-defined Jinja macros in Superset.
**Current Username**
### Current Username
The `{{ current_username() }}` macro returns the `username` of the currently logged in user.
@@ -197,7 +197,7 @@ cache key by adding the following parameter to your Jinja code:
{{ current_username(add_to_cache_keys=False) }}
```
**Current User ID**
### Current User ID
The `{{ current_user_id() }}` macro returns the account ID of the currently logged in user.
@@ -212,7 +212,7 @@ cache key by adding the following parameter to your Jinja code:
{{ current_user_id(add_to_cache_keys=False) }}
```
**Current User Email**
### Current User Email
The `{{ current_user_email() }}` macro returns the email address of the currently logged in user.
@@ -227,7 +227,7 @@ cache key by adding the following parameter to your Jinja code:
{{ current_user_email(add_to_cache_keys=False) }}
```
**Current User Roles**
### Current User Roles
The `{{ current_user_roles() }}` macro returns an array of roles for the logged in user.
@@ -257,7 +257,7 @@ Will be rendered as:
SELECT * FROM users WHERE role IN ('admin', 'viewer')
```
**Current User RLS Rules**
### Current User RLS Rules
The `{{ current_user_rls_rules() }}` macro returns an array of RLS rules applied to the current dataset for the logged in user.
@@ -265,7 +265,7 @@ If you have caching enabled in your Superset configuration, then the list of RLS
by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a
cache hit in the future and Superset can retrieve cached data.
**Custom URL Parameters**
### Custom URL Parameters
The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL
parameters and reference them in your SQL code.
@@ -299,7 +299,7 @@ Here's a concrete example:
WHERE country_code = 'US'
```
**Explicitly Including Values in Cache Key**
### Explicitly Including Values in Cache Key
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
accumulated list of values used in the calculation of the cache key.
@@ -311,7 +311,7 @@ in the cache key. You can gain more context
Note that this function powers the caching of the `user_id` and `username` values
in the `current_user_id()` and `current_username()` function calls (if you have caching enabled).
**Filter Values**
### Filter Values
You can retrieve the value for a specific filter as a list using `{{ filter_values() }}`.
@@ -332,7 +332,7 @@ GROUP BY action
There `where_in` filter converts the list of values from `filter_values('action_type')` into a string suitable for an `IN` expression.
**Filters for a Specific Column**
### Filters for a Specific Column
The `{{ get_filters() }}` macro returns the filters applied to a given column. In addition to
returning the values (similar to how `filter_values()` does), the `get_filters()` macro
@@ -394,7 +394,7 @@ Here's a concrete example:
order by lineage, level
```
**Time Filter**
### Time Filter
The `{{ get_time_filter() }}` macro returns the time filter applied to a specific column. This is useful if you want
to handle time filters inside the virtual dataset, as by default the time filter is placed on the outer query. This can
@@ -469,7 +469,7 @@ WHERE
AND dttm < {{ time_filter.to_expr }}
```
**Datasets**
### Datasets
It's possible to query physical and virtual datasets using the `dataset` macro. This is useful if you've defined computed columns and metrics on your datasets, and want to reuse the definition in adhoc SQL Lab queries.
@@ -493,7 +493,7 @@ Since metrics are aggregations, the resulting SQL expression will be grouped by
SELECT * FROM {{ dataset(42, include_metrics=True, columns=["ds", "category"]) }} LIMIT 10
```
**Metrics**
### Metrics
The `{{ metric('metric_key', dataset_id) }}` macro can be used to retrieve the metric SQL syntax from a dataset. This can be useful for different purposes:
@@ -511,7 +511,7 @@ The parameter can be used in SQL Lab, or when fetching a metric from another dat
Superset supports [builtin filters from the Jinja2 templating package](https://jinja.palletsprojects.com/en/stable/templates/#builtin-filters). Custom filters have also been implemented:
**Where In**
### Where In
Parses a list into a SQL-compatible statement. This is useful with macros that return an array (for example the `filter_values` macro):
```
@@ -528,7 +528,7 @@ Dashboard filter without any value applied
{{ filter_values('column')|where_in(default_to_none=True) }} => None
```
**To Datetime**
### To Datetime
Loads a string as a `datetime` object. This is useful when performing date operations. For example:
```
+174
View File
@@ -0,0 +1,174 @@
---
title: Securing Your Superset Installation for Production
sidebar_position: 3
---
> *This guide applies to Apache Superset version 4.0 and later and is an evolving set of best practices that administrators should adapt to their specific deployment architecture.*
The default Apache Superset configuration is optimized for ease of use and development, not for security. For any production deployment, it is **critical** that you review and apply the following security configurations to harden your instance, protect user data, and prevent unauthorized access.
This guide provides a comprehensive checklist of essential security configurations and best practices.
### **Critical Prerequisites: HTTPS/TLS Configuration**
Running Superset without HTTPS (TLS) is not secure. Without it, all network traffic—including user credentials, session tokens, and sensitive data—is sent in cleartext and can be easily intercepted.
* **Use a Reverse Proxy:** Your Superset instance should always be deployed behind a reverse proxy (e.g., Nginx, Traefik) or a load balancer (e.g., AWS ALB, Google Cloud Load Balancer) that is configured to handle HTTPS termination.
* **Enforce Modern TLS:** Configure your proxy to enforce TLS 1.2 or higher with strong, industry-standard cipher suites.
* **Implement HSTS:** Use the HTTP Strict Transport Security (HSTS) header to ensure browsers only connect to your Superset instance over HTTPS. This can be configured in your reverse proxy or within Superset's Talisman settings.
### **`SUPERSET_SECRET_KEY` Management (CRITICAL)**
This is the most critical security setting for your Superset instance. It is used to sign all session cookies and encrypt sensitive information in the metadata database, such as database connection credentials.
* **Generate a Unique, Strong Key:** A unique key must be generated for every Superset instance. Use a cryptographically secure method to create it.
```bash
# Example using openssl to generate a strong key
openssl rand -base64 42
```
* **Store the Key Securely:** The key must be kept confidential. The recommended approach is to store it as an environment variable or in a secrets management system (e.g., AWS Secrets Manager, HashiCorp Vault). **Do not hardcode the key in `superset_config.py` or commit it to version control.**
```python
# In superset_config.py
import os
SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY')
```
> #### ⚠️ Warning: Your `SUPERSET_SECRET_KEY` Must Be Unique
>
> **NEVER** reuse the same `SUPERSET_SECRET_KEY` across different environments (e.g., development, staging, production) or different Superset instances. Reusing a key allows cryptographically signed session cookies to be used across those instances, which can lead to a full authentication bypass if a cookie is compromised. Treat this key like a master password.
### **Session Management Security (CRITICAL)**
Properly configuring user sessions is essential to prevent session hijacking and ensure that sessions are terminated correctly.
#### **Use a Server-Side Session Backend (Strongly Recommended for Production)**
The default stateless cookie-based session handling presents challenges for immediate session invalidation upon logout. For all production deployments, we strongly recommend configuring an optional server-side session backend like Redis, Memcached, or a database. This ensures that session data is stored securely on the server and can be instantly destroyed upon logout, rendering any copied session cookies immediately useless.
**Example `superset_config.py` for Redis:**
```python
# superset_config.py
from redis import Redis
import os
# 1. Enable server-side sessions
SESSION_SERVER_SIDE = True
# 2. Choose your backend (e.g., 'redis', 'memcached', 'filesystem', 'sqlalchemy')
SESSION_TYPE = 'redis'
# 3. Configure your Redis connection
# Use environment variables for sensitive details
SESSION_REDIS = Redis(
host=os.environ.get('REDIS_HOST', 'localhost'),
port=int(os.environ.get('REDIS_PORT', 6379)),
password=os.environ.get('REDIS_PASSWORD'),
db=int(os.environ.get('REDIS_DB', 0)),
ssl=os.environ.get('REDIS_SSL_ENABLED', 'True').lower() == 'true',
ssl_cert_reqs='required' # Or another appropriate SSL setting
)
# 4. Ensure the session cookie is signed for integrity
SESSION_USE_SIGNER = True
```
#### **Configure Session Lifetime and Cookie Security Flags**
This is mandatory for *all* deployments, whether stateless or server-side.
```python
# superset_config.py
from datetime import timedelta
# Set a short absolute session timeout
# The default is 31 days, which is NOT recommended for production.
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
# Enforce secure cookie flags to prevent browser-based attacks
SESSION_COOKIE_SECURE = True # Transmit cookie only over HTTPS
SESSION_COOKIE_HTTPONLY = True # Prevent client-side JS from accessing the cookie
SESSION_COOKIE_SAMESITE = 'Lax' # Provide protection against CSRF attacks
```
> ##### Note on iFrame Embedding and `SESSION_COOKIE_SAMESITE`
>The recommended default setting `'Lax'` provides good CSRF protection for most use cases. However, if you need to embed Superset dashboards into other applications using an iFrame, you will need to change this setting to `'None'`.
SESSION_COOKIE_SAMESITE = 'None'
Setting SameSite to 'None' requires that SESSION_COOKIE_SECURE is also set to True. Be aware that this configuration disables some of the browser's built-in CSRF protections to allow for cross-domain functionality, so it should only be used when iFrame embedding is necessary.
### **Authentication and Authorization**
While Superset's built-in database authentication is convenient, for production it's highly recommended to integrate with an enterprise-grade identity provider (IdP).
* **Use an Enterprise IdP:** Configure authentication via OAuth or LDAP to leverage your organization's existing identity management system. This provides benefits like Single Sign-On (SSO), Multi-Factor Authentication (MFA), and centralized user provisioning/deprovisioning.
* **Principle of Least Privilege:** Assign users to the most restrictive roles necessary for their jobs. Avoid over-provisioning users with Admin or Alpha roles, and ensure row-level security is applied where appropriate.
* **Admin Accounts:** Delete or disable the default admin user after a new administrative account has been configured.
### **Content Security Policy (CSP) and Other Headers**
Superset can use Flask-Talisman to set security headers. However, it must be explicitly enabled.
> #### ⚠️ Important: Talisman is Disabled by Default
>
> In Superset 4.0 and later, Talisman is disabled by default (`TALISMAN_ENABLED = False`). You **must** explicitly enable it in your `superset_config.py` for the security headers defined in `TALISMAN_CONFIG` to take effect.
Here's the documentation section how how to set up Talisman: https://superset.apache.org/docs/security/#content-security-policy-csp
### **Database Security**
> #### ❗ Superset is Not a Database Firewall
>
> It is essential to understand that **Apache Superset is a data visualization and exploration platform, not a database firewall or a comprehensive security solution for your data warehouse.** While Superset provides features to help manage data access, the ultimate responsibility for securing your underlying databases lies with your database administrators (DBAs) and security teams. This includes managing network access, user privileges, and fine-grained permissions directly within the database. The configurations below are an important secondary layer of security but should not be your only line of defense.
* **Use a Dedicated Database User:** The database connection configured in Superset should use a dedicated, limited-privilege database user. This user should only have the minimum required permissions (e.g., `SELECT` on specific schemas) for the data sources it needs to query. It should **not** have `INSERT`, `UPDATE`, `DELETE`, or administrative privileges.
* **Restrict Dangerous SQL Functions:** To mitigate potential SQL injection risks, configure the `DISALLOWED_SQL_FUNCTIONS` list in your `superset_config.py`. Be aware that this is a defense-in-depth measure, not a substitute for proper database permissions.
### **Additional Security Layers**
* **Web Application Firewall (WAF):** Deploying Superset behind a WAF (e.g., Cloudflare, AWS WAF) is strongly recommended. A WAF with a standard ruleset (like the OWASP Core Rule Set) provides a critical layer of defense against common attacks like SQL Injection, XSS, and remote code execution.
### **Monitoring and Logging**
* **Configure Structured Logging:** Set up a robust logging configuration to capture important security events.
* **Centralize Logs:** Ship logs from all Superset components (frontend, worker, etc.) to a centralized SIEM (Security Information and Event Management) system for analysis and alerting.
* **Monitor Key Events:** Create alerts for suspicious activities, including:
* Multiple failed login attempts for a single user or from a single IP address.
* Changes to user roles or permissions.
* Creation or deletion of high-privilege users.
* Attempts to use disallowed SQL functions.
-----
### **Appendix A: Production Deployment Checklist**
#### **Initial Setup:**
- [ ] HTTPS/TLS is configured and enforced via a reverse proxy.
- [ ] A unique, strong `SUPERSET_SECRET_KEY` is generated and secured in an environment variable or secrets vault.
- [ ] Server-side session management is configured (e.g., Redis).
- [ ] `PERMANENT_SESSION_LIFETIME` is set to a short duration (e.g., 8 hours).
- [ ] All session cookie security flags (`Secure`, `HttpOnly`, `SameSite`) are enabled.
- [ ] `DEBUG` mode is set to `False`.
- [ ] Talisman is explicitly enabled and configured with a strict Content Security Policy.
- [ ] Database connections use dedicated, limited-privilege accounts.
- [ ] Authentication is integrated with an enterprise identity provider (OAuth/LDAP).
- [ ] A Web Application Firewall (WAF) is deployed in front of Superset.
- [ ] Logging is configured and logs are shipped to a central monitoring system.
#### **Ongoing Maintenance:**
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
- [ ] Conduct quarterly access reviews for all users.
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
### **Appendix B: `SECRET_KEY` Rotation and Compromise Response**
**Why and When to Rotate the `SECRET_KEY`**
Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is mandatory after a known or suspected compromise and is a best practice when an employee with access to the key departs. While periodic rotation can limit the window of exposure for an unknown leak, it is a high-impact operation that will invalidate all user sessions and requires careful execution to avoid breaking your instance. The principles behind managing this key align with general best practices for cryptographic storage, which are further detailed in the OWASP Cryptographic Storage Cheat Sheet here: https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
**Procedure for Rotating the Key**
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
https://superset.apache.org/docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
+10 -10
View File
@@ -28,10 +28,10 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@docusaurus/core": "3.9.1",
"@docusaurus/plugin-client-redirects": "3.9.1",
"@docusaurus/preset-classic": "3.9.1",
"@docusaurus/theme-mermaid": "^3.9.1",
"@docusaurus/core": "3.9.2",
"@docusaurus/plugin-client-redirects": "3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@emotion/core": "^10.0.27",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^10.0.27",
@@ -49,8 +49,8 @@
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"antd": "^5.27.4",
"caniuse-lite": "^1.0.30001749",
"antd": "^5.27.5",
"caniuse-lite": "^1.0.30001751",
"docusaurus-plugin-less": "^2.0.2",
"json-bigint": "^1.0.0",
"less": "^4.4.2",
@@ -63,14 +63,14 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"storybook": "^8.6.11",
"swagger-ui-react": "^5.29.3",
"swagger-ui-react": "^5.29.5",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.1",
"@docusaurus/tsconfig": "^3.9.1",
"@eslint/js": "^9.37.0",
"@docusaurus/tsconfig": "^3.9.2",
"@eslint/js": "^9.38.0",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.46.0",
@@ -81,7 +81,7 @@
"globals": "^16.4.0",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.0",
"typescript-eslint": "^8.46.1",
"webpack": "^5.102.1"
},
"browserslist": {
+337 -333
View File
@@ -1593,10 +1593,10 @@
marked "^16.3.0"
zod "^4.1.8"
"@docusaurus/babel@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.1.tgz#5297195ab34df9e184e3e2fe20de1a2e1b2a22e8"
integrity sha512-/uoi3oG+wvbVWNBRfPrzrEslOSeLxrQEyWMywK51TLDFTANqIRivzkMusudh5bdDty8fXzCYUT+tg5t697jYqg==
"@docusaurus/babel@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.2.tgz#f956c638baeccf2040e482c71a742bc7e35fdb22"
integrity sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==
dependencies:
"@babel/core" "^7.25.9"
"@babel/generator" "^7.25.9"
@@ -1608,23 +1608,23 @@
"@babel/runtime" "^7.25.9"
"@babel/runtime-corejs3" "^7.25.9"
"@babel/traverse" "^7.25.9"
"@docusaurus/logger" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/logger" "3.9.2"
"@docusaurus/utils" "3.9.2"
babel-plugin-dynamic-import-node "^2.3.3"
fs-extra "^11.1.1"
tslib "^2.6.0"
"@docusaurus/bundler@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.1.tgz#6b78c152cf364d706249f6978f8e3fedf576b118"
integrity sha512-E1c9DgNmAz4NqbNtiJVp4UgjLtr8O01IgtXD/NDQ4PZaK8895cMiTOgb3k7mN0qX8A3lb8vqyrPJ842+yMpuUg==
"@docusaurus/bundler@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.2.tgz#0ca82cda4acf13a493e3f66061aea351e9d356cf"
integrity sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==
dependencies:
"@babel/core" "^7.25.9"
"@docusaurus/babel" "3.9.1"
"@docusaurus/cssnano-preset" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/babel" "3.9.2"
"@docusaurus/cssnano-preset" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
babel-loader "^9.2.1"
clean-css "^5.3.3"
copy-webpack-plugin "^11.0.0"
@@ -1644,18 +1644,18 @@
webpack "^5.95.0"
webpackbar "^6.0.1"
"@docusaurus/core@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.1.tgz#be4d859464fee8889794d8527f884e931d591f2e"
integrity sha512-FWDk1LIGD5UR5Zmm9rCrXRoxZUgbwuP6FBA7rc50DVfzqDOMkeMe3NyJhOsA2dF0zBE3VbHEIMmTjKwTZJwbaA==
"@docusaurus/core@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.2.tgz#cc970f29b85a8926d63c84f8cffdcda43ed266ff"
integrity sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==
dependencies:
"@docusaurus/babel" "3.9.1"
"@docusaurus/bundler" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/mdx-loader" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/babel" "3.9.2"
"@docusaurus/bundler" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/mdx-loader" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
boxen "^6.2.1"
chalk "^4.1.2"
chokidar "^3.5.3"
@@ -1692,32 +1692,32 @@
webpack-dev-server "^5.2.2"
webpack-merge "^6.0.1"
"@docusaurus/cssnano-preset@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.1.tgz#fa57c81a3f41e4d118115f86c85a71aed6b90f49"
integrity sha512-2y7+s7RWQMqBg+9ejeKwvZs7Bdw/hHIVJIodwMXbs2kr+S48AhcmAfdOh6Cwm0unJb0hJUshN0ROwRoQMwl3xg==
"@docusaurus/cssnano-preset@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz#523aab65349db3c51a77f2489048d28527759428"
integrity sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==
dependencies:
cssnano-preset-advanced "^6.1.2"
postcss "^8.5.4"
postcss-sort-media-queries "^5.2.0"
tslib "^2.6.0"
"@docusaurus/logger@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.1.tgz#0209c4c1044ee35d89dbf676e3cbb5dc8b59c82b"
integrity sha512-C9iFzXwHzwvGlisE4bZx+XQE0JIqlGAYAd5LzpR7fEDgjctu7yL8bE5U4nTNywXKHURDzMt4RJK8V6+stFHVkA==
"@docusaurus/logger@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.2.tgz#6ec6364b90f5a618a438cc9fd01ac7376869f92a"
integrity sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==
dependencies:
chalk "^4.1.2"
tslib "^2.6.0"
"@docusaurus/mdx-loader@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.1.tgz#0ef77bee13450c83c18338f8e5f1753ed2e9ee3f"
integrity sha512-/1PY8lqry8jCt0qZddJSpc0U2sH6XC27kVJZfpA7o2TiQ3mdBQyH5AVbj/B2m682B1ounE+XjI0LdpOkAQLPoA==
"@docusaurus/mdx-loader@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.2.tgz#78d238de6c6203fa811cc2a7e90b9b79e111408c"
integrity sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==
dependencies:
"@docusaurus/logger" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/logger" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
"@mdx-js/mdx" "^3.0.0"
"@slorber/remark-comment" "^1.0.0"
escape-html "^1.0.3"
@@ -1740,12 +1740,12 @@
vfile "^6.0.1"
webpack "^5.88.1"
"@docusaurus/module-type-aliases@3.9.1", "@docusaurus/module-type-aliases@^3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.1.tgz#201959a22e7b30881cf879a21d2ae5b26415b705"
integrity sha512-YBce3GbJGGcMbJTyHcnEOMvdXqg41pa5HsrMCGA5Rm4z0h0tHS6YtEldj0mlfQRhCG7Y0VD66t2tb87Aom+11g==
"@docusaurus/module-type-aliases@3.9.2", "@docusaurus/module-type-aliases@^3.9.1":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.2.tgz#993c7cb0114363dea5ef6855e989b3ad4b843a34"
integrity sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==
dependencies:
"@docusaurus/types" "3.9.1"
"@docusaurus/types" "3.9.2"
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react-router-config" "*"
@@ -1753,34 +1753,34 @@
react-helmet-async "npm:@slorber/react-helmet-async@1.3.0"
react-loadable "npm:@docusaurus/react-loadable@6.0.0"
"@docusaurus/plugin-client-redirects@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.9.1.tgz#06e07487f1596c62536d50afac81535d5fe60a20"
integrity sha512-+1InCGvAnw46H+TnVqxaYlJC0qy9AY5gTMgTx2ZFryjAsImJNs3i1pEYW/iUTVbOdtWRj3E/87E4ehbBIaA1TA==
"@docusaurus/plugin-client-redirects@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.9.2.tgz#9c27025c72aeeedeb783a94720163911567da0e8"
integrity sha512-lUgMArI9vyOYMzLRBUILcg9vcPTCyyI2aiuXq/4npcMVqOr6GfmwtmBYWSbNMlIUM0147smm4WhpXD0KFboffw==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
eta "^2.2.0"
fs-extra "^11.1.1"
lodash "^4.17.21"
tslib "^2.6.0"
"@docusaurus/plugin-content-blog@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.1.tgz#bf6619847065360d52abc5bf1da307f5ce2a19f8"
integrity sha512-vT6kIimpJLWvW9iuWzH4u7VpTdsGlmn4yfyhq0/Kb1h4kf9uVouGsTmrD7WgtYBUG1P+TSmQzUUQa+ALBSRTig==
"@docusaurus/plugin-content-blog@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz#d5ce51eb7757bdab0515e2dd26a793ed4e119df9"
integrity sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/mdx-loader" "3.9.1"
"@docusaurus/theme-common" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/mdx-loader" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
cheerio "1.0.0-rc.12"
feed "^4.2.2"
fs-extra "^11.1.1"
@@ -1792,20 +1792,20 @@
utility-types "^3.10.0"
webpack "^5.88.1"
"@docusaurus/plugin-content-docs@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.1.tgz#e3e75d4aa310689c262c18e10010788e53f101ec"
integrity sha512-DyLk9BIA6I9gPIuia8XIL+XIEbNnExam6AHzRsfrEq4zJr7k/DsWW7oi4aJMepDnL7jMRhpVcdsCxdjb0/A9xg==
"@docusaurus/plugin-content-docs@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz#cd8f2d1c06e53c3fa3d24bdfcb48d237bf2d6b2e"
integrity sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/mdx-loader" "3.9.1"
"@docusaurus/module-type-aliases" "3.9.1"
"@docusaurus/theme-common" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/mdx-loader" "3.9.2"
"@docusaurus/module-type-aliases" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
"@types/react-router-config" "^5.0.7"
combine-promises "^1.1.0"
fs-extra "^11.1.1"
@@ -1816,142 +1816,142 @@
utility-types "^3.10.0"
webpack "^5.88.1"
"@docusaurus/plugin-content-pages@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.1.tgz#044b8adbd2a673ff22630a74b3e0ce482761655d"
integrity sha512-/1wFzRnXYASI+Nv9ck9IVPIMw0O5BGQ8ZVhDzEwhkL+tl44ycvSnY6PIe6rW2HLxsw61Z3WFwAiU8+xMMtMZpg==
"@docusaurus/plugin-content-pages@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz#22db6c88ade91cec0a9e87a00b8089898051b08d"
integrity sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/mdx-loader" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/mdx-loader" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
fs-extra "^11.1.1"
tslib "^2.6.0"
webpack "^5.88.1"
"@docusaurus/plugin-css-cascade-layers@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.1.tgz#958a04679279e787d14fd3cc423ad35c580dc6fc"
integrity sha512-/QyW2gRCk/XE3ttCK/ERIgle8KJ024dBNKMu6U5SmpJvuT2il1n5jR/48Pp/9wEwut8WVml4imNm6X8JsL5A0Q==
"@docusaurus/plugin-css-cascade-layers@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.2.tgz#358c85f63f1c6a11f611f1b8889d9435c11b22f8"
integrity sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
tslib "^2.6.0"
"@docusaurus/plugin-debug@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.1.tgz#5dbe01771176697f427b89a1ff023a3967c3e674"
integrity sha512-qPeAuk0LccC251d7jg2MRhNI+o7niyqa924oEM/AxnZJvIpMa596aAxkRImiAqNN6+gtLE1Hkrz/RHUH2HDGsA==
"@docusaurus/plugin-debug@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.2.tgz#b5df4db115583f5404a252dbf66f379ff933e53c"
integrity sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
fs-extra "^11.1.1"
react-json-view-lite "^2.3.0"
tslib "^2.6.0"
"@docusaurus/plugin-google-analytics@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.1.tgz#226e39ed6d0a5eb3978dc5189bc9676235756446"
integrity sha512-k4Qq2HphqOrIU/CevGPdEO1yJnWUI8m0zOJsYt5NfMJwNsIn/gDD6gv/DKD+hxHndQT5pacsfBd4BWHZVNVroQ==
"@docusaurus/plugin-google-analytics@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.2.tgz#857fe075fdeccdf6959e62954d9efe39769fa247"
integrity sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
tslib "^2.6.0"
"@docusaurus/plugin-google-gtag@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.1.tgz#971b075898d46d1a59482b2873ccb9aa2e679910"
integrity sha512-n9BURBiQyJKI/Ecz35IUjXYwXcgNCSq7/eA07+ZYcDiSyH2p/EjPf8q/QcZG3CyEJPZ/SzGkDHePfcVPahY4Gg==
"@docusaurus/plugin-google-gtag@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.2.tgz#df75b1a90ae9266b0471909ba0265f46d5dcae62"
integrity sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
"@types/gtag.js" "^0.0.12"
tslib "^2.6.0"
"@docusaurus/plugin-google-tag-manager@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.1.tgz#b26770d8bb07cedc1e01305cd9d66c2e4ce6d654"
integrity sha512-rZAQZ25ZuXaThBajxzLjXieTDUCMmBzfAA6ThElQ3o7Q+LEpOjCIrwGFau0KLY9HeG6x91+FwwsAM8zeApYDrg==
"@docusaurus/plugin-google-tag-manager@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.2.tgz#d1a3cf935acb7d31b84685e92d70a1d342946677"
integrity sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
tslib "^2.6.0"
"@docusaurus/plugin-sitemap@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.1.tgz#e84717c1e52f3a61f9fea414ef98ebe025e7ffd2"
integrity sha512-k/bf5cXDxAJUYTzqatgFJwmZsLUbIgl6S8AdZMKGG2Mv2wcOHt+EQNN9qPyWZ5/9cFj+Q8f8DN+KQheBMYLong==
"@docusaurus/plugin-sitemap@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.2.tgz#e1d9f7012942562cc0c6543d3cb2cdc4ae713dc4"
integrity sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
fs-extra "^11.1.1"
sitemap "^7.1.1"
tslib "^2.6.0"
"@docusaurus/plugin-svgr@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.1.tgz#394ad2b8da3af587a0f68167252b4bf99fb72351"
integrity sha512-TeZOXT2PSdTNR1OpDJMkYqFyX7MMhbd4t16hQByXksgZQCXNyw3Dio+KaDJ2Nj+LA4WkOvsk45bWgYG5MAaXSQ==
"@docusaurus/plugin-svgr@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.2.tgz#62857ed79d97c0150d25f7e7380fdee65671163a"
integrity sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
"@svgr/core" "8.1.0"
"@svgr/webpack" "^8.1.0"
tslib "^2.6.0"
webpack "^5.88.1"
"@docusaurus/preset-classic@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.1.tgz#58d86664b5c9779578092556a0e6ae5ccebbd6c0"
integrity sha512-ZHga2xsxxsyd0dN1BpLj8S889Eu9eMBuj2suqxdw/vaaXu/FjJ8KEGbcaeo6nHPo8VQcBBnPEdkBtSDm2TfMNw==
"@docusaurus/preset-classic@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.2.tgz#85cc4f91baf177f8146c9ce896dfa1f0fd377050"
integrity sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/plugin-content-blog" "3.9.1"
"@docusaurus/plugin-content-docs" "3.9.1"
"@docusaurus/plugin-content-pages" "3.9.1"
"@docusaurus/plugin-css-cascade-layers" "3.9.1"
"@docusaurus/plugin-debug" "3.9.1"
"@docusaurus/plugin-google-analytics" "3.9.1"
"@docusaurus/plugin-google-gtag" "3.9.1"
"@docusaurus/plugin-google-tag-manager" "3.9.1"
"@docusaurus/plugin-sitemap" "3.9.1"
"@docusaurus/plugin-svgr" "3.9.1"
"@docusaurus/theme-classic" "3.9.1"
"@docusaurus/theme-common" "3.9.1"
"@docusaurus/theme-search-algolia" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/plugin-content-blog" "3.9.2"
"@docusaurus/plugin-content-docs" "3.9.2"
"@docusaurus/plugin-content-pages" "3.9.2"
"@docusaurus/plugin-css-cascade-layers" "3.9.2"
"@docusaurus/plugin-debug" "3.9.2"
"@docusaurus/plugin-google-analytics" "3.9.2"
"@docusaurus/plugin-google-gtag" "3.9.2"
"@docusaurus/plugin-google-tag-manager" "3.9.2"
"@docusaurus/plugin-sitemap" "3.9.2"
"@docusaurus/plugin-svgr" "3.9.2"
"@docusaurus/theme-classic" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/theme-search-algolia" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/theme-classic@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.1.tgz#790fb1b8058d0572632211023ead238c1a6450e0"
integrity sha512-LrAIu/mQ04nG6s1cssC0TMmICD8twFIIn/hJ5Pd9uIPQvtKnyAKEn12RefopAul5KfMo9kixPaqogV5jIJr26w==
"@docusaurus/theme-classic@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.2.tgz#6e514f99a0ff42b80afcf42d5e5d042618311ce0"
integrity sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/mdx-loader" "3.9.1"
"@docusaurus/module-type-aliases" "3.9.1"
"@docusaurus/plugin-content-blog" "3.9.1"
"@docusaurus/plugin-content-docs" "3.9.1"
"@docusaurus/plugin-content-pages" "3.9.1"
"@docusaurus/theme-common" "3.9.1"
"@docusaurus/theme-translations" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/mdx-loader" "3.9.2"
"@docusaurus/module-type-aliases" "3.9.2"
"@docusaurus/plugin-content-blog" "3.9.2"
"@docusaurus/plugin-content-docs" "3.9.2"
"@docusaurus/plugin-content-pages" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/theme-translations" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
"@mdx-js/react" "^3.0.0"
clsx "^2.0.0"
infima "0.2.0-alpha.45"
@@ -1965,15 +1965,15 @@
tslib "^2.6.0"
utility-types "^3.10.0"
"@docusaurus/theme-common@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.1.tgz#095cbeab489d51380143951508571888f4d2928d"
integrity sha512-j9adi961F+6Ps9d0jcb5BokMcbjXAAJqKkV43eo8nh4YgmDj7KUNDX4EnOh/MjTQeO06oPY5cxp3yUXdW/8Ggw==
"@docusaurus/theme-common@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.2.tgz#487172c6fef9815c2746ef62a71e4f5b326f9ba5"
integrity sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==
dependencies:
"@docusaurus/mdx-loader" "3.9.1"
"@docusaurus/module-type-aliases" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/mdx-loader" "3.9.2"
"@docusaurus/module-type-aliases" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react-router-config" "*"
@@ -1983,32 +1983,32 @@
tslib "^2.6.0"
utility-types "^3.10.0"
"@docusaurus/theme-mermaid@^3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.1.tgz#92de20580489e05b3da6716503fda17e5a337a4d"
integrity sha512-aKMFlQfxueVBPdCdrNSshG12fOkJXSn1sb6EhI/sGn3UpiTEiazJm4QLP6NoF78mqq8O5Ar2Yll+iHWLvCsuZQ==
"@docusaurus/theme-mermaid@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz#f065e4b4b319560ddd8c3be65ce9dd19ce1d5cc8"
integrity sha512-5vhShRDq/ntLzdInsQkTdoKWSzw8d1jB17sNPYhA/KvYYFXfuVEGHLM6nrf8MFbV8TruAHDG21Fn3W4lO8GaDw==
dependencies:
"@docusaurus/core" "3.9.1"
"@docusaurus/module-type-aliases" "3.9.1"
"@docusaurus/theme-common" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/module-type-aliases" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
mermaid ">=11.6.0"
tslib "^2.6.0"
"@docusaurus/theme-search-algolia@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.1.tgz#2f2ad5212201a1bed3acf8527ae6d81a079e654e"
integrity sha512-WjM28bzlgfT6nHlEJemkwyGVpvGsZWPireV/w+wZ1Uo64xCZ8lNOb4xwQRukDaLSed3oPBN0gSnu06l5VuCXHg==
"@docusaurus/theme-search-algolia@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz#420fd5b27fc1673b48151fdc9fe7167ba135ed50"
integrity sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==
dependencies:
"@docsearch/react" "^3.9.0 || ^4.1.0"
"@docusaurus/core" "3.9.1"
"@docusaurus/logger" "3.9.1"
"@docusaurus/plugin-content-docs" "3.9.1"
"@docusaurus/theme-common" "3.9.1"
"@docusaurus/theme-translations" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-validation" "3.9.1"
"@docusaurus/core" "3.9.2"
"@docusaurus/logger" "3.9.2"
"@docusaurus/plugin-content-docs" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/theme-translations" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
algoliasearch "^5.37.0"
algoliasearch-helper "^3.26.0"
clsx "^2.0.0"
@@ -2018,23 +2018,23 @@
tslib "^2.6.0"
utility-types "^3.10.0"
"@docusaurus/theme-translations@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.1.tgz#189f1942d0178bc0da659db88c07682c7d7191ee"
integrity sha512-mUQd49BSGKTiM6vP9+JFgRJL28lMIN3PUvXjF3rzuOHMByUZUBNwCt26Z23GkKiSIOrRkjKoaBNTipR/MHdYSQ==
"@docusaurus/theme-translations@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz#238cd69c2da92d612be3d3b4f95944c1d0f1e041"
integrity sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==
dependencies:
fs-extra "^11.1.1"
tslib "^2.6.0"
"@docusaurus/tsconfig@^3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.9.1.tgz#a39cb74021f16dd4794db9182de812303817528e"
integrity sha512-stdzM1dNDgRO0OvxeznXlE3N1igUoeHPNJjiKqyffLizgpVgNXJBAWeG6fuoYiCH4udGUBqy2dyM+1+kG2/UPQ==
"@docusaurus/tsconfig@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.9.2.tgz#7f440e0ae665b841e1d487749037f26a0275f9c1"
integrity sha512-j6/Fp4Rlpxsc632cnRnl5HpOWeb6ZKssDj6/XzzAzVGXXfm9Eptx3rxCC+fDzySn9fHTS+CWJjPineCR1bB5WQ==
"@docusaurus/types@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.1.tgz#e4fdaf0b91ea014a6aae0d8b62d59f3f020117b6"
integrity sha512-ElekJ29sk39s5LTEZMByY1c2oH9FMtw7KbWFU3BtuQ1TytfIK39HhUivDEJvm5KCLyEnnfUZlvSNDXeyk0vzAA==
"@docusaurus/types@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.2.tgz#e482cf18faea0d1fa5ce0e3f1e28e0f32d2593eb"
integrity sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==
dependencies:
"@mdx-js/mdx" "^3.0.0"
"@types/history" "^4.7.11"
@@ -2047,36 +2047,36 @@
webpack "^5.95.0"
webpack-merge "^5.9.0"
"@docusaurus/utils-common@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.1.tgz#202778391caed923c2527a166a3aae3a22b2dcad"
integrity sha512-4M1u5Q8Zn2CYL2TJ864M51FV4YlxyGyfC3x+7CLuR6xsyTVNBNU4QMcPgsTHRS9J2+X6Lq7MyH6hiWXyi/sXUQ==
"@docusaurus/utils-common@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.2.tgz#e89bfcf43d66359f43df45293fcdf22814847460"
integrity sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==
dependencies:
"@docusaurus/types" "3.9.1"
"@docusaurus/types" "3.9.2"
tslib "^2.6.0"
"@docusaurus/utils-validation@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.1.tgz#8f9816b31ffb647539881f3c153d46f54e6399f7"
integrity sha512-5bzab5si3E1udrlZuVGR17857Lfwe8iFPoy5AvMP9PXqDfoyIKT7gDQgAmxdRDMurgHaJlyhXEHHdzDKkOxxZQ==
"@docusaurus/utils-validation@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz#04aec285604790806e2fc5aa90aa950dc7ba75ae"
integrity sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==
dependencies:
"@docusaurus/logger" "3.9.1"
"@docusaurus/utils" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/logger" "3.9.2"
"@docusaurus/utils" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
fs-extra "^11.2.0"
joi "^17.9.2"
js-yaml "^4.1.0"
lodash "^4.17.21"
tslib "^2.6.0"
"@docusaurus/utils@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.1.tgz#9b78849a2be5e3023580b800409aae36a0da6dc8"
integrity sha512-YAL4yhhWLl9DXuf5MVig260a6INz4MehrBGFU/CZu8yXmRiYEuQvRFWh9ZsjfAOyaG7za1MNmBVZ4VVAi/CiJA==
"@docusaurus/utils@3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.2.tgz#ffab7922631c7e0febcb54e6d499f648bf8a89eb"
integrity sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==
dependencies:
"@docusaurus/logger" "3.9.1"
"@docusaurus/types" "3.9.1"
"@docusaurus/utils-common" "3.9.1"
"@docusaurus/logger" "3.9.2"
"@docusaurus/types" "3.9.2"
"@docusaurus/utils-common" "3.9.2"
escape-string-regexp "^4.0.0"
execa "5.1.1"
file-loader "^6.2.0"
@@ -2466,11 +2466,16 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.37.0", "@eslint/js@^9.37.0":
"@eslint/js@9.37.0":
version "9.37.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
"@eslint/js@^9.38.0":
version "9.38.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.38.0.tgz#f7aa9c7577577f53302c1d795643589d7709ebd1"
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
"@eslint/object-schema@^2.1.6":
version "2.1.6"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
@@ -2796,14 +2801,13 @@
classnames "^2.3.2"
rc-util "^5.24.4"
"@rc-component/qrcode@~1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@rc-component/qrcode/-/qrcode-1.0.0.tgz#48a8de5eb11d0e65926f1377c4b1ef4c888997f5"
integrity sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==
"@rc-component/qrcode@~1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@rc-component/qrcode/-/qrcode-1.0.1.tgz#98e0a79dc95f26fe211b59d04ef3312bc70dedbe"
integrity sha512-g8eeeaMyFXVlq8cZUeaxCDhfIYjpao0l9cvm5gFwKXy/Vm1yDWV7h2sjH5jHYzdFedlVKBpATFB1VKMrHzwaWQ==
dependencies:
"@babel/runtime" "^7.24.7"
classnames "^2.3.2"
rc-util "^5.38.0"
"@rc-component/tour@~1.15.1":
version "1.15.1"
@@ -4336,79 +4340,79 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.46.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz#fc90b35d8025b5eaa66b2f6c3859cd5381a1e751"
integrity sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==
"@typescript-eslint/eslint-plugin@8.46.1", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz#20876354024140aabc8b400bc95735fdcade17d5"
integrity sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.46.0"
"@typescript-eslint/type-utils" "8.46.0"
"@typescript-eslint/utils" "8.46.0"
"@typescript-eslint/visitor-keys" "8.46.0"
"@typescript-eslint/scope-manager" "8.46.1"
"@typescript-eslint/type-utils" "8.46.1"
"@typescript-eslint/utils" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.46.0", "@typescript-eslint/parser@^8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.0.tgz#9186f28c59f6e477ab8919312d2654f4f27d45c1"
integrity sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==
"@typescript-eslint/parser@8.46.1", "@typescript-eslint/parser@^8.46.0":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.1.tgz#81751f46800fc6b01ce1a72760cd17f06e7f395b"
integrity sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==
dependencies:
"@typescript-eslint/scope-manager" "8.46.0"
"@typescript-eslint/types" "8.46.0"
"@typescript-eslint/typescript-estree" "8.46.0"
"@typescript-eslint/visitor-keys" "8.46.0"
"@typescript-eslint/scope-manager" "8.46.1"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
debug "^4.3.4"
"@typescript-eslint/project-service@8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.0.tgz#1190dcc0d3494d46a85773e0c3a2838cbb2b45a7"
integrity sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==
"@typescript-eslint/project-service@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.1.tgz#07be0e6f27fa90a17d8e5f6996ee02329c9a8c2e"
integrity sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.46.0"
"@typescript-eslint/types" "^8.46.0"
"@typescript-eslint/tsconfig-utils" "^8.46.1"
"@typescript-eslint/types" "^8.46.1"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz#a41833fe387044075cb2d4cfab490a7f1dd19b61"
integrity sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==
"@typescript-eslint/scope-manager@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz#590dd2e65e95af646bdaf50adeae9af39e25e8c1"
integrity sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==
dependencies:
"@typescript-eslint/types" "8.46.0"
"@typescript-eslint/visitor-keys" "8.46.0"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
"@typescript-eslint/tsconfig-utils@8.46.0", "@typescript-eslint/tsconfig-utils@^8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz#3e33019e0b94838d37d7cc61341fbcc5bf791007"
integrity sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==
"@typescript-eslint/tsconfig-utils@8.46.1", "@typescript-eslint/tsconfig-utils@^8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz#24405888560175c6c209c39df11ac06a2efef9d7"
integrity sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==
"@typescript-eslint/type-utils@8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz#815efeb11b9533da68fd825628cecf283ac79829"
integrity sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==
"@typescript-eslint/type-utils@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz#14d4307dd6045f6b48a888cde1513d6ec305537f"
integrity sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==
dependencies:
"@typescript-eslint/types" "8.46.0"
"@typescript-eslint/typescript-estree" "8.46.0"
"@typescript-eslint/utils" "8.46.0"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/utils" "8.46.1"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.46.0", "@typescript-eslint/types@^8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.0.tgz#20af6b332f9cd55a15fcd862fdb07d47a6131bf4"
integrity sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==
"@typescript-eslint/types@8.46.1", "@typescript-eslint/types@^8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.1.tgz#4c5479538ec10b5508b8e982e172911c987446d8"
integrity sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==
"@typescript-eslint/typescript-estree@8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz#f45a0d5f5e99b26f0280e8cff3ed3380658fd720"
integrity sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==
"@typescript-eslint/typescript-estree@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz#1c146573b942ebe609c156c217ceafdc7a88e6ed"
integrity sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==
dependencies:
"@typescript-eslint/project-service" "8.46.0"
"@typescript-eslint/tsconfig-utils" "8.46.0"
"@typescript-eslint/types" "8.46.0"
"@typescript-eslint/visitor-keys" "8.46.0"
"@typescript-eslint/project-service" "8.46.1"
"@typescript-eslint/tsconfig-utils" "8.46.1"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/visitor-keys" "8.46.1"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -4416,22 +4420,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.0.tgz#27025c5ed7cbc928440d6a30edd6ba34cc5b927a"
integrity sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==
"@typescript-eslint/utils@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.1.tgz#c572184d9227d66b10a954b90249a20c48b22452"
integrity sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.46.0"
"@typescript-eslint/types" "8.46.0"
"@typescript-eslint/typescript-estree" "8.46.0"
"@typescript-eslint/scope-manager" "8.46.1"
"@typescript-eslint/types" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/visitor-keys@8.46.0":
version "8.46.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz#23936809054c511f703713c56ddd2f46dc197845"
integrity sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==
"@typescript-eslint/visitor-keys@8.46.1":
version "8.46.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz#da35f1d58ec407419d68847cfd358b32746ac315"
integrity sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==
dependencies:
"@typescript-eslint/types" "8.46.0"
"@typescript-eslint/types" "8.46.1"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
@@ -4746,10 +4750,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.27.4:
version "5.27.4"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.4.tgz#13c97deb12e6aeb43adecd23f3dbe3139a62e579"
integrity sha512-rhArohoAUCxhkPjGI/BXthOrrjaElL4Fb7d4vEHnIR3DpxFXfegd4rN21IgGdiF+Iz4EFuUZu8MdS8NuJHLSVQ==
antd@^5.27.5:
version "5.27.5"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.5.tgz#978b265c722b9229e7dcc2fcddc5f5445af9bdf0"
integrity sha512-Ehd9mqtHvJ1clon1yJ/1BTV6eX/3SH2YXZZPTHUk8XdzXFwUioI+Lht47s+MaHIUBY77RnZrmtKwwR+VVu0l7A==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -4760,7 +4764,7 @@ antd@^5.27.4:
"@babel/runtime" "^7.26.0"
"@rc-component/color-picker" "~2.0.1"
"@rc-component/mutate-observer" "^1.1.0"
"@rc-component/qrcode" "~1.0.0"
"@rc-component/qrcode" "~1.0.1"
"@rc-component/tour" "~1.15.1"
"@rc-component/trigger" "^2.3.0"
classnames "^2.5.1"
@@ -4790,7 +4794,7 @@ antd@^5.27.4:
rc-slider "~11.1.9"
rc-steps "~6.0.1"
rc-switch "~4.1.0"
rc-table "~7.53.0"
rc-table "~7.54.0"
rc-tabs "~15.7.0"
rc-textarea "~1.10.2"
rc-tooltip "~6.4.0"
@@ -5305,10 +5309,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001749:
version "1.0.30001749"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz#21a43b923577932097fe32bcaabb6da7f4677632"
integrity sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001751:
version "1.0.30001751"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz#dacd5d9f4baeea841641640139d2b2a4df4226ad"
integrity sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==
ccount@^2.0.0:
version "2.0.1"
@@ -11729,10 +11733,10 @@ rc-switch@~4.1.0:
classnames "^2.2.1"
rc-util "^5.30.0"
rc-table@~7.53.0:
version "7.53.1"
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.53.1.tgz#b891aa39e9d1d944711f018692d2c52013afc90f"
integrity sha512-firAd7Z+liqIDS5TubJ1qqcoBd6YcANLKWQDZhFf3rfoOTt/UNPj4n3O+2vhl+z4QMqwPEUVAil661WHA8H8Aw==
rc-table@~7.54.0:
version "7.54.0"
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.54.0.tgz#dedd4ea18d1189f2acdf90a80f04d8ca0111e16a"
integrity sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==
dependencies:
"@babel/runtime" "^7.10.1"
"@rc-component/context" "^1.4.0"
@@ -13259,10 +13263,10 @@ swagger-client@^3.35.7:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
swagger-ui-react@^5.29.3:
version "5.29.3"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.3.tgz#a132c3c3c4553c2acd0aca1f02c8484ca4c78183"
integrity sha512-cx47SmqrxXCP86+6NHEzXUBEG/MGbNK/H8BQphzUVomxGpG9lZCUo6hIGFNe1i7fP5eaMxpLV/qoqaWVo3TSvw==
swagger-ui-react@^5.29.5:
version "5.29.5"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.5.tgz#8c6eafebb75972c15a9f3e24627caec10cc32cbe"
integrity sha512-D0YbsDhi4F38HsY5p1DjzuNduU/fVQxtqm3v0o2dRTF5BbLJYRSgjMZ79jejG4q3nNw4kuouCKKiq5xqCLjWrQ==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -13590,15 +13594,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.46.0:
version "8.46.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.0.tgz#fb1c37a90fadf42fe1c8f8b192b974b6d9c439cc"
integrity sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==
typescript-eslint@^8.46.1:
version "8.46.1"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.1.tgz#baeb322ee83ca566a8cf1f6403847694a3acd44a"
integrity sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==
dependencies:
"@typescript-eslint/eslint-plugin" "8.46.0"
"@typescript-eslint/parser" "8.46.0"
"@typescript-eslint/typescript-estree" "8.46.0"
"@typescript-eslint/utils" "8.46.0"
"@typescript-eslint/eslint-plugin" "8.46.1"
"@typescript-eslint/parser" "8.46.1"
"@typescript-eslint/typescript-estree" "8.46.1"
"@typescript-eslint/utils" "8.46.1"
typescript@~5.9.3:
version "5.9.3"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+153 -98
View File
@@ -62,14 +62,14 @@
"content-disposition": "^0.5.4",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"fuse.js": "^7.0.0",
"fuse.js": "^7.1.0",
"geolib": "^2.0.24",
"geostyler": "^14.1.3",
"geostyler-data": "^1.1.0",
@@ -112,7 +112,7 @@
"react-loadable": "^5.5.0",
"react-redux": "^7.2.9",
"react-resize-detector": "^7.1.2",
"react-reverse-portal": "^2.1.2",
"react-reverse-portal": "^2.3.0",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
"react-sortable-hoc": "^2.0.0",
@@ -139,10 +139,10 @@
},
"devDependencies": {
"@applitools/eyes-storybook": "^3.60.0",
"@babel/cli": "^7.27.2",
"@babel/cli": "^7.28.3",
"@babel/compat-data": "^7.28.0",
"@babel/core": "^7.28.3",
"@babel/eslint-parser": "^7.25.9",
"@babel/eslint-parser": "^7.28.4",
"@babel/node": "^7.22.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
@@ -161,7 +161,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.56.0",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -184,7 +184,7 @@
"@types/json-bigint": "^1.0.4",
"@types/math-expression-evaluator": "^1.3.3",
"@types/mousetrap": "^1.6.15",
"@types/node": "^24.6.2",
"@types/node": "^24.8.1",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-json-tree": "^0.13.0",
@@ -210,7 +210,7 @@
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"cheerio": "1.1.0",
"copy-webpack-plugin": "^13.0.0",
"copy-webpack-plugin": "^13.0.1",
"cross-env": "^10.0.0",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
@@ -228,7 +228,7 @@
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-prefer-function-component": "^3.3.0",
@@ -268,7 +268,7 @@
"tsx": "^4.20.3",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.102.0",
"webpack": "^5.102.1",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
@@ -1106,13 +1106,13 @@
}
},
"node_modules/@babel/cli": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.27.2.tgz",
"integrity": "sha512-cfd7DnGlhH6OIyuPSSj3vcfIdnbXukhAyKY8NaZrFadC7pXyL9mOL5WgjcptiEJLi5k3j8aYvLIVCzezrWTaiA==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.3.tgz",
"integrity": "sha512-n1RU5vuCX0CsaqaXm9I0KUCNKNQMy5epmzl/xdSSm70bSqhg9GWhgeosypyQLc0bK24+Xpk1WGzZlI9pJtkZdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@jridgewell/trace-mapping": "^0.3.28",
"commander": "^6.2.0",
"convert-source-map": "^2.0.0",
"fs-readdir-recursive": "^1.1.0",
@@ -1201,9 +1201,9 @@
}
},
"node_modules/@babel/eslint-parser": {
"version": "7.26.5",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.26.5.tgz",
"integrity": "sha512-Kkm8C8uxI842AwQADxl0GbcG1rupELYLShazYEZO/2DYjhyWXJIOUVOE3tBYm6JXzUCNJOZEzqc4rCW/jsEQYQ==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.4.tgz",
"integrity": "sha512-Aa+yDiH87980jR6zvRfFuCR1+dLb00vBydhTL+zI992Rz/wQhSvuxjmOOuJOgO3XmakO6RykRGD2S1mq1AtgHA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4125,6 +4125,12 @@
"mjolnir.js": "^3.0.0"
}
},
"node_modules/@deck.gl/core/node_modules/@luma.gl/constants": {
"version": "9.1.10",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.10.tgz",
"integrity": "sha512-O4Nx8UbWmrHHZ7ihKB8WiscX1cz05l1KvKorYTgq+xeXwz2Beh3MkXBMnA46uuyEtimN945OEdYshZnbh80jyw==",
"license": "MIT"
},
"node_modules/@deck.gl/extensions": {
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.1.13.tgz",
@@ -9000,9 +9006,9 @@
}
},
"node_modules/@luma.gl/constants": {
"version": "9.1.9",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.9.tgz",
"integrity": "sha512-yc9fml04OeTTcwK+7gmDMxoLQ67j4ZiAFXjmYvPomYyBVzS0NZxTDuwcCBmnxjLOiroOZW8FRRrVc/yOiFug2w==",
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.2.tgz",
"integrity": "sha512-XURMF0gSh0ImZltYa/PCe9KgmopQJiOA6y1m1PxDxJY8OCLma7ZJyvomLn7TQBvPtWTYZsibTW7blu7RwThsaQ==",
"license": "MIT"
},
"node_modules/@luma.gl/core": {
@@ -9078,6 +9084,12 @@
"@luma.gl/core": "^9.1.0"
}
},
"node_modules/@luma.gl/webgl/node_modules/@luma.gl/constants": {
"version": "9.1.9",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.9.tgz",
"integrity": "sha512-yc9fml04OeTTcwK+7gmDMxoLQ67j4ZiAFXjmYvPomYyBVzS0NZxTDuwcCBmnxjLOiroOZW8FRRrVc/yOiFug2w==",
"license": "MIT"
},
"node_modules/@mapbox/extent": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@mapbox/extent/-/extent-0.4.0.tgz",
@@ -10590,13 +10602,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
"playwright": "1.56.0"
},
"bin": {
"playwright": "cli.js"
@@ -16371,12 +16383,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz",
"integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==",
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.13.0"
"undici-types": "~7.14.0"
}
},
"node_modules/@types/node-forge": {
@@ -20893,9 +20905,9 @@
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
"integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==",
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true,
"funding": [
{
@@ -20913,9 +20925,9 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
"electron-to-chromium": "^1.5.218",
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
},
@@ -22723,9 +22735,9 @@
}
},
"node_modules/copy-webpack-plugin": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz",
"integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==",
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz",
"integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -23925,9 +23937,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -26343,14 +26355,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz",
"integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
"integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.9.1"
"synckit": "^0.11.7"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -26361,7 +26373,7 @@
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": "*",
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
@@ -26373,6 +26385,35 @@
}
}
},
"node_modules/eslint-plugin-prettier/node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/eslint-plugin-prettier/node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -28414,9 +28455,9 @@
}
},
"node_modules/fuse.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
@@ -46079,13 +46120,13 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
"playwright-core": "1.56.0"
},
"bin": {
"playwright": "cli.js"
@@ -46098,9 +46139,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -48969,13 +49010,13 @@
}
},
"node_modules/react-reverse-portal": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.1.2.tgz",
"integrity": "sha512-li4puNtBmMMJhtI+IVxeSX0RvK1ft8qjPSbCih4OKQ/YUIcROc31Nmo22gv94hTx8EUfR7fzZY47RuZF2YRMdQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.3.0.tgz",
"integrity": "sha512-kvbPfLPKg6Y3S6tVq83us2RghvDpOS4GcJxbI7cZ0V0tuzUaSzblRIhVnKLOucfqF4lN/i9oWvEmpEi6bAOYlQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-router": {
@@ -51689,9 +51730,9 @@
}
},
"node_modules/schema-utils": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -54372,9 +54413,9 @@
}
},
"node_modules/tapable": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz",
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -56413,9 +56454,9 @@
}
},
"node_modules/undici-types": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz",
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
@@ -57554,9 +57595,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz",
"integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==",
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -57568,7 +57609,7 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.24.5",
"browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
@@ -57580,8 +57621,8 @@
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.2",
"tapable": "^2.2.3",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
@@ -59296,7 +59337,7 @@
"version": "0.20.3",
"license": "Apache-2.0",
"dependencies": {
"chalk": "^5.4.1",
"chalk": "^5.6.2",
"lodash-es": "^4.17.21",
"yeoman-generator": "^7.5.1",
"yosay": "^3.0.0"
@@ -59977,9 +60018,9 @@
}
},
"packages/generator-superset/node_modules/chalk": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
@@ -61343,7 +61384,7 @@
"version": "0.0.1-rc5",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.3",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
@@ -64067,7 +64108,7 @@
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@babel/runtime": "^7.28.4",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
@@ -64083,14 +64124,14 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.4",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.8",
"jed": "^1.1.1",
"lodash": "^4.17.21",
"math-expression-evaluator": "^2.0.6",
"pretty-ms": "^9.2.0",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.5.0",
@@ -64107,7 +64148,7 @@
"reselect": "^5.1.1",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.14"
"xss": "^1.0.15"
},
"devDependencies": {
"@emotion/styled": "^11.14.1",
@@ -64119,7 +64160,7 @@
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.20",
"@types/math-expression-evaluator": "^1.3.3",
"@types/node": "^24.6.2",
"@types/node": "^24.8.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -65000,6 +65041,20 @@
],
"license": "MIT"
},
"packages/superset-ui-core/node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
"integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/superset-ui-core/node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
@@ -65936,7 +65991,7 @@
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/layers": "^9.1.13",
"@deck.gl/react": "^9.1.14",
"@luma.gl/constants": "^9.1.9",
"@luma.gl/constants": "^9.2.2",
"@luma.gl/core": "^9.1.9",
"@luma.gl/engine": "^9.1.9",
"@luma.gl/shadertools": "^9.1.9",
@@ -65950,7 +66005,7 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
@@ -65994,6 +66049,12 @@
"@luma.gl/engine": "~9.1.9"
}
},
"plugins/legacy-preset-chart-deckgl/node_modules/@deck.gl/aggregation-layers/node_modules/@luma.gl/constants": {
"version": "9.1.10",
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.10.tgz",
"integrity": "sha512-O4Nx8UbWmrHHZ7ihKB8WiscX1cz05l1KvKorYTgq+xeXwz2Beh3MkXBMnA46uuyEtimN945OEdYshZnbh80jyw==",
"license": "MIT"
},
"plugins/legacy-preset-chart-deckgl/node_modules/@mapbox/tiny-sdf": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
@@ -66084,7 +66145,7 @@
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dayjs": "^1.11.18",
"dompurify": "^3.2.7",
"dompurify": "^3.3.0",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.17.21",
"nvd3-fork": "^2.0.5",
@@ -66097,16 +66158,10 @@
"react": "^17.0.2"
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT"
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -66178,7 +66233,7 @@
"dependencies": {
"@types/react-redux": "^7.1.34",
"d3-array": "^1.2.0",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"lodash": "^4.17.21"
},
"peerDependencies": {
+10 -10
View File
@@ -135,14 +135,14 @@
"content-disposition": "^0.5.4",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
"fuse.js": "^7.0.0",
"fuse.js": "^7.1.0",
"geolib": "^2.0.24",
"geostyler": "^14.1.3",
"geostyler-data": "^1.1.0",
@@ -185,7 +185,7 @@
"react-loadable": "^5.5.0",
"react-redux": "^7.2.9",
"react-resize-detector": "^7.1.2",
"react-reverse-portal": "^2.1.2",
"react-reverse-portal": "^2.3.0",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
"react-sortable-hoc": "^2.0.0",
@@ -212,10 +212,10 @@
},
"devDependencies": {
"@applitools/eyes-storybook": "^3.60.0",
"@babel/cli": "^7.27.2",
"@babel/cli": "^7.28.3",
"@babel/compat-data": "^7.28.0",
"@babel/core": "^7.28.3",
"@babel/eslint-parser": "^7.25.9",
"@babel/eslint-parser": "^7.28.4",
"@babel/node": "^7.22.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
@@ -234,7 +234,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.56.0",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -257,7 +257,7 @@
"@types/json-bigint": "^1.0.4",
"@types/math-expression-evaluator": "^1.3.3",
"@types/mousetrap": "^1.6.15",
"@types/node": "^24.6.2",
"@types/node": "^24.8.1",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-json-tree": "^0.13.0",
@@ -283,7 +283,7 @@
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"cheerio": "1.1.0",
"copy-webpack-plugin": "^13.0.0",
"copy-webpack-plugin": "^13.0.1",
"cross-env": "^10.0.0",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
@@ -301,7 +301,7 @@
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-prefer-function-component": "^3.3.0",
@@ -341,7 +341,7 @@
"tsx": "^4.20.3",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.102.0",
"webpack": "^5.102.1",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
@@ -28,7 +28,7 @@
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
},
"dependencies": {
"chalk": "^5.4.1",
"chalk": "^5.6.2",
"lodash-es": "^4.17.21",
"yeoman-generator": "^7.5.1",
"yosay": "^3.0.0"
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.3",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
@@ -27,7 +27,7 @@
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.28.4",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.43.3",
@@ -38,7 +38,7 @@
"csstype": "^3.1.3",
"core-js": "^3.38.1",
"d3-format": "^1.3.2",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
@@ -49,7 +49,7 @@
"jed": "^1.1.1",
"lodash": "^4.17.21",
"math-expression-evaluator": "^2.0.6",
"pretty-ms": "^9.2.0",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-js-cron": "^5.2.0",
@@ -67,7 +67,7 @@
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",
"xss": "^1.0.14"
"xss": "^1.0.15"
},
"devDependencies": {
"@emotion/styled": "^11.14.1",
@@ -81,7 +81,7 @@
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.20",
"@types/math-expression-evaluator": "^1.3.3",
"@types/node": "^24.6.2",
"@types/node": "^24.8.1",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
@@ -29,7 +29,7 @@
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/layers": "^9.1.13",
"@deck.gl/react": "^9.1.14",
"@luma.gl/constants": "^9.1.9",
"@luma.gl/constants": "^9.2.2",
"@luma.gl/core": "^9.1.9",
"@luma.gl/engine": "^9.1.9",
"@luma.gl/shadertools": "^9.1.9",
@@ -43,7 +43,7 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
@@ -16,10 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, memo } from 'react';
import { styled, t } from '@superset-ui/core';
import { useEffect, useState, memo, useMemo } from 'react';
import { styled, t, sanitizeHtml } from '@superset-ui/core';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import { SafeMarkdown } from '@superset-ui/core/components';
import Handlebars from 'handlebars';
import { isPlainObject } from 'lodash';
@@ -45,8 +44,6 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
const htmlSchemaOverrides =
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
useEffect(() => {
try {
@@ -60,6 +57,12 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
}
}, [templateSource, data]);
const htmlContent = useMemo(
() =>
htmlSanitization ? sanitizeHtml(renderedTemplate) : renderedTemplate,
[renderedTemplate, htmlSanitization],
);
if (error) {
return <ErrorContainer>{error}</ErrorContainer>;
}
@@ -73,13 +76,9 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
fontSize: '12px',
lineHeight: '1.4',
}}
>
<SafeMarkdown
source={renderedTemplate || ''}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
</div>
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
}
@@ -35,7 +35,7 @@
"lodash": "^4.17.21",
"dayjs": "^1.11.18",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.2.7",
"dompurify": "^3.3.0",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
@@ -26,7 +26,7 @@
"dependencies": {
"@types/react-redux": "^7.1.34",
"d3-array": "^1.2.0",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"lodash": "^4.17.21"
},
"peerDependencies": {
@@ -142,7 +142,7 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('X AXIS TITLE MARGIN'),
label: t('X axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -214,7 +214,7 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('Y AXIS TITLE MARGIN'),
label: t('Y axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -81,7 +81,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('AXIS TITLE MARGIN'),
label: t('Axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -114,7 +114,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('AXIS TITLE MARGIN'),
label: t('Axis title margin'),
renderTrigger: true,
default: sections.TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
@@ -132,7 +132,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
type: 'SelectControl',
freeForm: true,
clearable: false,
label: t('AXIS TITLE POSITION'),
label: t('Axis title position'),
renderTrigger: true,
default: sections.TITLE_POSITION_OPTIONS[0][0],
choices: sections.TITLE_POSITION_OPTIONS,
@@ -27,7 +27,7 @@ import {
DragEvent,
useEffect,
} from 'react';
import { styled, typedMemo, usePrevious } from '@superset-ui/core';
import { typedMemo, usePrevious } from '@superset-ui/core';
import {
useTable,
usePagination,
@@ -42,7 +42,7 @@ import {
} from 'react-table';
import { matchSorter, rankings } from 'match-sorter';
import { isEqual } from 'lodash';
import { Space } from '@superset-ui/core/components';
import { Flex, Space } from '@superset-ui/core/components';
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
import SelectPageSize, {
SelectPageSizeProps,
@@ -77,7 +77,7 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
sticky?: boolean;
rowCount: number;
wrapperRef?: MutableRefObject<HTMLDivElement>;
onColumnOrderChange: () => void;
onColumnOrderChange?: () => void;
renderGroupingHeaders?: () => JSX.Element;
renderTimeComparisonDropdown?: () => JSX.Element;
handleSortByChange: (sortBy: SortByItem[]) => void;
@@ -98,24 +98,6 @@ const sortTypes = {
alphanumeric: sortAlphanumericCaseInsensitive,
};
const StyledSpace = styled(Space)`
display: flex;
justify-content: flex-end;
.search-select-container {
display: flex;
}
.search-by-label {
align-self: center;
margin-right: 4px;
}
`;
const StyledRow = styled.div`
display: flex;
`;
// Be sure to pass our updateMyData and the skipReset option
export default typedMemo(function DataTable<D extends object>({
tableClassName,
@@ -336,8 +318,7 @@ export default typedMemo(function DataTable<D extends object>({
const colToBeMoved = currentCols.splice(columnBeingDragged, 1);
currentCols.splice(newPosition, 0, colToBeMoved[0]);
setColumnOrder(currentCols);
// toggle value in TableChart to trigger column width recalc
onColumnOrderChange();
onColumnOrderChange?.();
}
e.preventDefault();
};
@@ -450,30 +431,36 @@ export default typedMemo(function DataTable<D extends object>({
>
{hasGlobalControl ? (
<div ref={globalControlRef} className="form-inline dt-controls">
<StyledRow className="row">
<StyledSpace size="middle">
{hasPagination ? (
<SelectPageSize
total={resultsSize}
current={resultCurrentPageSize}
options={pageSizeOptions}
selectRenderer={
typeof selectPageSize === 'boolean'
? undefined
: selectPageSize
}
onChange={setPageSize}
/>
) : null}
<Flex
wrap
className="row"
align="center"
justify="space-between"
gap="middle"
>
{hasPagination ? (
<SelectPageSize
total={resultsSize}
current={resultCurrentPageSize}
options={pageSizeOptions}
selectRenderer={
typeof selectPageSize === 'boolean'
? undefined
: selectPageSize
}
onChange={setPageSize}
/>
) : null}
<Flex wrap align="center" gap="middle">
{serverPagination && (
<div className="search-select-container">
<span className="search-by-label">Search by: </span>
<Space size="small" className="search-select-container">
<span className="search-by-label">Search by:</span>
<SearchSelectDropdown
searchOptions={searchOptions}
value={serverPaginationData?.searchColumn || ''}
onChange={onSearchColChange}
/>
</div>
</Space>
)}
{searchInput && (
<GlobalFilter<D>
@@ -493,8 +480,8 @@ export default typedMemo(function DataTable<D extends object>({
{renderTimeComparisonDropdown
? renderTimeComparisonDropdown()
: null}
</StyledSpace>
</StyledRow>
</Flex>
</Flex>
</div>
) : null}
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}
@@ -195,6 +195,21 @@ function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
return sortIcon;
}
/**
* Label that is visually hidden but accessible
*/
const VisuallyHidden = styled.label`
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
`;
function SearchInput({
count,
value,
@@ -225,10 +240,10 @@ function SelectPageSize({
const { Option } = Select;
return (
<>
<label htmlFor="pageSizeSelect" className="sr-only">
<span className="dt-select-page-size">
<VisuallyHidden htmlFor="pageSizeSelect">
{t('Select page size')}
</label>
</VisuallyHidden>
{t('Show')}{' '}
<Select<number>
id="pageSizeSelect"
@@ -252,7 +267,7 @@ function SelectPageSize({
})}
</Select>{' '}
{t('entries per page')}
</>
</span>
);
}
@@ -296,12 +311,17 @@ export default function TableChart<D extends DataRecord = DataRecord>(
serverPageLength,
slice_id,
} = props;
const comparisonColumns = [
{ key: 'all', label: t('Display all') },
{ key: '#', label: '#' },
{ key: '', label: '△' },
{ key: '%', label: '%' },
];
const comparisonColumns = useMemo(
() => [
{ key: 'all', label: t('Display all') },
{ key: '#', label: '#' },
{ key: '△', label: '△' },
{ key: '%', label: '%' },
],
[],
);
const timestampFormatter = useCallback(
value => getTimeFormatterForGranularity(timeGrain)(value),
[timeGrain],
@@ -353,71 +373,74 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[filters],
);
const getCrossFilterDataMask = (key: string, value: DataRecordValue) => {
let updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, value)) {
updatedFilters = {};
} else {
updatedFilters = {
[key]: [value],
};
}
if (
Array.isArray(updatedFilters[key]) &&
updatedFilters[key].length === 0
) {
delete updatedFilters[key];
}
const groupBy = Object.keys(updatedFilters);
const groupByValues = Object.values(updatedFilters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(updatedFilters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
const getCrossFilterDataMask = useCallback(
(key: string, value: DataRecordValue) => {
let updatedFilters = { ...(filters || {}) };
if (filters && isActiveFilterValue(key, value)) {
updatedFilters = {};
} else {
updatedFilters = {
[key]: [value],
};
}
if (
Array.isArray(updatedFilters[key]) &&
updatedFilters[key].length === 0
) {
delete updatedFilters[key];
}
});
return {
dataMask: {
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(updatedFilters?.[col]);
if (!val.length)
const groupBy = Object.keys(updatedFilters);
const groupByValues = Object.values(updatedFilters);
const labelElements: string[] = [];
groupBy.forEach(col => {
const isTimestamp = col === DTTM_ALIAS;
const filterValues = ensureIsArray(updatedFilters?.[col]);
if (filterValues.length) {
const valueLabels = filterValues.map(value =>
isTimestamp ? timestampFormatter(value) : value,
);
labelElements.push(`${valueLabels.join(', ')}`);
}
});
return {
dataMask: {
extraFormData: {
filters:
groupBy.length === 0
? []
: groupBy.map(col => {
const val = ensureIsArray(updatedFilters?.[col]);
if (!val.length)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IS NULL' as const,
op: 'IN' as const,
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
};
return {
col,
op: 'IN' as const,
val: val.map(el =>
el instanceof Date ? el.getTime() : el!,
),
grain: col === DTTM_ALIAS ? timeGrain : undefined,
};
}),
}),
},
filterState: {
label: labelElements.join(', '),
value: groupByValues.length ? groupByValues : null,
filters:
updatedFilters && Object.keys(updatedFilters).length
? updatedFilters
: null,
},
},
filterState: {
label: labelElements.join(', '),
value: groupByValues.length ? groupByValues : null,
filters:
updatedFilters && Object.keys(updatedFilters).length
? updatedFilters
: null,
},
},
isCurrentValueSelected: isActiveFilterValue(key, value),
};
};
isCurrentValueSelected: isActiveFilterValue(key, value),
};
},
[filters, isActiveFilterValue, timestampFormatter, timeGrain],
);
const toggleFilter = useCallback(
function toggleFilter(key: string, val: DataRecordValue) {
@@ -429,17 +452,21 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
);
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
const { isNumeric, config = {} } = column;
const textAlign =
config.horizontalAlign ||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
return {
textAlign,
};
};
const getSharedStyle = useCallback(
(column: DataColumnMeta): CSSProperties => {
const { isNumeric, config = {} } = column;
const textAlign =
config.horizontalAlign ||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
return {
textAlign,
};
},
[isUsingTimeComparison],
);
const comparisonLabels = useMemo(() => [t('Main'), '#', '△', '%'], []);
const comparisonLabels = [t('Main'), '#', '△', '%'];
const filteredColumnsMeta = useMemo(() => {
if (!isUsingTimeComparison) {
return columnsMeta;
@@ -471,79 +498,86 @@ export default function TableChart<D extends DataRecord = DataRecord>(
selectedComparisonColumns,
]);
const handleContextMenu =
onContextMenu && !isRawRecords
? (
value: D,
cellPoint: {
key: string;
value: DataRecordValue;
isMetric?: boolean;
},
clientX: number,
clientY: number,
) => {
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
filteredColumnsMeta.forEach(col => {
if (!col.isMetric) {
const dataRecordValue = value[col.key];
drillToDetailFilters.push({
col: col.key,
op: '==',
val: dataRecordValue as string | number | boolean,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
});
onContextMenu(clientX, clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: cellPoint.isMetric
? undefined
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
drillBy: cellPoint.isMetric
? undefined
: {
filters: [
{
col: cellPoint.key,
op: '==',
val: cellPoint.value as string | number | boolean,
},
],
groupbyFieldName: 'groupby',
},
});
}
: undefined;
const getHeaderColumns = (
columnsMeta: DataColumnMeta[],
enableTimeComparison?: boolean,
) => {
const resultMap: Record<string, number[]> = {};
if (!enableTimeComparison) {
return resultMap;
const handleContextMenu = useMemo(() => {
if (onContextMenu && !isRawRecords) {
return (
value: D,
cellPoint: {
key: string;
value: DataRecordValue;
isMetric?: boolean;
},
clientX: number,
clientY: number,
) => {
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
filteredColumnsMeta.forEach(col => {
if (!col.isMetric) {
const dataRecordValue = value[col.key];
drillToDetailFilters.push({
col: col.key,
op: '==',
val: dataRecordValue as string | number | boolean,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
});
onContextMenu(clientX, clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: cellPoint.isMetric
? undefined
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
drillBy: cellPoint.isMetric
? undefined
: {
filters: [
{
col: cellPoint.key,
op: '==',
val: cellPoint.value as string | number | boolean,
},
],
groupbyFieldName: 'groupby',
},
});
};
}
return undefined;
}, [
onContextMenu,
isRawRecords,
filteredColumnsMeta,
getCrossFilterDataMask,
]);
columnsMeta.forEach((element, index) => {
// Check if element's label is one of the comparison labels
if (comparisonLabels.includes(element.label)) {
// Extract the key portion after the space, assuming the format is always "label key"
const keyPortion = element.key.substring(element.label.length);
const getHeaderColumns = useCallback(
(columnsMeta: DataColumnMeta[], enableTimeComparison?: boolean) => {
const resultMap: Record<string, number[]> = {};
// If the key portion is not in the map, initialize it with the current index
if (!resultMap[keyPortion]) {
resultMap[keyPortion] = [index];
} else {
// Add the index to the existing array
resultMap[keyPortion].push(index);
}
if (!enableTimeComparison) {
return resultMap;
}
});
return resultMap;
};
columnsMeta.forEach((element, index) => {
// Check if element's label is one of the comparison labels
if (comparisonLabels.includes(element.label)) {
// Extract the key portion after the space, assuming the format is always "label key"
const keyPortion = element.key.substring(element.label.length);
// If the key portion is not in the map, initialize it with the current index
if (!resultMap[keyPortion]) {
resultMap[keyPortion] = [index];
} else {
// Add the index to the existing array
resultMap[keyPortion].push(index);
}
}
});
return resultMap;
},
[comparisonLabels],
);
const renderTimeComparisonDropdown = (): JSX.Element => {
const allKey = comparisonColumns[0].key;
@@ -638,6 +672,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
);
};
const groupHeaderColumns = useMemo(
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
[filteredColumnsMeta, getHeaderColumns, isUsingTimeComparison],
);
const renderGroupingHeaders = (): JSX.Element => {
// TODO: Make use of ColumnGroup to render the aditional headers
const headers: any = [];
@@ -719,11 +758,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
);
};
const groupHeaderColumns = useMemo(
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
[filteredColumnsMeta, isUsingTimeComparison],
);
const getColumnConfigs = useCallback(
(
column: DataColumnMeta,
@@ -1086,19 +1120,27 @@ export default function TableChart<D extends DataRecord = DataRecord>(
};
},
[
getSharedStyle,
defaultAlignPN,
defaultColorPN,
emitCrossFilters,
getValueRange,
isActiveFilterValue,
isRawRecords,
showCellBars,
sortDesc,
toggleFilter,
totals,
columnColorFormatters,
columnOrderToggle,
isUsingTimeComparison,
basicColorFormatters,
showCellBars,
isRawRecords,
getValueRange,
emitCrossFilters,
comparisonLabels,
totals,
theme,
sortDesc,
groupHeaderColumns,
allowRenderHtml,
basicColorColumnFormatters,
isActiveFilterValue,
toggleFilter,
handleContextMenu,
allowRearrangeColumns,
],
);
@@ -1131,7 +1173,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
if (!isEqual(options, searchOptions)) {
setSearchOptions(options || []);
}
}, [columns]);
}, [columns, searchOptions]);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
@@ -1142,7 +1184,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask],
[serverPaginationData, setDataMask],
);
useEffect(() => {
@@ -1154,7 +1196,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
};
updateTableOwnState(setDataMask, modifiedOwnState);
}
}, []);
}, [
hasServerPageLengthChanged,
serverPageLength,
serverPaginationData,
setDataMask,
]);
const handleSizeChange = useCallback(
({ width, height }: { width: number; height: number }) => {
@@ -1200,7 +1247,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[setDataMask, serverPagination],
[serverPagination, serverPaginationData, setDataMask],
);
const handleSearch = (searchText: string) => {
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DatasourceType } from '@superset-ui/core';
export const id = 7;
export const datasourceId = `${id}__table`;
@@ -40,125 +42,135 @@ export default {
},
metrics: [
{
id: 1,
uuid: 'metric-1-uuid',
expression: 'SUM(birth_names.num)',
warning_text: null,
verbose_name: 'sum__num',
metric_name: 'sum__num',
description: null,
metric_type: 'sum',
certified_by: 'someone',
certification_details: 'foo',
warning_markdown: 'bar',
extra:
'{"certification":{"details":"foo", "certified_by":"someone"},"warning_markdown":"bar"}',
},
{
id: 2,
uuid: 'metric-2-uuid',
expression: 'AVG(birth_names.num)',
warning_text: null,
verbose_name: 'avg__num',
metric_name: 'avg__num',
description: null,
metric_type: 'avg',
},
{
id: 3,
uuid: 'metric-3-uuid',
expression: 'SUM(birth_names.num_boys)',
warning_text: null,
verbose_name: 'sum__num_boys',
metric_name: 'sum__num_boys',
description: null,
metric_type: 'sum',
},
{
id: 4,
uuid: 'metric-4-uuid',
expression: 'AVG(birth_names.num_boys)',
warning_text: null,
verbose_name: 'avg__num_boys',
metric_name: 'avg__num_boys',
description: null,
metric_type: 'avg',
},
{
id: 5,
uuid: 'metric-5-uuid',
expression: 'SUM(birth_names.num_girls)',
warning_text: null,
verbose_name: 'sum__num_girls',
metric_name: 'sum__num_girls',
description: null,
metric_type: 'sum',
},
{
id: 6,
uuid: 'metric-6-uuid',
expression: 'AVG(birth_names.num_girls)',
warning_text: null,
verbose_name: 'avg__num_girls',
metric_name: 'avg__num_girls',
description: null,
metric_type: 'avg',
},
{
id: 7,
uuid: 'metric-7-uuid',
expression: 'COUNT(*)',
warning_text: null,
verbose_name: 'COUNT(*)',
metric_name: 'count',
description: null,
metric_type: 'count',
},
],
column_formats: {},
columns: [
{
id: 1,
type: 'DATETIME',
description: null,
filterable: false,
verbose_name: null,
is_dttm: true,
is_active: true,
expression: '',
groupby: false,
column_name: 'ds',
},
{
id: 2,
type: 'VARCHAR(16)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
is_active: true,
expression: '',
groupby: true,
column_name: 'gender',
},
{
id: 3,
type: 'VARCHAR(255)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
is_active: true,
expression: '',
groupby: true,
column_name: 'name',
},
{
id: 4,
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
is_active: true,
expression: '',
groupby: false,
column_name: 'num',
},
{
id: 5,
type: 'VARCHAR(10)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
is_active: true,
expression: '',
groupby: true,
column_name: 'state',
},
{
id: 6,
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
is_active: true,
expression: '',
groupby: false,
column_name: 'num_boys',
},
{
id: 7,
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
is_active: true,
expression: '',
groupby: false,
column_name: 'num_girls',
@@ -169,7 +181,9 @@ export default {
granularity_sqla: [['ds', 'ds']],
main_dttm_col: 'ds',
name: 'birth_names',
owners: [{ first_name: 'joe', last_name: 'man', id: 1 }],
owners: [
{ first_name: 'joe', last_name: 'man', id: 1, username: 'joeman' },
],
database: {
name: 'main',
backend: 'sqlite',
@@ -198,6 +212,11 @@ export default {
['["num_girls", true]', 'num_girls [asc]'],
['["num_girls", false]', 'num_girls [desc]'],
],
type: 'table',
type: DatasourceType.Table,
description: null,
is_managed_externally: false,
normalize_columns: false,
always_filter_main_dttm: false,
datasource_name: null,
},
};
@@ -1019,10 +1019,16 @@ class DatasourceEditor extends PureComponent {
<Field
fieldKey="default_endpoint"
label={t('Default URL')}
description={t(
`Default URL to redirect to when accessing from the dataset list page.
Accepts relative URLs such as <span style=„white-space: nowrap;”>/superset/dashboard/{id}/</span>`,
)}
description={
<>
{t(
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
)}{' '}
<Typography.Text code>
/superset/dashboard/{'{id}'}/
</Typography.Text>
</>
}
control={<TextControl controlId="default_endpoint" />}
/>
<Field
@@ -25,7 +25,8 @@ import {
cleanup,
} from 'spec/helpers/testing-library';
import mockDatasource from 'spec/fixtures/mockDatasource';
import { isFeatureEnabled } from '@superset-ui/core';
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
import type { DatasetObject } from 'src/features/datasets/types';
import DatasourceEditor from '..';
/* eslint-disable jest/no-export */
@@ -34,8 +35,17 @@ jest.mock('@superset-ui/core', () => ({
isFeatureEnabled: jest.fn(),
}));
interface DatasourceEditorProps {
datasource: DatasetObject;
addSuccessToast: () => void;
addDangerToast: () => void;
onChange: jest.Mock;
columnLabels?: Record<string, string>;
columnLabelTooltips?: Record<string, string>;
}
// Common setup for tests
export const props = {
export const props: DatasourceEditorProps = {
datasource: mockDatasource['7__table'],
addSuccessToast: () => {},
addDangerToast: () => {},
@@ -47,16 +57,19 @@ export const props = {
state: 'This is a tooltip for state',
},
};
export const DATASOURCE_ENDPOINT =
'glob:*/datasource/external_metadata_by_name/*';
const routeProps = {
history: {},
location: {},
match: {},
};
export const asyncRender = props =>
export const asyncRender = (renderProps: DatasourceEditorProps) =>
waitFor(() =>
render(<DatasourceEditor {...props} {...routeProps} />, {
render(<DatasourceEditor {...renderProps} {...routeProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
useRouter: true,
@@ -87,16 +100,16 @@ describe('DatasourceEditor', () => {
test('can sync columns from source', async () => {
const columnsTab = screen.getByTestId('collection-tab-Columns');
userEvent.click(columnsTab);
await userEvent.click(columnsTab);
const syncButton = screen.getByText(/sync columns from source/i);
expect(syncButton).toBeInTheDocument();
// Use a Promise to track when fetchMock is called
const fetchPromise = new Promise(resolve => {
const fetchPromise = new Promise<string>(resolve => {
fetchMock.get(
DATASOURCE_ENDPOINT,
url => {
(url: string) => {
resolve(url);
return [];
},
@@ -104,7 +117,7 @@ describe('DatasourceEditor', () => {
);
});
userEvent.click(syncButton);
await userEvent.click(syncButton);
// Wait for the fetch to be called
const url = await fetchPromise;
@@ -114,12 +127,12 @@ describe('DatasourceEditor', () => {
// to add, remove and modify columns accordingly
test('can modify columns', async () => {
const columnsTab = screen.getByTestId('collection-tab-Columns');
userEvent.click(columnsTab);
await userEvent.click(columnsTab);
const getToggles = screen.getAllByRole('button', {
name: /expand row/i,
});
userEvent.click(getToggles[0]);
await userEvent.click(getToggles[0]);
const getTextboxes = await screen.findAllByRole('textbox');
expect(getTextboxes.length).toBeGreaterThanOrEqual(5);
@@ -132,22 +145,39 @@ describe('DatasourceEditor', () => {
'Certification details',
);
userEvent.type(inputLabel, 'test_label');
userEvent.type(inputDescription, 'test');
userEvent.type(inputDtmFormat, 'test');
userEvent.type(inputCertifiedBy, 'test');
userEvent.type(inputCertDetails, 'test');
// Clear onChange mock to track user action callbacks
props.onChange.mockClear();
await userEvent.type(inputLabel, 'test_label');
await userEvent.type(inputDescription, 'test');
await userEvent.type(inputDtmFormat, 'test');
await userEvent.type(inputCertifiedBy, 'test');
await userEvent.type(inputCertDetails, 'test');
// Verify the inputs were updated with the typed values
await waitFor(() => {
expect(inputLabel).toHaveValue('test_label');
expect(inputDescription).toHaveValue('test');
expect(inputDtmFormat).toHaveValue('test');
expect(inputCertifiedBy).toHaveValue('test');
expect(inputCertDetails).toHaveValue('test');
});
// Verify that onChange was triggered by user actions
await waitFor(() => {
expect(props.onChange).toHaveBeenCalled();
});
}, 40000);
test('can delete columns', async () => {
const columnsTab = screen.getByTestId('collection-tab-Columns');
userEvent.click(columnsTab);
await userEvent.click(columnsTab);
const getToggles = screen.getAllByRole('button', {
name: /expand row/i,
});
userEvent.click(getToggles[0]);
await userEvent.click(getToggles[0]);
const deleteButtons = await screen.findAllByRole('button', {
name: /delete item/i,
@@ -155,7 +185,7 @@ describe('DatasourceEditor', () => {
const initialCount = deleteButtons.length;
expect(initialCount).toBeGreaterThan(0);
userEvent.click(deleteButtons[0]);
await userEvent.click(deleteButtons[0]);
await waitFor(() => {
const countRows = screen.getAllByRole('button', { name: /delete item/i });
@@ -165,14 +195,14 @@ describe('DatasourceEditor', () => {
test('can add new columns', async () => {
const calcColsTab = screen.getByTestId('collection-tab-Calculated columns');
userEvent.click(calcColsTab);
await userEvent.click(calcColsTab);
const addBtn = screen.getByRole('button', {
name: /add item/i,
});
expect(addBtn).toBeInTheDocument();
userEvent.click(addBtn);
await userEvent.click(addBtn);
// newColumn (Column name) is the first textbox in the tab
await waitFor(() => {
@@ -185,7 +215,7 @@ describe('DatasourceEditor', () => {
const columnsTab = screen.getByRole('tab', {
name: /settings/i,
});
userEvent.click(columnsTab);
await userEvent.click(columnsTab);
const extraField = screen.getAllByText(/extra/i);
expect(extraField.length).toBeGreaterThan(0);
@@ -199,7 +229,7 @@ describe('DatasourceEditor', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceEditor Source Tab', () => {
beforeAll(() => {
isFeatureEnabled.mockImplementation(() => false);
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
});
beforeEach(async () => {
@@ -215,12 +245,12 @@ describe('DatasourceEditor Source Tab', () => {
});
afterAll(() => {
isFeatureEnabled.mockRestore();
(isFeatureEnabled as jest.Mock).mockRestore();
});
test('Source Tab: edit mode', async () => {
const getLockBtn = screen.getByRole('img', { name: /lock/i });
userEvent.click(getLockBtn);
await userEvent.click(getLockBtn);
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
@@ -259,7 +289,7 @@ describe('DatasourceEditor Source Tab', () => {
datasource: {
...props.datasource,
table_name: 'Vehicle Sales +',
datasourceType: 'virtual',
type: DatasourceType.Query,
sql: 'SELECT * FROM users',
},
});
@@ -19,13 +19,16 @@
import fetchMock from 'fetch-mock';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import type { DatasetObject } from 'src/features/datasets/types';
import DatasourceEditor from '..';
import { props, DATASOURCE_ENDPOINT } from './DatasourceEditor.test';
type MetricType = DatasetObject['metrics'][number];
// Optimized render function that doesn't use waitFor initially
// This helps prevent one source of the timeout
const fastRender = props =>
render(<DatasourceEditor {...props} />, {
const fastRender = (renderProps: typeof props) =>
render(<DatasourceEditor {...renderProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
});
@@ -66,13 +69,15 @@ describe('DatasourceEditor Currency Tests', () => {
const metricButton = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
// Find and expand the first metric row
// Find and expand the metric row with currency
// Metrics are sorted by ID descending, so metric with id=1 (which has currency)
// is at position 6 (last). Expand that one.
const expandToggles = await screen.findAllByLabelText(
/expand row/i,
{},
{ timeout: 5000 },
);
await userEvent.click(expandToggles[0]);
await userEvent.click(expandToggles[6]);
// Check for currency section header
const currencyHeader = await screen.findByText(
@@ -91,7 +96,7 @@ describe('DatasourceEditor Currency Tests', () => {
expect(positionSelector).toBeInTheDocument();
// Open the dropdown
userEvent.click(positionSelector);
await userEvent.click(positionSelector);
// Wait for dropdown to open and find the suffix option
const suffixOption = await waitFor(
@@ -99,7 +104,7 @@ describe('DatasourceEditor Currency Tests', () => {
// Look for 'suffix' option in the dropdown
const options = document.querySelectorAll('.ant-select-item-option');
const suffixOpt = Array.from(options).find(opt =>
opt.textContent.toLowerCase().includes('suffix'),
opt.textContent?.toLowerCase().includes('suffix'),
);
if (!suffixOpt) throw new Error('Suffix option not found');
@@ -112,7 +117,7 @@ describe('DatasourceEditor Currency Tests', () => {
propsWithCurrency.onChange.mockClear();
// Click the suffix option
userEvent.click(suffixOption);
await userEvent.click(suffixOption);
// Check if onChange was called with the expected parameters
await waitFor(
@@ -123,11 +128,12 @@ describe('DatasourceEditor Currency Tests', () => {
// More robust check for the metrics array
const metrics = callArg.metrics || [];
const updatedMetric = metrics.find(
m => m.currency && m.currency.symbolPosition === 'suffix',
(m: MetricType) =>
m.currency && m.currency.symbolPosition === 'suffix',
);
expect(updatedMetric).toBeDefined();
expect(updatedMetric.currency.symbol).toBe('USD');
expect(updatedMetric?.currency?.symbol).toBe('USD');
},
{ timeout: 5000 },
);
@@ -142,7 +148,7 @@ describe('DatasourceEditor Currency Tests', () => {
);
// Open the currency dropdown
userEvent.click(currencySymbol);
await userEvent.click(currencySymbol);
// Wait for dropdown to open and find the GBP option
const gbpOption = await waitFor(
@@ -150,7 +156,7 @@ describe('DatasourceEditor Currency Tests', () => {
// Look for 'GBP' option in the dropdown
const options = document.querySelectorAll('.ant-select-item-option');
const gbpOpt = Array.from(options).find(opt =>
opt.textContent.includes('GBP'),
opt.textContent?.includes('GBP'),
);
if (!gbpOpt) throw new Error('GBP option not found');
@@ -163,7 +169,7 @@ describe('DatasourceEditor Currency Tests', () => {
propsWithCurrency.onChange.mockClear();
// Click the GBP option
userEvent.click(gbpOption);
await userEvent.click(gbpOption);
// Verify the onChange with GBP was called
await waitFor(
@@ -174,11 +180,11 @@ describe('DatasourceEditor Currency Tests', () => {
// More robust check
const metrics = callArg.metrics || [];
const updatedMetric = metrics.find(
m => m.currency && m.currency.symbol === 'GBP',
(m: MetricType) => m.currency && m.currency.symbol === 'GBP',
);
expect(updatedMetric).toBeDefined();
expect(updatedMetric.currency.symbolPosition).toBe('suffix');
expect(updatedMetric?.currency?.symbolPosition).toBe('suffix');
},
{ timeout: 5000 },
);
@@ -42,15 +42,18 @@ describe('DatasourceEditor RTL Metrics Tests', () => {
await userEvent.click(metricButton);
const expandToggle = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggle[0]);
// Metrics are sorted by ID descending, so metric with id=1 (which has certification)
// is at position 6 (last). Expand that one.
await userEvent.click(expandToggle[6]);
// Wait for fields to appear
const certificationDetails = await screen.findByPlaceholderText(
/certification details/i,
);
expect(certificationDetails.value).toEqual('foo');
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
const warningMarkdown = await screen.findByPlaceholderText(/certified by/i);
expect(warningMarkdown.value).toEqual('someone');
expect(certificationDetails).toHaveValue('foo');
expect(certifiedBy).toHaveValue('someone');
});
test('properly updates the metric information', async () => {
@@ -71,14 +74,14 @@ describe('DatasourceEditor RTL Metrics Tests', () => {
/certification details/i,
);
await waitFor(() => {
expect(certifiedBy.value).toEqual('I am typing a new name');
expect(certifiedBy).toHaveValue('I am typing a new name');
});
await userEvent.clear(certificationDetails);
await userEvent.type(certificationDetails, 'I am typing something new');
await waitFor(() => {
expect(certificationDetails.value).toEqual('I am typing something new');
expect(certificationDetails).toHaveValue('I am typing something new');
});
});
});
+1 -5
View File
@@ -30,7 +30,6 @@ import {
} from 'src/SqlLab/actions/sqlLab';
import { RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import memoizeOne from 'memoize-one';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { Disposable } from '../models';
import { createActionListener } from '../utils';
@@ -198,13 +197,10 @@ const getActiveEditorImmutableId = () => {
return activeEditor?.immutableId;
};
// Memoized version to avoid repeated store lookups when active editor hasn't changed
const getActiveEditorId = memoizeOne(getActiveEditorImmutableId);
const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
// Capture the immutable ID of the active editor at the time the listener is created
// This ID never changes for a tab, ensuring stable event routing
const registrationImmutableId = getActiveEditorId();
const registrationImmutableId = getActiveEditorImmutableId();
return action => {
if (action.type !== actionType) return false;
@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
FeatureFlag,
SupersetClient,
isFeatureEnabled,
logging,
} from '@superset-ui/core';
import { SupersetClient, logging } from '@superset-ui/core';
import type { contributions, core } from '@apache-superset/core';
import { ExtensionContext } from '../core/models';
@@ -62,9 +57,6 @@ class ExtensionsManager {
* @throws Error if initialization fails.
*/
public async initializeExtensions(): Promise<void> {
if (!isFeatureEnabled(FeatureFlag.EnableExtensions)) {
return;
}
const response = await SupersetClient.get({
endpoint: '/api/v1/extensions/',
});
@@ -17,10 +17,21 @@
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { logging } from '@superset-ui/core';
import { logging, FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import ExtensionsStartup from './ExtensionsStartup';
import ExtensionsManager from './ExtensionsManager';
// Mock the isFeatureEnabled function
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;
const mockInitialState = {
user: { userId: 1 },
};
@@ -36,12 +47,26 @@ beforeEach(() => {
// Clear any existing ExtensionsManager instance
(ExtensionsManager as any).instance = undefined;
// Reset feature flag mock to enabled by default
mockIsFeatureEnabled.mockReset();
mockIsFeatureEnabled.mockReturnValue(true);
// Setup fetch mocks for API calls
fetchMock.restore();
fetchMock.get('glob:*/api/v1/extensions/', {
result: [],
});
});
afterEach(() => {
// Clean up after each test
delete (window as any).superset;
(ExtensionsManager as any).instance = undefined;
// Reset mocks
mockIsFeatureEnabled.mockReset();
fetchMock.restore();
});
test('renders without crashing', () => {
@@ -55,6 +80,12 @@ test('renders without crashing', () => {
});
test('sets up global superset object when user is logged in', async () => {
// Mock initializeExtensions to avoid API calls in this test
const manager = ExtensionsManager.getInstance();
const initializeSpy = jest
.spyOn(manager, 'initializeExtensions')
.mockImplementation(() => Promise.resolve());
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
@@ -70,6 +101,8 @@ test('sets up global superset object when user is logged in', async () => {
expect((window as any).superset.extensions).toBeDefined();
expect((window as any).superset.sqlLab).toBeDefined();
});
initializeSpy.mockRestore();
});
test('does not set up global superset object when user is not logged in', async () => {
@@ -85,18 +118,26 @@ test('does not set up global superset object when user is not logged in', async
});
test('initializes ExtensionsManager when user is logged in', async () => {
// Mock initializeExtensions to avoid API calls, but track that it was called
const manager = ExtensionsManager.getInstance();
const initializeSpy = jest
.spyOn(manager, 'initializeExtensions')
.mockImplementation(() => Promise.resolve());
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
await waitFor(() => {
// Verify ExtensionsManager has been initialized by checking if it has extensions loaded
const manager = ExtensionsManager.getInstance();
// Verify ExtensionsManager initialization was called
expect(initializeSpy).toHaveBeenCalledTimes(1);
// The manager should exist and be ready to use
expect(manager).toBeDefined();
expect(manager.getExtensions).toBeDefined();
});
initializeSpy.mockRestore();
});
test('does not initialize ExtensionsManager when user is not logged in', async () => {
@@ -114,61 +155,6 @@ test('does not initialize ExtensionsManager when user is not logged in', async (
});
});
test('handles ExtensionsManager initialization errors gracefully', async () => {
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
// Mock the initializeExtensions method to throw an error
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
ExtensionsManager.prototype.initializeExtensions = jest
.fn()
.mockImplementation(() => {
throw new Error('Test initialization error');
});
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
await waitFor(() => {
// Verify error was logged
expect(errorSpy).toHaveBeenCalledWith(
'Error setting up extensions:',
expect.any(Error),
);
});
// Restore original method
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
errorSpy.mockRestore();
});
test('logs success message when ExtensionsManager initializes successfully', async () => {
const infoSpy = jest.spyOn(logging, 'info').mockImplementation();
// Mock the initializeExtensions method to succeed
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
ExtensionsManager.prototype.initializeExtensions = jest
.fn()
.mockImplementation(() => Promise.resolve());
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
await waitFor(() => {
// Verify success message was logged
expect(infoSpy).toHaveBeenCalledWith(
'Extensions initialized successfully.',
);
});
// Restore original method
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
infoSpy.mockRestore();
});
test('only initializes once even with multiple renders', async () => {
// Track calls to the manager's public API
const manager = ExtensionsManager.getInstance();
@@ -203,3 +189,106 @@ test('only initializes once even with multiple renders', async () => {
// Restore original method
manager.initializeExtensions = originalInitialize;
});
test('initializes ExtensionsManager and logs success when EnableExtensions feature flag is enabled', async () => {
// Ensure feature flag is enabled
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.EnableExtensions,
);
const infoSpy = jest.spyOn(logging, 'info').mockImplementation();
// Mock the initializeExtensions method to succeed
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
ExtensionsManager.prototype.initializeExtensions = jest
.fn()
.mockImplementation(() => Promise.resolve());
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
await waitFor(() => {
// Verify feature flag was checked
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
FeatureFlag.EnableExtensions,
);
// Verify initialization was called
expect(
ExtensionsManager.prototype.initializeExtensions,
).toHaveBeenCalledTimes(1);
// Verify success message was logged
expect(infoSpy).toHaveBeenCalledWith(
'Extensions initialized successfully.',
);
});
// Restore original method
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
infoSpy.mockRestore();
});
test('does not initialize ExtensionsManager when EnableExtensions feature flag is disabled', async () => {
// Disable the feature flag
mockIsFeatureEnabled.mockReturnValue(false);
const manager = ExtensionsManager.getInstance();
const initializeSpy = jest
.spyOn(manager, 'initializeExtensions')
.mockImplementation();
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
await waitFor(() => {
// Verify feature flag was checked
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
FeatureFlag.EnableExtensions,
);
// Verify the global superset object is still set up
expect((window as any).superset).toBeDefined();
// But extensions should not be initialized
expect(initializeSpy).not.toHaveBeenCalled();
});
initializeSpy.mockRestore();
});
test('logs error when ExtensionsManager initialization fails', async () => {
// Ensure feature flag is enabled
mockIsFeatureEnabled.mockReturnValue(true);
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
// Mock the initializeExtensions method to throw an error
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
ExtensionsManager.prototype.initializeExtensions = jest
.fn()
.mockImplementation(() => {
throw new Error('Test initialization error');
});
render(<ExtensionsStartup />, {
useRedux: true,
initialState: mockInitialState,
});
await waitFor(() => {
// Verify feature flag was checked
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
FeatureFlag.EnableExtensions,
);
// Verify error was logged
expect(errorSpy).toHaveBeenCalledWith(
'Error setting up extensions:',
expect.any(Error),
);
});
// Restore original method
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
errorSpy.mockRestore();
});
@@ -19,7 +19,7 @@
import { useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { logging } from '@superset-ui/core';
import { FeatureFlag, isFeatureEnabled, logging } from '@superset-ui/core';
import {
authentication,
core,
@@ -75,14 +75,15 @@ const ExtensionsStartup = () => {
};
// Initialize extensions
try {
ExtensionsManager.getInstance().initializeExtensions();
logging.info('Extensions initialized successfully.');
} catch (error) {
logging.error('Error setting up extensions:', error);
} finally {
setInitialized(true);
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
try {
ExtensionsManager.getInstance().initializeExtensions();
logging.info('Extensions initialized successfully.');
} catch (error) {
logging.error('Error setting up extensions:', error);
}
}
setInitialized(true);
}, [initialized, userId]);
return null;
@@ -104,22 +104,14 @@ export const useThemeMenuItems = ({
: []),
];
const children: MenuItem[] = [
{
type: 'group' as const,
label: t('Theme'),
key: 'theme-group',
children: themeOptions,
},
];
// Add clear settings option only when there's a local theme active
// Add clear settings option to theme options if there's a local theme active
const themeGroupOptions = [...themeOptions];
if (onClearLocalSettings && hasLocalOverride) {
children.push({
themeGroupOptions.push({
type: 'divider' as const,
key: 'theme-divider',
});
children.push({
themeGroupOptions.push({
key: 'clear-local',
label: (
<>
@@ -130,6 +122,15 @@ export const useThemeMenuItems = ({
});
}
const children: MenuItem[] = [
{
type: 'group' as const,
label: t('Theme'),
key: 'theme-group',
children: themeGroupOptions,
},
];
return {
key: 'theme-sub-menu',
label: selectedThemeModeIcon,
+24 -4
View File
@@ -27,7 +27,7 @@ import {
Typography,
Icons,
} from '@superset-ui/core/components';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { capitalize } from 'lodash/fp';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { useDispatch } from 'react-redux';
@@ -82,6 +82,26 @@ export default function Login() {
const dispatch = useDispatch();
const bootstrapData = getBootstrapData();
const nextUrl = useMemo(() => {
try {
const params = new URLSearchParams(window.location.search);
return params.get('next') || '';
} catch (_error) {
return '';
}
}, []);
const loginEndpoint = useMemo(
() => (nextUrl ? `/login/?next=${encodeURIComponent(nextUrl)}` : '/login/'),
[nextUrl],
);
const buildProviderLoginUrl = (providerName: string) => {
const base = `/login/${providerName}`;
return nextUrl
? `${base}${base.includes('?') ? '&' : '?'}next=${encodeURIComponent(nextUrl)}`
: base;
};
const authType: AuthType = bootstrapData.common.conf.AUTH_TYPE;
const providers: Provider[] = bootstrapData.common.conf.AUTH_PROVIDERS;
@@ -109,7 +129,7 @@ export default function Login() {
sessionStorage.setItem('login_attempted', 'true');
// Use standard form submission for Flask-AppBuilder compatibility
SupersetClient.postForm('/login/', values, '');
SupersetClient.postForm(loginEndpoint, values, '');
};
const getAuthIconElement = (
@@ -146,7 +166,7 @@ export default function Login() {
{providers.map((provider: OIDProvider) => (
<Form.Item<LoginForm>>
<Button
href={`/login/${provider.name}`}
href={buildProviderLoginUrl(provider.name)}
block
iconPosition="start"
icon={getAuthIconElement(provider.name)}
@@ -164,7 +184,7 @@ export default function Login() {
{providers.map((provider: OAuthProvider) => (
<Form.Item<LoginForm>>
<Button
href={`/login/${provider.name}`}
href={buildProviderLoginUrl(provider.name)}
block
iconPosition="start"
icon={getAuthIconElement(provider.name)}
+120
View File
@@ -276,3 +276,123 @@ test('handles various resource types', async () => {
expect(doneMock).toHaveBeenCalledTimes(5);
});
test('handles network errors and logs them', async () => {
const networkError = new Error('Network request failed');
(SupersetClient.get as jest.Mock).mockRejectedValue(networkError);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('Network request failed');
expect(logging.error).toHaveBeenCalledWith(
'Resource export failed:',
networkError,
);
expect(doneMock).toHaveBeenCalled();
});
test('handles 404 errors when resource not found', async () => {
const notFoundError = new Error('Not found');
(SupersetClient.get as jest.Mock).mockRejectedValue(notFoundError);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [999], doneMock),
).rejects.toThrow('Not found');
expect(doneMock).toHaveBeenCalled();
});
test('handles empty response from server', async () => {
const emptyBlob = new Blob([], { type: 'application/zip' });
mockResponse = {
headers: new Headers({
'Content-Disposition': 'attachment; filename="empty.zip"',
}),
blob: jest.fn().mockResolvedValue(emptyBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(window.URL.createObjectURL).toHaveBeenCalledWith(emptyBlob);
expect(doneMock).toHaveBeenCalled();
});
test('cleans up blob URL even when download fails', async () => {
const mockAnchor = document.createElement('a');
mockAnchor.click = jest.fn().mockImplementation(() => {
throw new Error('Click failed');
});
createElementSpy.mockRestore();
createElementSpy = jest
.spyOn(document, 'createElement')
.mockReturnValue(mockAnchor);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('Click failed');
// Verify cleanup still happens
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
expect(doneMock).toHaveBeenCalled();
});
test('handles malformed Content-Disposition header', async () => {
mockResponse = {
headers: new Headers({
'Content-Disposition': 'not-a-valid-header',
}),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
throw new Error('Parse error');
});
const doneMock = jest.fn();
await handleResourceExport('dataset', [5], doneMock);
// Should fall back to default filename
const anchor = document.createElement('a');
expect(anchor.download).toBe('dataset_export.zip');
expect(logging.warn).toHaveBeenCalledWith(
'Failed to parse Content-Disposition header:',
expect.any(Error),
);
});
test('handles missing headers object', async () => {
mockResponse = {
headers: new Headers(),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('chart', [7], doneMock);
const anchor = document.createElement('a');
expect(anchor.download).toBe('chart_export.zip');
expect(doneMock).toHaveBeenCalled();
});
test('handles export with empty IDs array', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dashboard/export/?q=!()',
}),
);
});
+186 -186
View File
@@ -25,22 +25,22 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.20",
"@types/node": "^24.7.1",
"@types/node": "^24.8.1",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.42.0",
"eslint": "^9.37.0",
"@typescript-eslint/parser": "^8.46.1",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.4.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.4.4",
"ts-jest": "^29.4.5",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0"
"typescript-eslint": "^8.46.1"
},
"engines": {
"node": "^20.19.4",
@@ -750,12 +750,13 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.6",
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
@@ -764,9 +765,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -847,9 +848,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.37.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -860,10 +861,11 @@
}
},
"node_modules/@eslint/object-schema": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
@@ -1857,9 +1859,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.7.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1909,17 +1911,17 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
"integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/type-utils": "8.46.0",
"@typescript-eslint/utils": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/type-utils": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -1933,7 +1935,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.46.0",
"@typescript-eslint/parser": "^8.46.1",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
@@ -1949,16 +1951,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"debug": "^4.3.4"
},
"engines": {
@@ -1974,14 +1976,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
"integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.46.0",
"@typescript-eslint/types": "^8.46.0",
"@typescript-eslint/tsconfig-utils": "^8.46.1",
"@typescript-eslint/types": "^8.46.1",
"debug": "^4.3.4"
},
"engines": {
@@ -1996,14 +1998,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
"integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0"
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2014,9 +2016,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
"integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2031,15 +2033,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
"integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0",
"@typescript-eslint/utils": "8.46.0",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -2056,9 +2058,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
"integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2070,16 +2072,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
"integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.46.0",
"@typescript-eslint/tsconfig-utils": "8.46.0",
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0",
"@typescript-eslint/project-service": "8.46.1",
"@typescript-eslint/tsconfig-utils": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -2125,16 +2127,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
"integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0"
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2149,13 +2151,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
"integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/types": "8.46.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -2839,25 +2841,24 @@
}
},
"node_modules/eslint": {
"version": "9.37.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.4.0",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.1",
"@eslint/core": "^0.16.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.37.0",
"@eslint/js": "9.38.0",
"@eslint/plugin-kit": "^0.4.0",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
@@ -5849,9 +5850,9 @@
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -6122,9 +6123,9 @@
}
},
"node_modules/ts-jest": {
"version": "29.4.4",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz",
"integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==",
"version": "29.4.5",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
"integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6134,7 +6135,7 @@
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.2",
"semver": "^7.7.3",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
@@ -6305,16 +6306,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
"integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.46.0",
"@typescript-eslint/parser": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0",
"@typescript-eslint/utils": "8.46.0"
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7163,20 +7164,20 @@
"dev": true
},
"@eslint/config-array": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"requires": {
"@eslint/object-schema": "^2.1.6",
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
}
},
"@eslint/config-helpers": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
"dev": true,
"requires": {
"@eslint/core": "^0.16.0"
@@ -7232,15 +7233,15 @@
}
},
"@eslint/js": {
"version": "9.37.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
"dev": true
},
"@eslint/object-schema": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
"dev": true
},
"@eslint/plugin-kit": {
@@ -8056,9 +8057,9 @@
"dev": true
},
"@types/node": {
"version": "24.7.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"dev": true,
"requires": {
"undici-types": "~7.14.0"
@@ -8106,16 +8107,16 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
"integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/type-utils": "8.46.0",
"@typescript-eslint/utils": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/type-utils": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -8131,75 +8132,75 @@
}
},
"@typescript-eslint/parser": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0",
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"debug": "^4.3.4"
}
},
"@typescript-eslint/project-service": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
"integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.46.0",
"@typescript-eslint/types": "^8.46.0",
"@typescript-eslint/tsconfig-utils": "^8.46.1",
"@typescript-eslint/types": "^8.46.1",
"debug": "^4.3.4"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
"integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0"
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
"integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
"dev": true,
"requires": {}
},
"@typescript-eslint/type-utils": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
"integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0",
"@typescript-eslint/utils": "8.46.0",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
}
},
"@typescript-eslint/types": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
"integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
"integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.46.0",
"@typescript-eslint/tsconfig-utils": "8.46.0",
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/visitor-keys": "8.46.0",
"@typescript-eslint/project-service": "8.46.1",
"@typescript-eslint/tsconfig-utils": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/visitor-keys": "8.46.1",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -8229,24 +8230,24 @@
}
},
"@typescript-eslint/utils": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
"integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0"
"@typescript-eslint/scope-manager": "8.46.1",
"@typescript-eslint/types": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
"integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.46.0",
"@typescript-eslint/types": "8.46.1",
"eslint-visitor-keys": "^4.2.1"
},
"dependencies": {
@@ -8742,24 +8743,23 @@
"dev": true
},
"eslint": {
"version": "9.37.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.4.0",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.1",
"@eslint/core": "^0.16.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.37.0",
"@eslint/js": "9.38.0",
"@eslint/plugin-kit": "^0.4.0",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"@types/json-schema": "^7.0.15",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.6",
@@ -10970,9 +10970,9 @@
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
},
"semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="
},
"shebang-command": {
"version": "2.0.0",
@@ -11169,9 +11169,9 @@
"requires": {}
},
"ts-jest": {
"version": "29.4.4",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz",
"integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==",
"version": "29.4.5",
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
"integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==",
"dev": true,
"requires": {
"bs-logger": "^0.2.6",
@@ -11180,7 +11180,7 @@
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
"semver": "^7.7.2",
"semver": "^7.7.3",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
}
@@ -11259,15 +11259,15 @@
"dev": true
},
"typescript-eslint": {
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
"integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
"version": "8.46.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
"dev": true,
"requires": {
"@typescript-eslint/eslint-plugin": "8.46.0",
"@typescript-eslint/parser": "8.46.0",
"@typescript-eslint/typescript-estree": "8.46.0",
"@typescript-eslint/utils": "8.46.0"
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"@typescript-eslint/typescript-estree": "8.46.1",
"@typescript-eslint/utils": "8.46.1"
}
},
"uglify-js": {
+5 -5
View File
@@ -33,22 +33,22 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.20",
"@types/node": "^24.7.1",
"@types/node": "^24.8.1",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.42.0",
"eslint": "^9.37.0",
"@typescript-eslint/parser": "^8.46.1",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.4.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.4.4",
"ts-jest": "^29.4.5",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.0"
"typescript-eslint": "^8.46.1"
},
"engines": {
"node": "^20.19.4",
@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,77 @@
# 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.
"""Create semantic layer command."""
from __future__ import annotations
from functools import partial
from typing import Any
from flask_appbuilder.models.sqla import Model
from marshmallow.validate import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerExistsValidationError,
SemanticLayerInvalidError,
SemanticLayerRequiredFieldValidationError,
)
from superset.daos.semantic_layer import SemanticLayerDAO
from superset.utils.decorators import on_error, transaction
class CreateSemanticLayerCommand(BaseCommand):
"""Command to create a semantic layer."""
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@transaction(on_error=partial(on_error, reraise=SemanticLayerCreateFailedError))
def run(self) -> Model:
"""
Create a semantic layer.
:return: The created semantic layer
"""
self.validate()
return SemanticLayerDAO.create(attributes=self._properties)
def validate(self) -> None:
"""
Validate the semantic layer data.
:raises SemanticLayerInvalidError: If validation fails
"""
exceptions: list[ValidationError] = []
# Validate required fields
if not self._properties.get("name"):
exceptions.append(SemanticLayerRequiredFieldValidationError("name"))
if not self._properties.get("type"):
exceptions.append(SemanticLayerRequiredFieldValidationError("type"))
# Validate uniqueness
name = self._properties.get("name")
if name and not SemanticLayerDAO.validate_uniqueness(name):
exceptions.append(SemanticLayerExistsValidationError())
if exceptions:
exception = SemanticLayerInvalidError()
exception.extend(exceptions)
raise exception
@@ -0,0 +1,59 @@
# 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.
"""Delete semantic layer command."""
from __future__ import annotations
from functools import partial
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerDeleteFailedError,
SemanticLayerNotFoundError,
)
from superset.daos.semantic_layer import SemanticLayerDAO
from superset.semantic_layers.models import SemanticLayer
from superset.utils.decorators import on_error, transaction
class DeleteSemanticLayerCommand(BaseCommand):
"""Command to delete a semantic layer."""
def __init__(self, model_id: str):
self._model_id = model_id
self._model: SemanticLayer | None = None
@transaction(on_error=partial(on_error, reraise=SemanticLayerDeleteFailedError))
def run(self) -> None:
"""
Delete a semantic layer.
Semantic views will be cascade deleted.
"""
self.validate()
assert self._model
SemanticLayerDAO.delete([self._model])
def validate(self) -> None:
"""
Validate the semantic layer deletion.
:raises SemanticLayerNotFoundError: If semantic layer not found
"""
self._model = SemanticLayerDAO.find_by_id(self._model_id)
if not self._model:
raise SemanticLayerNotFoundError()
@@ -0,0 +1,76 @@
# 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.
"""Exceptions for semantic layer commands."""
from flask_babel import lazy_gettext as _
from marshmallow.validate import ValidationError
from superset.commands.exceptions import (
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ObjectNotFoundError,
UpdateFailedError,
)
class SemanticLayerInvalidError(CommandInvalidError):
"""Semantic layer parameters are invalid."""
message = _("Semantic layer parameters are invalid.")
class SemanticLayerNotFoundError(ObjectNotFoundError):
"""Semantic layer not found."""
def __init__(self) -> None:
super().__init__("Semantic layer", None)
class SemanticLayerCreateFailedError(CreateFailedError):
"""Semantic layer could not be created."""
message = _("Semantic layer could not be created.")
class SemanticLayerUpdateFailedError(UpdateFailedError):
"""Semantic layer could not be updated."""
message = _("Semantic layer could not be updated.")
class SemanticLayerDeleteFailedError(DeleteFailedError):
"""Semantic layer could not be deleted."""
message = _("Semantic layer could not be deleted.")
class SemanticLayerRequiredFieldValidationError(ValidationError):
"""Required field validation error."""
def __init__(self, field_name: str) -> None:
super().__init__([_("Field is required")], field_name=field_name)
class SemanticLayerExistsValidationError(ValidationError):
"""Semantic layer already exists validation error."""
def __init__(self) -> None:
super().__init__(
[_("A semantic layer with this name already exists")],
field_name="name",
)
@@ -0,0 +1,81 @@
# 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.
"""Update semantic layer command."""
from __future__ import annotations
from functools import partial
from typing import Any
from flask_appbuilder.models.sqla import Model
from marshmallow.validate import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerExistsValidationError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
)
from superset.daos.semantic_layer import SemanticLayerDAO
from superset.semantic_layers.models import SemanticLayer
from superset.utils.decorators import on_error, transaction
class UpdateSemanticLayerCommand(BaseCommand):
"""Command to update a semantic layer."""
def __init__(self, model_id: str, data: dict[str, Any]):
self._properties = data.copy()
self._model_id = model_id
self._model: SemanticLayer | None = None
@transaction(on_error=partial(on_error, reraise=SemanticLayerUpdateFailedError))
def run(self) -> Model:
"""
Update a semantic layer.
:return: The updated semantic layer
"""
self.validate()
assert self._model
return SemanticLayerDAO.update(self._model, self._properties)
def validate(self) -> None:
"""
Validate the semantic layer update.
:raises SemanticLayerNotFoundError: If semantic layer not found
:raises SemanticLayerInvalidError: If validation fails
"""
exceptions: list[ValidationError] = []
# Find the model
self._model = SemanticLayerDAO.find_by_id(self._model_id)
if not self._model:
raise SemanticLayerNotFoundError()
# Validate uniqueness if name is being changed
if name := self._properties.get("name"):
if not SemanticLayerDAO.validate_update_uniqueness(self._model_id, name):
exceptions.append(SemanticLayerExistsValidationError())
if exceptions:
exception = SemanticLayerInvalidError()
exception.extend(exceptions)
raise exception
@@ -0,0 +1,16 @@
# 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.
+87
View File
@@ -0,0 +1,87 @@
# 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.
"""Create semantic view command."""
from __future__ import annotations
from functools import partial
from typing import Any
from flask_appbuilder.models.sqla import Model
from marshmallow.validate import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
from superset.commands.semantic_view.exceptions import (
SemanticViewCreateFailedError,
SemanticViewExistsValidationError,
SemanticViewInvalidError,
SemanticViewRequiredFieldValidationError,
)
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.utils.decorators import on_error, transaction
class CreateSemanticViewCommand(BaseCommand):
"""Command to create a semantic view."""
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@transaction(on_error=partial(on_error, reraise=SemanticViewCreateFailedError))
def run(self) -> Model:
"""
Create a semantic view.
:return: The created semantic view
"""
self.validate()
return SemanticViewDAO.create(attributes=self._properties)
def validate(self) -> None:
"""
Validate the semantic view data.
:raises SemanticViewInvalidError: If validation fails
:raises SemanticLayerNotFoundError: If semantic layer not found
"""
exceptions: list[ValidationError] = []
# Validate required fields
if not self._properties.get("name"):
exceptions.append(SemanticViewRequiredFieldValidationError("name"))
layer_uuid = self._properties.get("semantic_layer_uuid")
if not layer_uuid:
exceptions.append(
SemanticViewRequiredFieldValidationError("semantic_layer_uuid")
)
else:
# Validate semantic layer exists
semantic_layer = SemanticLayerDAO.find_by_id(layer_uuid)
if not semantic_layer:
raise SemanticLayerNotFoundError()
# Validate uniqueness within semantic layer
name = self._properties.get("name")
if name and not SemanticViewDAO.validate_uniqueness(name, layer_uuid):
exceptions.append(SemanticViewExistsValidationError())
if exceptions:
exception = SemanticViewInvalidError()
exception.extend(exceptions)
raise exception
+55
View File
@@ -0,0 +1,55 @@
# 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.
"""Delete semantic view command."""
from __future__ import annotations
from functools import partial
from superset.commands.base import BaseCommand
from superset.commands.semantic_view.exceptions import (
SemanticViewDeleteFailedError,
SemanticViewNotFoundError,
)
from superset.daos.semantic_layer import SemanticViewDAO
from superset.semantic_layers.models import SemanticView
from superset.utils.decorators import on_error, transaction
class DeleteSemanticViewCommand(BaseCommand):
"""Command to delete a semantic view."""
def __init__(self, model_id: str):
self._model_id = model_id
self._model: SemanticView | None = None
@transaction(on_error=partial(on_error, reraise=SemanticViewDeleteFailedError))
def run(self) -> None:
"""Delete a semantic view."""
self.validate()
assert self._model
SemanticViewDAO.delete([self._model])
def validate(self) -> None:
"""
Validate the semantic view deletion.
:raises SemanticViewNotFoundError: If semantic view not found
"""
self._model = SemanticViewDAO.find_by_id(self._model_id)
if not self._model:
raise SemanticViewNotFoundError()
@@ -0,0 +1,76 @@
# 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.
"""Exceptions for semantic view commands."""
from flask_babel import lazy_gettext as _
from marshmallow.validate import ValidationError
from superset.commands.exceptions import (
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ObjectNotFoundError,
UpdateFailedError,
)
class SemanticViewInvalidError(CommandInvalidError):
"""Semantic view parameters are invalid."""
message = _("Semantic view parameters are invalid.")
class SemanticViewNotFoundError(ObjectNotFoundError):
"""Semantic view not found."""
def __init__(self) -> None:
super().__init__("Semantic view", None)
class SemanticViewCreateFailedError(CreateFailedError):
"""Semantic view could not be created."""
message = _("Semantic view could not be created.")
class SemanticViewUpdateFailedError(UpdateFailedError):
"""Semantic view could not be updated."""
message = _("Semantic view could not be updated.")
class SemanticViewDeleteFailedError(DeleteFailedError):
"""Semantic view could not be deleted."""
message = _("Semantic view could not be deleted.")
class SemanticViewRequiredFieldValidationError(ValidationError):
"""Required field validation error."""
def __init__(self, field_name: str) -> None:
super().__init__([_("Field is required")], field_name=field_name)
class SemanticViewExistsValidationError(ValidationError):
"""Semantic view already exists validation error."""
def __init__(self) -> None:
super().__init__(
[_("A semantic view with this name already exists in this semantic layer")],
field_name="name",
)
+83
View File
@@ -0,0 +1,83 @@
# 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.
"""Update semantic view command."""
from __future__ import annotations
from functools import partial
from typing import Any
from flask_appbuilder.models.sqla import Model
from marshmallow.validate import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.semantic_view.exceptions import (
SemanticViewExistsValidationError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
SemanticViewUpdateFailedError,
)
from superset.daos.semantic_layer import SemanticViewDAO
from superset.semantic_layers.models import SemanticView
from superset.utils.decorators import on_error, transaction
class UpdateSemanticViewCommand(BaseCommand):
"""Command to update a semantic view."""
def __init__(self, model_id: str, data: dict[str, Any]):
self._properties = data.copy()
self._model_id = model_id
self._model: SemanticView | None = None
@transaction(on_error=partial(on_error, reraise=SemanticViewUpdateFailedError))
def run(self) -> Model:
"""
Update a semantic view.
:return: The updated semantic view
"""
self.validate()
assert self._model
return SemanticViewDAO.update(self._model, self._properties)
def validate(self) -> None:
"""
Validate the semantic view update.
:raises SemanticViewNotFoundError: If semantic view not found
:raises SemanticViewInvalidError: If validation fails
"""
exceptions: list[ValidationError] = []
# Find the model
self._model = SemanticViewDAO.find_by_id(self._model_id)
if not self._model:
raise SemanticViewNotFoundError()
# Validate uniqueness if name is being changed
if name := self._properties.get("name"):
if not SemanticViewDAO.validate_update_uniqueness(
self._model_id, name, self._model.semantic_layer_uuid
):
exceptions.append(SemanticViewExistsValidationError())
if exceptions:
exception = SemanticViewInvalidError()
exception.extend(exceptions)
raise exception
+1 -1
View File
@@ -972,7 +972,7 @@ CORS_OPTIONS: dict[Any, Any] = {
# Disabling this option is not recommended for security reasons. If you wish to allow
# valid safe elements that are not included in the default sanitization schema, use the
# HTML_SANITIZATION_SCHEMA_EXTENSIONS configuration.
HTML_SANITIZATION = False
HTML_SANITIZATION = True
# Use this configuration to extend the HTML sanitization schema.
# By default we use the GitHub schema defined in
+152
View File
@@ -0,0 +1,152 @@
# 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.
"""DAOs for semantic layer models."""
from __future__ import annotations
from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.semantic_layers.models import SemanticLayer, SemanticView
class SemanticLayerDAO(BaseDAO[SemanticLayer]):
"""
Data Access Object for SemanticLayer model.
"""
@staticmethod
def validate_uniqueness(name: str) -> bool:
"""
Validate that semantic layer name is unique.
:param name: Semantic layer name
:return: True if name is unique, False otherwise
"""
query = db.session.query(SemanticLayer).filter(SemanticLayer.name == name)
return not db.session.query(query.exists()).scalar()
@staticmethod
def validate_update_uniqueness(layer_uuid: str, name: str) -> bool:
"""
Validate that semantic layer name is unique for updates.
:param layer_uuid: UUID of the semantic layer being updated
:param name: New name to validate
:return: True if name is unique, False otherwise
"""
query = db.session.query(SemanticLayer).filter(
SemanticLayer.name == name,
SemanticLayer.uuid != layer_uuid,
)
return not db.session.query(query.exists()).scalar()
@staticmethod
def find_by_name(name: str) -> SemanticLayer | None:
"""
Find semantic layer by name.
:param name: Semantic layer name
:return: SemanticLayer instance or None
"""
return (
db.session.query(SemanticLayer)
.filter(SemanticLayer.name == name)
.one_or_none()
)
@classmethod
def get_semantic_views(cls, layer_uuid: str) -> list[SemanticView]:
"""
Get all semantic views for a semantic layer.
:param layer_uuid: UUID of the semantic layer
:return: List of SemanticView instances
"""
return (
db.session.query(SemanticView)
.filter(SemanticView.semantic_layer_uuid == layer_uuid)
.all()
)
class SemanticViewDAO(BaseDAO[SemanticView]):
"""Data Access Object for SemanticView model."""
@staticmethod
def find_by_semantic_layer(layer_uuid: str) -> list[SemanticView]:
"""
Find all views for a semantic layer.
:param layer_uuid: UUID of the semantic layer
:return: List of SemanticView instances
"""
return (
db.session.query(SemanticView)
.filter(SemanticView.semantic_layer_uuid == layer_uuid)
.all()
)
@staticmethod
def validate_uniqueness(name: str, layer_uuid: str) -> bool:
"""
Validate that view name is unique within semantic layer.
:param name: View name
:param layer_uuid: UUID of the semantic layer
:return: True if name is unique within layer, False otherwise
"""
query = db.session.query(SemanticView).filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
)
return not db.session.query(query.exists()).scalar()
@staticmethod
def validate_update_uniqueness(view_uuid: str, name: str, layer_uuid: str) -> bool:
"""
Validate that view name is unique within semantic layer for updates.
:param view_uuid: UUID of the view being updated
:param name: New name to validate
:param layer_uuid: UUID of the semantic layer
:return: True if name is unique within layer, False otherwise
"""
query = db.session.query(SemanticView).filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
SemanticView.uuid != view_uuid,
)
return not db.session.query(query.exists()).scalar()
@staticmethod
def find_by_name(name: str, layer_uuid: str) -> SemanticView | None:
"""
Find semantic view by name within a semantic layer.
:param name: View name
:param layer_uuid: UUID of the semantic layer
:return: SemanticView instance or None
"""
return (
db.session.query(SemanticView)
.filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
)
.one_or_none()
)
+16
View File
@@ -0,0 +1,16 @@
# 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.
+248
View File
@@ -0,0 +1,248 @@
# 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.
"""
Base protocol for explorable data sources in Superset.
An "explorable" is any data source that can be explored to create charts,
including SQL datasets, saved queries, and semantic layer views.
"""
from __future__ import annotations
from collections.abc import Hashable
from datetime import datetime
from typing import Any, Protocol, runtime_checkable
from superset.common.query_object import QueryObject
from superset.models.helpers import QueryResult
from superset.superset_typing import QueryObjectDict
@runtime_checkable
class Explorable(Protocol):
"""
Protocol for objects that can be explored to create charts.
This protocol defines the minimal interface required for a data source
to be visualizable in Superset. It is implemented by:
- BaseDatasource (SQL datasets and queries)
- SemanticView (semantic layer views)
- Future: Other data source types
The protocol focuses on the essential methods and properties needed
for query execution, caching, and security.
"""
# =========================================================================
# Core Query Interface
# =========================================================================
def get_query_result(self, query_object: QueryObject) -> QueryResult:
"""
Execute a query and return results.
This is the primary method for data retrieval. It takes a query
object describing what data to fetch (columns, metrics, filters, time range,
etc.) and returns a QueryResult containing a pandas DataFrame with the results.
:param query_obj: QueryObject describing the query
:return: QueryResult containing:
- df: pandas DataFrame with query results
- query: string representation of the executed query
- duration: query execution time
- status: QueryStatus (SUCCESS/FAILED)
- error_message: error details if query failed
"""
def get_query_str(self, query_obj: QueryObjectDict) -> str:
"""
Get the query string without executing.
Returns a string representation of the query that would be executed
for the given query object. This is used for display in the UI
and debugging.
:param query_obj: Dictionary describing the query
:return: String representation of the query (SQL, GraphQL, etc.)
"""
# =========================================================================
# Identity & Metadata
# =========================================================================
@property
def uid(self) -> str:
"""
Unique identifier for this explorable.
Used as part of cache keys and for tracking. Should be stable
across application restarts but change when the explorable's
data or structure changes.
Format convention: "{type}_{id}" (e.g., "table_123", "semantic_view_abc")
:return: Unique identifier string
"""
@property
def type(self) -> str:
"""
Type discriminator for this explorable.
Identifies the kind of data source (e.g., 'table', 'query', 'semantic_view').
Used for routing and type-specific behavior.
:return: Type identifier string
"""
@property
def columns(self) -> list[Any]:
"""
List of column metadata objects.
Each object should provide at minimum:
- column_name: str - the column's name
- type: str - the column's data type
- is_dttm: bool - whether it's a datetime column
Used for validation, autocomplete, and query building.
:return: List of column metadata objects
"""
@property
def column_names(self) -> list[str]:
"""
List of available column names.
A simple list of all column names in the explorable.
Used for quick validation and filtering.
:return: List of column name strings
"""
@property
def data(self) -> dict[str, Any]:
"""
Full metadata representation sent to the frontend.
This property returns a dictionary containing all the metadata
needed by the Explore UI, including columns, metrics, and
other configuration.
Required keys in the returned dictionary:
- id: unique identifier (int or str)
- uid: unique string identifier
- name: display name
- type: explorable type ('table', 'query', 'semantic_view', etc.)
- columns: list of column metadata dicts (with column_name, type, etc.)
- metrics: list of metric metadata dicts (with metric_name, expression, etc.)
- database: database metadata dict (with id, backend, etc.)
Optional keys:
- description: human-readable description
- schema: schema name (if applicable)
- catalog: catalog name (if applicable)
- cache_timeout: default cache timeout
- offset: timezone offset
- owners: list of owner IDs
- verbose_map: dict mapping column/metric names to display names
:return: Dictionary with complete explorable metadata
"""
# =========================================================================
# Caching
# =========================================================================
@property
def cache_timeout(self) -> int | None:
"""
Default cache timeout in seconds.
Determines how long query results should be cached.
Returns None to use the system default cache timeout.
:return: Cache timeout in seconds, or None for system default
"""
@property
def changed_on(self) -> datetime | None:
"""
Last modification timestamp.
Used for cache invalidation - when this changes, cached
results for this explorable become invalid.
:return: Datetime of last modification, or None
"""
def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]:
"""
Additional cache key components specific to this explorable.
Provides explorable-specific values to include in cache keys.
Used to ensure cache invalidation when the explorable's
underlying data or configuration changes in ways not captured
by uid or changed_on.
:param query_obj: The query being executed
:return: List of additional hashable values for cache key
"""
# =========================================================================
# Security
# =========================================================================
@property
def perm(self) -> str:
"""
Permission string for this explorable.
Used by the security manager to check if a user has access
to this data source. Format depends on the explorable type
(e.g., "[database].[schema].[table]" for SQL tables).
:return: Permission identifier string
"""
@property
def schema_perm(self) -> str | None:
"""
Schema-level permission string.
Optional permission string for schema-level access control.
Some explorables don't have a schema concept and can return None.
:return: Schema permission string, or None
"""
# =========================================================================
# Time/Date Handling
# =========================================================================
@property
def offset(self) -> int:
"""
Timezone offset for datetime columns.
Used to normalize datetime values to the user's timezone.
Returns 0 for UTC, or an offset in seconds.
:return: Timezone offset in seconds (0 for UTC)
"""
@@ -0,0 +1,124 @@
# 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.
"""add_semantic_layers_and_views
Revision ID: 33d7e0e21daa
Revises: c233f5365c9e
Create Date: 2025-11-04 11:26:00.000000
"""
import uuid
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy_utils import UUIDType
from superset.migrations.shared.utils import (
create_fks_for_table,
create_table,
drop_table,
)
# revision identifiers, used by Alembic.
revision = "33d7e0e21daa"
down_revision = "c233f5365c9e"
def upgrade():
# Create semantic_layers table
create_table(
"semantic_layers",
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("type", sa.String(length=250), nullable=False),
sa.Column(
"configuration",
sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"),
nullable=True,
),
sa.Column("cache_timeout", sa.Integer(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid"),
)
# Create foreign key constraints for semantic_layers
create_fks_for_table(
"fk_semantic_layers_created_by_fk_ab_user",
"semantic_layers",
"ab_user",
["created_by_fk"],
["id"],
)
create_fks_for_table(
"fk_semantic_layers_changed_by_fk_ab_user",
"semantic_layers",
"ab_user",
["changed_by_fk"],
["id"],
)
# Create semantic_views table
create_table(
"semantic_views",
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column(
"configuration",
sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"),
nullable=True,
),
sa.Column("cache_timeout", sa.Integer(), nullable=True),
sa.Column(
"semantic_layer_uuid",
UUIDType(binary=True),
sa.ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid"),
)
# Create foreign key constraints for semantic_views
create_fks_for_table(
"fk_semantic_views_created_by_fk_ab_user",
"semantic_views",
"ab_user",
["created_by_fk"],
["id"],
)
create_fks_for_table(
"fk_semantic_views_changed_by_fk_ab_user",
"semantic_views",
"ab_user",
["changed_by_fk"],
["id"],
)
def downgrade():
drop_table("semantic_views")
drop_table("semantic_layers")
+16
View File
@@ -0,0 +1,16 @@
# 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.
+869
View File
@@ -0,0 +1,869 @@
# 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.
"""
Functions for mapping `QueryObject` to semantic layers.
These functions validate and convert a `QueryObject` into one or more `SemanticQuery`,
which are then passed to semantic layer implementations for execution, returning a
single dataframe.
"""
from datetime import datetime, timedelta
from time import time
from typing import Any, cast, Sequence, TypeGuard
import numpy as np
from superset.common.db_query_status import QueryStatus
from superset.common.query_object import QueryObject
from superset.common.utils.time_range_utils import get_since_until_from_query_object
from superset.connectors.sqla.models import BaseDatasource
from superset.models.helpers import QueryResult
from superset.semantic_layers.types import (
AdhocExpression,
AdhocFilter,
DateGrain,
Dimension,
Filter,
FilterValues,
GroupLimit,
Metric,
Operator,
OrderDirection,
OrderTuple,
PredicateType,
SemanticQuery,
SemanticResult,
SemanticViewFeature,
TimeGrain,
)
from superset.utils.core import (
FilterOperator,
QueryObjectFilterClause,
TIME_COMPARISON,
)
from superset.utils.date_parser import get_past_or_future
class ValidatedQueryObjectFilterClause(QueryObjectFilterClause):
"""
A validated QueryObject filter clause with a string column name.
The `col` in a `QueryObjectFilterClause` can be either a string (column name) or an
adhoc column, but we only support the former in semantic layers.
"""
# overwrite to narrow type; mypy complains about more restrictive typed dicts,
# but the alternative would be to redefine the object
col: str # type: ignore[misc]
op: str # type: ignore[misc]
class ValidatedQueryObject(QueryObject):
"""
A query object that has a datasource defined.
"""
datasource: BaseDatasource
# overwrite to narrow type; mypy complains about the assignment since the base type
# allows adhoc filters, but we only support validated filters here
filter: list[ValidatedQueryObjectFilterClause] # type: ignore[assignment]
series_columns: Sequence[str] # type: ignore[assignment]
series_limit_metric: str | None
def get_results(query_object: QueryObject) -> QueryResult:
"""
Run 1+ queries based on `QueryObject` and return the results.
:param query_object: The QueryObject containing query specifications
:return: QueryResult compatible with Superset's query interface
"""
if not validate_query_object(query_object):
raise ValueError("QueryObject must have a datasource defined.")
# Track execution time
start_time = time()
semantic_view = query_object.datasource.implementation
dispatcher = (
semantic_view.get_row_count
if query_object.is_rowcount
else semantic_view.get_dataframe
)
# Step 1: Convert QueryObject to list of SemanticQuery objects
# The first query is the main query, subsequent queries are for time offsets
queries = map_query_object(query_object)
# Step 2: Execute the main query (first in the list)
main_query = queries[0]
main_result = dispatcher(
metrics=main_query.metrics,
dimensions=main_query.dimensions,
filters=main_query.filters,
order=main_query.order,
limit=main_query.limit,
offset=main_query.offset,
group_limit=main_query.group_limit,
)
main_df = main_result.results
# Collect all requests (SQL queries, HTTP requests, etc.) for troubleshooting
all_requests = list(main_result.requests)
# If no time offsets, return the main result as-is
if not query_object.time_offsets or len(queries) <= 1:
semantic_result = SemanticResult(
requests=all_requests,
results=main_df,
)
duration = timedelta(seconds=time() - start_time)
return map_semantic_result_to_query_result(
semantic_result,
query_object,
duration,
)
# Get metric names from the main query
# These are the columns that will be renamed with offset suffixes
metric_names = [metric.name for metric in main_query.metrics]
# Join keys are all columns except metrics
# These will be used to match rows between main and offset DataFrames
join_keys = [col for col in main_df.columns if col not in metric_names]
# Step 3 & 4: Execute each time offset query and join results
for offset_query, time_offset in zip(
queries[1:],
query_object.time_offsets,
strict=False,
):
# Execute the offset query
result = dispatcher(
metrics=offset_query.metrics,
dimensions=offset_query.dimensions,
filters=offset_query.filters,
order=offset_query.order,
limit=offset_query.limit,
offset=offset_query.offset,
group_limit=offset_query.group_limit,
)
# Add this query's requests to the collection
all_requests.extend(result.requests)
offset_df = result.results
# Handle empty results - add NaN columns directly instead of merging
# This avoids dtype mismatch issues with empty DataFrames
if offset_df.empty:
# Add offset metric columns with NaN values directly to main_df
for metric in metric_names:
offset_col_name = TIME_COMPARISON.join([metric, time_offset])
main_df[offset_col_name] = np.nan
else:
# Rename metric columns with time offset suffix
# Format: "{metric_name}__{time_offset}"
# Example: "revenue" -> "revenue__1 week ago"
offset_df = offset_df.rename(
columns={
metric: TIME_COMPARISON.join([metric, time_offset])
for metric in metric_names
}
)
# Step 5: Perform left join on dimension columns
# This preserves all rows from main_df and adds offset metrics
# where they match
main_df = main_df.merge(
offset_df,
on=join_keys,
how="left",
suffixes=("", "__duplicate"),
)
# Clean up any duplicate columns that might have been created
# (shouldn't happen with proper join keys, but defensive programming)
duplicate_cols = [
col for col in main_df.columns if col.endswith("__duplicate")
]
if duplicate_cols:
main_df = main_df.drop(columns=duplicate_cols)
# Convert final result to QueryResult
semantic_result = SemanticResult(requests=all_requests, results=main_df)
duration = timedelta(seconds=time() - start_time)
return map_semantic_result_to_query_result(
semantic_result,
query_object,
duration,
)
def map_semantic_result_to_query_result(
semantic_result: SemanticResult,
query_object: ValidatedQueryObject,
duration: timedelta,
) -> QueryResult:
"""
Convert a SemanticResult to a QueryResult.
:param semantic_result: Result from the semantic layer
:param query_object: Original QueryObject (for passthrough attributes)
:param duration: Time taken to execute the query
:return: QueryResult compatible with Superset's query interface
"""
# Get the query string from requests (typically one or more SQL queries)
query_str = ""
if semantic_result.requests:
# Join all requests for display (could be multiple for time comparisons)
query_str = "\n\n".join(
f"-- {req.type}\n{req.definition}" for req in semantic_result.requests
)
return QueryResult(
# Core data
df=semantic_result.results,
query=query_str,
duration=duration,
# Template filters - not applicable to semantic layers
# (semantic layers don't use Jinja templates)
applied_template_filters=None,
# Filter columns - not applicable to semantic layers
# (semantic layers handle filter validation internally)
applied_filter_columns=None,
rejected_filter_columns=None,
# Status - always success if we got here
# (errors would raise exceptions before reaching this point)
status=QueryStatus.SUCCESS,
error_message=None,
errors=None,
# Time range - pass through from original query_object
from_dttm=query_object.from_dttm,
to_dttm=query_object.to_dttm,
)
def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
"""
Convert a `QueryObject` into a list of `SemanticQuery`.
This function maps the `QueryObject` into query objects that focus less on
visualization and more on semantics.
"""
semantic_view = query_object.datasource.implementation
all_metrics = {metric.name: metric for metric in semantic_view.metrics}
all_dimensions = {
dimension.name: dimension for dimension in semantic_view.dimensions
}
metrics = [all_metrics[metric] for metric in (query_object.metrics or [])]
grain = (
_convert_time_grain(query_object.extras["time_grain_sqla"])
if "time_grain_sqla" in query_object.extras
else None
)
dimensions = [
dimension
for dimension in semantic_view.dimensions
if dimension.name in query_object.columns
and (
# if a grain is specified, only include the time dimension if its grain
# matches the requested grain
grain is None
or dimension.name != query_object.granularity
or dimension.grain == grain
)
]
order = _get_order_from_query_object(query_object, all_metrics, all_dimensions)
limit = query_object.row_limit
offset = query_object.row_offset
group_limit = _get_group_limit_from_query_object(
query_object,
all_metrics,
all_dimensions,
)
queries = []
for time_offset in [None] + query_object.time_offsets:
filters = _get_filters_from_query_object(
query_object,
time_offset,
all_dimensions,
)
queries.append(
SemanticQuery(
metrics=metrics,
dimensions=dimensions,
filters=filters,
order=order,
limit=limit,
offset=offset,
group_limit=group_limit,
)
)
return queries
def _get_filters_from_query_object(
query_object: ValidatedQueryObject,
time_offset: str | None,
all_dimensions: dict[str, Dimension],
) -> set[Filter | AdhocFilter]:
"""
Extract all filters from the query object, including time range filters.
This simplifies the complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm
by converting all time constraints into filters.
"""
filters: set[Filter | AdhocFilter] = set()
# 1. Add fetch values predicate if present
if (
query_object.apply_fetch_values_predicate
and query_object.datasource.fetch_values_predicate
):
filters.add(
AdhocFilter(
type=PredicateType.WHERE,
definition=query_object.datasource.fetch_values_predicate,
)
)
# 2. Add time range filter based on from_dttm/to_dttm
# For time offsets, this automatically calculates the shifted bounds
time_filters = _get_time_filter(query_object, time_offset, all_dimensions)
filters.update(time_filters)
# 3. Add filters from query_object.extras (WHERE and HAVING clauses)
extras_filters = _get_filters_from_extras(query_object.extras)
filters.update(extras_filters)
# 4. Add all other filters from query_object.filter
for filter_ in query_object.filter:
converted_filter = _convert_query_object_filter(filter_, all_dimensions)
if converted_filter:
filters.add(converted_filter)
return filters
def _get_filters_from_extras(extras: dict[str, Any]) -> set[AdhocFilter]:
"""
Extract filters from the extras dict.
The extras dict can contain various keys that affect query behavior:
Supported keys (converted to filters):
- "where": SQL WHERE clause expression (e.g., "customer_id > 100")
- "having": SQL HAVING clause expression (e.g., "SUM(sales) > 1000")
Other keys in extras (handled elsewhere in the mapper):
- "time_grain_sqla": Time granularity (e.g., "P1D", "PT1H")
Handled in _convert_time_grain() and used for dimension grain matching
Note: The WHERE and HAVING clauses from extras are SQL expressions that
are passed through as-is to the semantic layer as AdhocFilter objects.
"""
filters: set[AdhocFilter] = set()
# Add WHERE clause from extras
if where_clause := extras.get("where"):
filters.add(
AdhocFilter(
type=PredicateType.WHERE,
definition=where_clause,
)
)
# Add HAVING clause from extras
if having_clause := extras.get("having"):
filters.add(
AdhocFilter(
type=PredicateType.HAVING,
definition=having_clause,
)
)
return filters
def _get_time_filter(
query_object: ValidatedQueryObject,
time_offset: str | None,
all_dimensions: dict[str, Dimension],
) -> set[Filter]:
"""
Create a time range filter from the query object.
This handles both regular queries and time offset queries, simplifying the
complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm by using the
same time bounds for both the main query and series limit subqueries.
"""
filters: set[Filter] = set()
if not query_object.granularity:
return filters
time_dimension = all_dimensions.get(query_object.granularity)
if not time_dimension:
return filters
# Get the appropriate time bounds based on whether this is a time offset query
from_dttm, to_dttm = _get_time_bounds(query_object, time_offset)
if not from_dttm or not to_dttm:
return filters
# Create a filter with >= and < operators
return {
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.GREATER_THAN_OR_EQUAL,
value=from_dttm,
),
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.LESS_THAN,
value=to_dttm,
),
}
def _get_time_bounds(
query_object: ValidatedQueryObject,
time_offset: str | None,
) -> tuple[datetime | None, datetime | None]:
"""
Get the appropriate time bounds for the query.
For regular queries (time_offset is None), returns from_dttm/to_dttm.
For time offset queries, calculates the shifted bounds.
This simplifies the inner_from_dttm/inner_to_dttm complexity by using
the same bounds for both main queries and series limit subqueries (Option 1).
"""
if time_offset is None:
# Main query: use from_dttm/to_dttm directly
return query_object.from_dttm, query_object.to_dttm
# Time offset query: calculate shifted bounds
# Use from_dttm/to_dttm if available, otherwise try to get from time_range
outer_from = query_object.from_dttm
outer_to = query_object.to_dttm
if not outer_from or not outer_to:
# Fall back to parsing time_range if from_dttm/to_dttm not set
outer_from, outer_to = get_since_until_from_query_object(query_object)
if not outer_from or not outer_to:
return None, None
# Apply the offset to both bounds
offset_from = get_past_or_future(time_offset, outer_from)
offset_to = get_past_or_future(time_offset, outer_to)
return offset_from, offset_to
def _convert_query_object_filter(
filter_: ValidatedQueryObjectFilterClause,
all_dimensions: dict[str, Dimension],
) -> Filter | AdhocFilter | None:
"""
Convert a QueryObject filter dict to a semantic layer Filter or AdhocFilter.
"""
operator_str = filter_["op"]
# Handle TEMPORAL_RANGE filters (these are already handled by _get_time_filter)
if operator_str == FilterOperator.TEMPORAL_RANGE.value:
# Skip - already handled in _get_time_filter
return None
# Handle simple column filters
col = filter_.get("col")
if col not in all_dimensions:
return None
dimension = all_dimensions[col]
val_str = filter_["val"]
value: FilterValues | set[FilterValues]
if val_str is None:
value = None
elif isinstance(val_str, (list, tuple)):
value = set(val_str)
else:
value = val_str
# Map QueryObject operators to semantic layer operators
operator_mapping = {
FilterOperator.EQUALS.value: Operator.EQUALS,
FilterOperator.NOT_EQUALS.value: Operator.NOT_EQUALS,
FilterOperator.GREATER_THAN.value: Operator.GREATER_THAN,
FilterOperator.LESS_THAN.value: Operator.LESS_THAN,
FilterOperator.GREATER_THAN_OR_EQUALS.value: Operator.GREATER_THAN_OR_EQUAL,
FilterOperator.LESS_THAN_OR_EQUALS.value: Operator.LESS_THAN_OR_EQUAL,
FilterOperator.IN.value: Operator.IN,
FilterOperator.NOT_IN.value: Operator.NOT_IN,
FilterOperator.LIKE.value: Operator.LIKE,
FilterOperator.NOT_LIKE.value: Operator.NOT_LIKE,
FilterOperator.IS_NULL.value: Operator.IS_NULL,
FilterOperator.IS_NOT_NULL.value: Operator.IS_NOT_NULL,
}
operator = operator_mapping.get(operator_str)
if not operator:
# Unknown operator - create adhoc filter
return None
return Filter(
type=PredicateType.WHERE,
column=dimension,
operator=operator,
value=value,
)
def _get_order_from_query_object(
query_object: ValidatedQueryObject,
all_metrics: dict[str, Metric],
all_dimensions: dict[str, Dimension],
) -> list[OrderTuple]:
order: list[OrderTuple] = []
for element, ascending in query_object.orderby:
direction = OrderDirection.ASC if ascending else OrderDirection.DESC
# adhoc
if isinstance(element, dict):
if element["sqlExpression"] is not None:
order.append(
(
AdhocExpression(
id=element["label"] or element["sqlExpression"],
definition=element["sqlExpression"],
),
direction,
)
)
elif element in all_dimensions:
order.append((all_dimensions[element], direction))
elif element in all_metrics:
order.append((all_metrics[element], direction))
return order
def _get_group_limit_from_query_object(
query_object: ValidatedQueryObject,
all_metrics: dict[str, Metric],
all_dimensions: dict[str, Dimension],
) -> GroupLimit | None:
# no limit
if query_object.series_limit == 0 or not query_object.columns:
return None
dimensions = [all_dimensions[dim_id] for dim_id in query_object.series_columns]
top = query_object.series_limit
metric = (
all_metrics[query_object.series_limit_metric]
if query_object.series_limit_metric
else None
)
direction = OrderDirection.DESC if query_object.order_desc else OrderDirection.ASC
group_others = query_object.group_others_when_limit_reached
# Check if we need separate filters for the group limit subquery
# This happens when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm
group_limit_filters = _get_group_limit_filters(query_object, all_dimensions)
return GroupLimit(
dimensions=dimensions,
top=top,
metric=metric,
direction=direction,
group_others=group_others,
filters=group_limit_filters,
)
def _get_group_limit_filters(
query_object: ValidatedQueryObject,
all_dimensions: dict[str, Dimension],
) -> set[Filter | AdhocFilter] | None:
"""
Get separate filters for the group limit subquery if needed.
This is used when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm,
which happens during time comparison queries. The group limit subquery may need
different time bounds to determine the top N groups.
Returns None if the group limit should use the same filters as the main query.
"""
# Check if inner time bounds are explicitly set and differ from outer bounds
if (
query_object.inner_from_dttm is None
or query_object.inner_to_dttm is None
or (
query_object.inner_from_dttm == query_object.from_dttm
and query_object.inner_to_dttm == query_object.to_dttm
)
):
# No separate bounds needed - use the same filters as the main query
return None
# Create separate filters for the group limit subquery
filters: set[Filter | AdhocFilter] = set()
# Add time range filter using inner bounds
if query_object.granularity:
time_dimension = all_dimensions.get(query_object.granularity)
if (
time_dimension
and query_object.inner_from_dttm
and query_object.inner_to_dttm
):
filters.update(
{
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.GREATER_THAN_OR_EQUAL,
value=query_object.inner_from_dttm,
),
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.LESS_THAN,
value=query_object.inner_to_dttm,
),
}
)
# Add fetch values predicate if present
if (
query_object.apply_fetch_values_predicate
and query_object.datasource.fetch_values_predicate
):
filters.add(
AdhocFilter(
type=PredicateType.WHERE,
definition=query_object.datasource.fetch_values_predicate,
)
)
# Add filters from query_object.extras (WHERE and HAVING clauses)
extras_filters = _get_filters_from_extras(query_object.extras)
filters.update(extras_filters)
# Add all other non-temporal filters from query_object.filter
for filter_ in query_object.filter:
# Skip temporal range filters - we're using inner bounds instead
if filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value:
continue
converted_filter = _convert_query_object_filter(filter_, all_dimensions)
if converted_filter:
filters.add(converted_filter)
return filters if filters else None
def _convert_time_grain(time_grain: str) -> TimeGrain | DateGrain | None:
"""
Convert a time grain string from the query object to a TimeGrain or DateGrain enum.
"""
if time_grain in TimeGrain.__members__:
return TimeGrain[time_grain]
if time_grain in DateGrain.__members__:
return DateGrain[time_grain]
return None
def validate_query_object(
query_object: QueryObject,
) -> TypeGuard[ValidatedQueryObject]:
"""
Validate that the `QueryObject` is compatible with the `SemanticView`.
If some semantic view implementation supports these features we should add an
attribute to the `SemanticViewImplementation` to indicate support for them.
"""
if not query_object.datasource:
return False
query_object = cast(ValidatedQueryObject, query_object)
_validate_metrics(query_object)
_validate_dimensions(query_object)
_validate_filters(query_object)
_validate_granularity(query_object)
_validate_group_limit(query_object)
_validate_orderby(query_object)
return True
def _validate_metrics(query_object: ValidatedQueryObject) -> None:
"""
Make sure metrics are defined in the semantic view.
"""
semantic_view = query_object.datasource.implementation
if any(not isinstance(metric, str) for metric in (query_object.metrics or [])):
raise ValueError("Adhoc metrics are not supported in Semantic Views.")
metric_names = {metric.name for metric in semantic_view.metrics}
if not set(query_object.metrics or []) <= metric_names:
raise ValueError("All metrics must be defined in the Semantic View.")
def _validate_dimensions(query_object: ValidatedQueryObject) -> None:
"""
Make sure all dimensions are defined in the semantic view.
"""
semantic_view = query_object.datasource.implementation
if any(not isinstance(column, str) for column in query_object.columns):
raise ValueError("Adhoc dimensions are not supported in Semantic Views.")
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if not set(query_object.columns) <= dimension_names:
raise ValueError("All dimensions must be defined in the Semantic View.")
def _validate_filters(query_object: ValidatedQueryObject) -> None:
"""
Make sure all filters are valid.
"""
for filter_ in query_object.filter:
if isinstance(filter_["col"], dict):
raise ValueError(
"Adhoc columns are not supported in Semantic View filters."
)
if not filter_.get("op"):
raise ValueError("All filters must have an operator defined.")
def _validate_granularity(query_object: ValidatedQueryObject) -> None:
"""
Make sure time column and time grain are valid.
"""
semantic_view = query_object.datasource.implementation
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if time_column := query_object.granularity:
if time_column not in dimension_names:
raise ValueError(
"The time column must be defined in the Semantic View dimensions."
)
if time_grain := query_object.extras.get("time_grain_sqla"):
if not time_column:
raise ValueError(
"A time column must be specified when a time grain is provided."
)
supported_time_grains = {
dimension.grain
for dimension in semantic_view.dimensions
if dimension.name == time_column and dimension.grain
}
if _convert_time_grain(time_grain) not in supported_time_grains:
raise ValueError(
"The time grain is not supported for the time column in the "
"Semantic View."
)
def _validate_group_limit(query_object: ValidatedQueryObject) -> None:
"""
Validate group limit related features in the query object.
"""
semantic_view = query_object.datasource.implementation
# no limit
if query_object.series_limit == 0:
return
if (
query_object.series_columns
and SemanticViewFeature.GROUP_LIMIT not in semantic_view.features
):
raise ValueError("Group limit is not supported in this Semantic View.")
if any(not isinstance(col, str) for col in query_object.series_columns):
raise ValueError("Adhoc dimensions are not supported in series columns.")
metric_names = {metric.name for metric in semantic_view.metrics}
if query_object.series_limit_metric and (
not isinstance(query_object.series_limit_metric, str)
or query_object.series_limit_metric not in metric_names
):
raise ValueError(
"The series limit metric must be defined in the Semantic View."
)
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if not set(query_object.series_columns) <= dimension_names:
raise ValueError("All series columns must be defined in the Semantic View.")
if (
query_object.group_others_when_limit_reached
and SemanticViewFeature.GROUP_OTHERS not in semantic_view.features
):
raise ValueError(
"Grouping others when limit is reached is not supported in this Semantic "
"View."
)
def _validate_orderby(query_object: ValidatedQueryObject) -> None:
"""
Validate order by elements in the query object.
"""
semantic_view = query_object.datasource.implementation
if (
any(not isinstance(element, str) for element, _ in query_object.orderby)
and SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY
not in semantic_view.features
):
raise ValueError(
"Adhoc expressions in order by are not supported in this Semantic View."
)
elements = set(query_object.orderby)
metric_names = {metric.name for metric in semantic_view.metrics}
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if not elements <= metric_names | dimension_names:
raise ValueError("All order by elements must be defined in the Semantic View.")
+205
View File
@@ -0,0 +1,205 @@
# 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.
"""Semantic layer models."""
from __future__ import annotations
import uuid
from importlib.metadata import entry_points
from typing import Any
from flask_appbuilder import Model
from sqlalchemy import Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from superset.common.query_object import QueryObject
from superset.models.helpers import AuditMixinNullable, QueryResult
from superset.semantic_layers.mapper import get_results
from superset.semantic_layers.types import (
DATE,
DATETIME,
SemanticLayerImplementation,
SemanticViewImplementation,
TIME,
)
from superset.superset_typing import QueryObjectDict
from superset.utils import core as utils
@dataclass(frozen=True)
class ColumnMetadata:
column_name: str
type: str
is_dttm: bool
class SemanticLayer(AuditMixinNullable, Model):
"""
Semantic layer model.
A semantic layer provides an abstraction over data sources,
allowing users to query data through a semantic interface.
"""
__tablename__ = "semantic_layers"
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
# Core fields
name = Column(String(250), nullable=False)
description = Column(Text, nullable=True)
type = Column(String(250), nullable=False)
# XXX: encrypt at rest
configuration = Column(utils.MediumText(), default="{}")
cache_timeout = Column(Integer, nullable=True)
# Semantic views relationship
semantic_views: list[SemanticView] = relationship(
"SemanticView",
back_populates="semantic_layer",
cascade="all, delete-orphan",
passive_deletes=True,
)
def __repr__(self) -> str:
return self.name or str(self.uuid)
@property
def implementation(
self,
) -> SemanticLayerImplementation[Any, SemanticViewImplementation]:
"""
Return semantic layer implementation.
"""
entry_point = next(
iter(
entry_points(
group="superset.semantic_layers",
name=self.type,
)
)
)
implementation_class = entry_point.load()
if not issubclass(implementation_class, SemanticLayerImplementation):
raise TypeError(
f"Entry point for semantic layer type '{self.type}' "
"must be a subclass of SemanticLayerImplementation"
)
# XXX store in self._implementation
return implementation_class.from_configuration(self.configuration)
class SemanticView(AuditMixinNullable, Model):
"""
Semantic view model.
A semantic view represents a queryable view within a semantic layer.
"""
__tablename__ = "semantic_views"
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
# Core fields
name = Column(String(250), nullable=False)
# XXX: encrypt at rest
configuration = Column(utils.MediumText(), default="{}")
cache_timeout = Column(Integer, nullable=True)
# Semantic layer relationship
semantic_layer_uuid = Column(
UUIDType(binary=True),
ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
nullable=False,
)
semantic_layer: SemanticLayer = relationship(
"SemanticLayer",
back_populates="semantic_views",
foreign_keys=[semantic_layer_uuid],
)
def __repr__(self) -> str:
return self.name or str(self.uuid)
@property
def implementation(self) -> SemanticViewImplementation:
"""
Return semantic view implementation.
"""
# XXX store in self._implementation
return self.semantic_layer.implementation.get_semantic_view(
self.name,
self.configuration,
)
# =========================================================================
# Explorable protocol implementation
# =========================================================================
def get_query_result(self, query_object: QueryObject) -> QueryResult:
return get_results(query_object)
def get_query_str(self, query_obj: QueryObjectDict) -> str:
return "Not implemented for semantic layers"
@property
def uid(self) -> str:
return self.implementation.uid()
@property
def type(self) -> str:
return "semantic_view"
@property
def columns(self) -> list[ColumnMetadata]:
return [
ColumnMetadata(
column_name=dimension.name,
type=dimension.type.__name__,
is_dttm=dimension.type in {DATE, TIME, DATETIME},
)
for dimension in self.implementation.dimensions
]
@property
def column_names(self) -> list[str]:
return [dimension.name for dimension in self.implementation.dimensions]
@property
def data(self) -> dict[str, Any]:
return {
"id": str(self.uuid),
"uid": self.uid,
"name": self.name,
"type": self.type,
"columns": [],
"metrics": [],
"database": [],
"description": self.description,
"schema": None,
"catalog": None,
"cache_timeout": self.cache_timeout,
"offset": None, # XXX
"owners": [], # XXX
"verbose_map": {}, # XXX
}
@@ -0,0 +1,26 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.semantic_layers.snowflake.schemas import SnowflakeConfiguration
from superset.semantic_layers.snowflake.semantic_layer import SnowflakeSemanticLayer
from superset.semantic_layers.snowflake.semantic_view import SnowflakeSemanticView
__all__ = [
"SnowflakeConfiguration",
"SnowflakeSemanticLayer",
"SnowflakeSemanticView",
]
@@ -0,0 +1,130 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from typing import Literal, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator, SecretStr
class UserPasswordAuth(BaseModel):
"""
Username and password authentication.
"""
model_config = ConfigDict(title="Username and password")
auth_type: Literal["user_password"] = "user_password"
username: str = Field(description="The username to authenticate as.")
password: SecretStr = Field(
description="The password to authenticate with.",
repr=False,
)
class PrivateKeyAuth(BaseModel):
"""
Private key authentication.
"""
model_config = ConfigDict(title="Private key")
auth_type: Literal["private_key"] = "private_key"
private_key: SecretStr = Field(
description="The private key to authenticate with, in PEM format.",
repr=False,
)
private_key_password: SecretStr = Field(
description="The password to decrypt the private key with.",
repr=False,
)
class SnowflakeConfiguration(BaseModel):
"""
Parameters needed to connect to Snowflake.
"""
# account is the only required parameter
account_identifier: str = Field(
description="The Snowflake account identifier.",
json_schema_extra={"examples": ["abc12345"]},
)
role: str | None = Field(
default=None,
description="The default role to use.",
json_schema_extra={"examples": ["myrole"]},
)
warehouse: str | None = Field(
default=None,
description="The default warehouse to use.",
json_schema_extra={"examples": ["testwh"]},
)
auth: Union[UserPasswordAuth, PrivateKeyAuth] = Field(
discriminator="auth_type",
description="Authentication method",
)
# database and schema can be optionally provided; if not provided the user
# will be able to browse databases/schemas
database: str | None = Field(
default=None,
description="The default database to use.",
json_schema_extra={
"examples": ["testdb"],
"x-dynamic": True,
"x-dependsOn": ["account_identifier", "auth"],
},
)
allow_changing_database: bool = Field(
default=False,
description="Allow changing the default database.",
)
schema_: str | None = Field(
default=None,
description="The default schema to use.",
json_schema_extra={
"examples": ["public"],
"x-dynamic": True,
"x-dependsOn": ["account_identifier", "auth", "database"],
},
# `schema` is an attribute of `BaseModel` so it needs to be aliased
alias="schema",
)
allow_changing_schema: bool = Field(
default=False,
description="Allow changing the default schema.",
)
@model_validator(mode="after")
def validate_database_schema_settings(self) -> SnowflakeConfiguration:
"""
Validate that if database or schema is not specified, the corresponding
allow_changing flag must be true.
"""
if not self.database and not self.allow_changing_database:
raise ValueError(
"If no database is specified, allow_changing_database must be true"
)
if not self.schema_ and not self.allow_changing_schema:
raise ValueError(
"If no schema is specified, allow_changing_schema must be true"
)
return self
@@ -0,0 +1,236 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from textwrap import dedent
from typing import Any, Literal, TYPE_CHECKING
from pydantic import create_model, Field
from snowflake.connector import connect
from snowflake.connector.connection import SnowflakeConnection
from superset.semantic_layers.snowflake.schemas import SnowflakeConfiguration
from superset.semantic_layers.snowflake.utils import get_connection_parameters
from superset.semantic_layers.types import (
SemanticLayerImplementation,
)
if TYPE_CHECKING:
from superset.semantic_layers.snowflake.semantic_view import SnowflakeSemanticView
class SnowflakeSemanticLayer(
SemanticLayerImplementation[SnowflakeConfiguration, SnowflakeSemanticView]
):
id = "snowflake"
name = "Snowflake Semantic Layer"
description = "Connect to semantic views stored in Snowflake."
@classmethod
def from_configuration(
cls,
configuration: dict[str, Any],
) -> SnowflakeSemanticLayer:
"""
Create a SnowflakeSemanticLayer from a configuration dictionary.
"""
config = SnowflakeConfiguration.model_validate(configuration)
return cls(config)
@classmethod
def get_configuration_schema(
cls,
configuration: SnowflakeConfiguration | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the configuration needed to add the semantic layer.
A partial configuration can be sent to improve the schema. For example,
providing account and auth will allow the schema to provide a list of
databases; providing a database will allow the schema to provide a list of
schemas.
Note that database and schema can both be left empty when the semantic layer is
added to Superset; the user will then have to provide them when loading
semantic views.
"""
schema = SnowflakeConfiguration.model_json_schema()
properties = schema["properties"]
if configuration is None:
# set these to empty; they will be populated when a partial configuration is
# passed
properties["database"]["enum"] = []
properties["schema"]["enum"] = []
return schema
connection_parameters = get_connection_parameters(configuration)
with connect(**connection_parameters) as connection:
if all(
getattr(configuration, dependency)
for dependency in properties["database"].get("x-dependsOn", [])
):
options = cls._fetch_databases(connection)
properties["database"]["enum"] = list(options)
if (
all(
getattr(configuration, dependency)
for dependency in properties["schema"].get("x-dependsOn", [])
)
and configuration.database
):
options = cls._fetch_schemas(connection, configuration.database)
properties["schema"]["enum"] = list(options)
return schema
@classmethod
def get_runtime_schema(
cls,
configuration: SnowflakeConfiguration,
runtime_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the runtime parameters needed to load semantic views.
The schema can be enriched with actual values when `runtime_data` is provided,
enabling dynamic schema updates (e.g., populating schema dropdown after
database is selected).
"""
fields: dict[str, tuple[Any, Field]] = {}
# update configuration with runtime data, for example, to select a schema after
# the database has been selected
configuration = configuration.model_copy(update=runtime_data)
connection_parameters = get_connection_parameters(configuration)
with connect(**connection_parameters) as connection:
if not configuration.database or configuration.allow_changing_database:
options = cls._fetch_databases(connection)
fields["database"] = (
Literal[*options],
Field(description="The default database to use."),
)
if not configuration.schema_ or configuration.allow_changing_schema:
if configuration.database:
options = cls._fetch_schemas(connection, configuration.database)
fields["schema_"] = (
Literal[*options],
Field(
description="The default schema to use.",
alias="schema",
json_schema_extra=(
{
"x-dynamic": True,
"x-dependsOn": ["database"],
}
if "database" in fields
else {}
),
),
)
else:
# Database not provided yet, add schema as empty
# (will be populated dynamically)
fields["schema_"] = (
str | None,
Field(
default=None,
description="The default schema to use.",
alias="schema",
json_schema_extra={
"x-dynamic": True,
"x-dependsOn": ["database"],
},
),
)
return create_model("RuntimeParameters", **fields).model_json_schema()
@classmethod
def _fetch_databases(cls, connection: SnowflakeConnection) -> set[str]:
"""
Fetch the list of databases available in the Snowflake account.
We use `SHOW DATABASES` instead of querying the information schema since it
allows to retrieve the list of databases without having to specify a database
when connecting.
"""
cursor = connection.cursor()
cursor.execute("SHOW DATABASES")
return {row[1] for row in cursor}
@classmethod
def _fetch_schemas(
cls,
connection: SnowflakeConnection,
database: str | None,
) -> set[str]:
"""
Fetch the list of schemas available in a given database.
The connection should already have the database set in its context.
"""
if not database:
return set()
cursor = connection.cursor()
query = dedent(
"""
SELECT SCHEMA_NAME
FROM INFORMATION_SCHEMA.SCHEMATA
WHERE CATALOG_NAME = ?
"""
).strip()
return {row[0] for row in cursor.execute(query, (database,))}
def __init__(self, configuration: SnowflakeConfiguration):
self.configuration = configuration
def get_semantic_views(
self,
runtime_configuration: dict[str, Any],
) -> set[SnowflakeSemanticView]:
"""
Get the semantic views available in the semantic layer.
"""
# Avoid circular import
from superset.semantic_layers.snowflake.semantic_view import (
SnowflakeSemanticView,
)
# create a new configuration with the runtime parameters
configuration = self.configuration.model_copy(update=runtime_configuration)
connection_parameters = get_connection_parameters(configuration)
with connect(**connection_parameters) as connection:
cursor = connection.cursor()
query = dedent(
"""
SHOW SEMANTIC VIEWS
->> SELECT "name" FROM $1;
"""
).strip()
views = {
SnowflakeSemanticView(row[0], configuration)
for row in cursor.execute(query)
}
return views
@@ -0,0 +1,817 @@
# 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.
# ruff: noqa: S608
from __future__ import annotations
import itertools
import re
from collections import defaultdict
from textwrap import dedent
from pandas import DataFrame
from snowflake.connector import connect, DictCursor
from snowflake.sqlalchemy.snowdialect import SnowflakeDialect
from superset.semantic_layers.snowflake.schemas import SnowflakeConfiguration
from superset.semantic_layers.snowflake.utils import (
get_connection_parameters,
substitute_parameters,
validate_order_by,
)
from superset.semantic_layers.types import (
AdhocExpression,
AdhocFilter,
BINARY,
BOOLEAN,
DATE,
DATETIME,
DECIMAL,
Dimension,
Filter,
FilterValues,
GroupLimit,
INTEGER,
Metric,
NUMBER,
OBJECT,
Operator,
OrderTuple,
PredicateType,
SemanticRequest,
SemanticResult,
SemanticViewFeature,
SemanticViewImplementation,
STRING,
TIME,
Type,
)
REQUEST_TYPE = "snowflake"
class SnowflakeSemanticView(SemanticViewImplementation):
features = frozenset(
{
SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY,
SemanticViewFeature.GROUP_LIMIT,
SemanticViewFeature.GROUP_OTHERS,
}
)
def __init__(self, name: str, configuration: SnowflakeConfiguration):
self.configuration = configuration
self.name = name
self._quote = SnowflakeDialect().identifier_preparer.quote
self.dimensions = self.get_dimensions()
self.metrics = self.get_metrics()
def uid(self) -> str:
return ".".join(
self._quote(part)
for part in (
self.configuration.database,
self.configuration.schema_,
self.name,
)
)
def get_dimensions(self) -> set[Dimension]:
"""
Get the dimensions defined in the semantic view.
Even though Snowflake supports `SHOW SEMANTIC DIMENSIONS IN my_semantic_view`,
it doesn't return the expression of dimensions, so we use a slightly more
complicated query to get all the information we need in one go.
"""
dimensions: set[Dimension] = set()
query = dedent(
f"""
DESC SEMANTIC VIEW {self.uid()}
->> SELECT "object_name", "property", "property_value"
FROM $1
WHERE
"object_kind" = 'DIMENSION' AND
"property" IN ('COMMENT', 'DATA_TYPE', 'EXPRESSION', 'TABLE');
"""
).strip()
connection_parameters = get_connection_parameters(self.configuration)
with connect(**connection_parameters) as connection:
cursor = connection.cursor(DictCursor)
rows = cursor.execute(query).fetchall()
for name, group in itertools.groupby(rows, key=lambda x: x["object_name"]):
attributes = defaultdict(set)
for row in group:
attributes[row["property"]].add(row["property_value"])
table = next(iter(attributes["TABLE"]))
id_ = table + "." + name
type_ = self._get_type(next(iter(attributes["DATA_TYPE"])))
description = next(iter(attributes["COMMENT"]), None)
definition = next(iter(attributes["EXPRESSION"]), None)
dimensions.add(Dimension(id_, name, type_, description, definition))
return dimensions
def get_metrics(self) -> set[Metric]:
"""
Get the metrics defined in the semantic view.
"""
metrics: set[Metric] = set()
query = dedent(
f"""
DESC SEMANTIC VIEW {self.uid()}
->> SELECT "object_name", "property", "property_value"
FROM $1
WHERE
"object_kind" = 'METRIC' AND
"property" IN ('COMMENT', 'DATA_TYPE', 'EXPRESSION', 'TABLE');
"""
).strip()
connection_parameters = get_connection_parameters(self.configuration)
with connect(**connection_parameters) as connection:
cursor = connection.cursor(DictCursor)
rows = cursor.execute(query).fetchall()
for name, group in itertools.groupby(rows, key=lambda x: x["object_name"]):
attributes = defaultdict(set)
for row in group:
attributes[row["property"]].add(row["property_value"])
table = next(iter(attributes["TABLE"]))
id_ = table + "." + name
type_ = self._get_type(next(iter(attributes["DATA_TYPE"])))
description = next(iter(attributes["COMMENT"]), None)
definition = next(iter(attributes["EXPRESSION"]), None)
metrics.add(Metric(id_, name, type_, definition, description))
return metrics
def _get_type(self, snowflake_type: str | None) -> type[Type]:
"""
Return the semantic type corresponding to a Snowflake type.
"""
if snowflake_type is None:
return STRING
type_map = {
STRING: {r"VARCHAR\(\d+\)$", "STRING$", "TEXT$", r"CHAR\(\d+\)$"},
INTEGER: {r"NUMBER\(38,\s?0\)$", "INT$", "INTEGER$", "BIGINT$"},
DECIMAL: {r"NUMBER\(10,\s?2\)$"},
NUMBER: {r"NUMBER\(\d+,\s?\d+\)$", "FLOAT$", "DOUBLE$"},
BOOLEAN: {"BOOLEAN$"},
DATE: {"DATE$"},
DATETIME: {"TIMESTAMP_TZ$", "TIMESTAMP__NTZ$"},
TIME: {"TIME$"},
OBJECT: {"OBJECT$"},
BINARY: {r"BINARY\(\d+\)$", r"VARBINARY\(\d+\)$"},
}
for semantic_type, patterns in type_map.items():
if any(
re.match(pattern, snowflake_type, re.IGNORECASE) for pattern in patterns
):
return semantic_type
return STRING
def _build_predicates(
self,
filters: list[Filter | AdhocFilter],
) -> tuple[str, tuple[FilterValues, ...]]:
"""
Convert a set of filters to a single `AND`ed predicate.
Caller should check the types of filters beforehand, as this method does not
differentiate between `WHERE` and `HAVING` predicates.
"""
if not filters:
return "", ()
# convert filters predicate with associated parameters; native filters are
# already strings, so we keep them as-is
unary_operators = {Operator.IS_NULL, Operator.IS_NOT_NULL}
predicates: list[str] = []
parameters: list[FilterValues] = []
for filter_ in filters or set():
if isinstance(filter_, AdhocFilter):
predicates.append(f"({filter_.definition})")
else:
predicates.append(f"({self._build_native_filter(filter_)})")
if filter_.operator not in unary_operators:
parameters.extend(
[filter_.value]
if not isinstance(filter_.value, (set, frozenset))
else filter_.value
)
return " AND ".join(predicates), tuple(parameters)
def get_values(
self,
dimension: Dimension,
filters: set[Filter | AdhocFilter] | None = None,
) -> SemanticResult:
"""
Return distinct values for a dimension.
"""
where_clause, parameters = self._build_predicates(
sorted(
filter_
for filter_ in (filters or [])
if filter_.type == PredicateType.WHERE
)
)
query = dedent(
f"""
SELECT {self._quote(dimension.name)}
FROM SEMANTIC_VIEW(
{self.uid()}
DIMENSIONS {dimension.id}
{"WHERE " + where_clause if where_clause else ""}
)
"""
).strip()
connection_parameters = get_connection_parameters(self.configuration)
with connect(**connection_parameters) as connection:
df = connection.cursor().execute(query, parameters).fetch_pandas_all()
return SemanticResult(
requests=[
SemanticRequest(
REQUEST_TYPE,
substitute_parameters(query, parameters),
)
],
results=df,
)
def _build_native_filter(self, filter_: Filter) -> str:
"""
Convert a Filter to a AdhocFilter.
"""
column = filter_.column
operator = filter_.operator
value = filter_.value
column_name = self._quote(column.name)
# Handle IS NULL and IS NOT NULL operators (no value needed)
if operator in {Operator.IS_NULL, Operator.IS_NOT_NULL}:
return f"{column_name} {operator.value}"
# Handle IN and NOT IN operators (set values)
if operator in {Operator.IN, Operator.NOT_IN}:
parameter_count = len(value) if isinstance(value, (set, frozenset)) else 1
formatted_values = ", ".join("?" for _ in range(parameter_count))
return f"{column_name} {operator.value} ({formatted_values})"
return f"{column_name} {operator.value} ?"
def get_dataframe(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a query and return the results as a Pandas DataFrame.
"""
if not metrics and not dimensions:
return DataFrame()
query, parameters = self._get_query(
metrics,
dimensions,
filters,
order,
limit,
offset,
group_limit,
)
connection_parameters = get_connection_parameters(self.configuration)
with connect(**connection_parameters) as connection:
df = connection.cursor().execute(query, parameters).fetch_pandas_all()
return SemanticResult(
requests=[
SemanticRequest(
REQUEST_TYPE,
substitute_parameters(query, parameters),
)
],
results=df,
)
def get_row_count(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a query and return the number of rows the result would have.
"""
if not metrics and not dimensions:
return SemanticResult(
requests=[],
results=DataFrame([[0]], columns=["COUNT"]),
)
query, parameters = self._get_query(
metrics,
dimensions,
filters,
order,
limit,
offset,
group_limit,
)
query = f"SELECT COUNT(*) FROM ({query}) AS subquery"
connection_parameters = get_connection_parameters(self.configuration)
with connect(**connection_parameters) as connection:
df = connection.cursor().execute(query, parameters).fechone()[0]
return SemanticResult(
requests=[
SemanticRequest(
REQUEST_TYPE,
substitute_parameters(query, parameters),
)
],
results=df,
)
def _get_query(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
group_limit: GroupLimit | None = None,
) -> tuple[str, tuple[FilterValues, ...]]:
"""
Build a query to fetch data from the semantic view.
This also returns the parameters need to run `cursor.execute()`, passed
separately to prevent SQL injection.
"""
if limit is None and offset is not None:
raise ValueError("Offset cannot be set without limit")
filters = filters or set()
where_clause, where_parameters = self._build_predicates(
sorted(
filter_ for filter_ in filters if filter_.type == PredicateType.WHERE
)
)
# having clauses are not supported, since there's no GROUP BY
if any(filter_.type == PredicateType.HAVING for filter_ in filters):
raise ValueError("HAVING filters are not supported")
if group_limit:
query, cte_parameters = self._build_query_with_group_limit(
metrics,
dimensions,
where_clause,
order,
limit,
offset,
group_limit,
)
# Combine parameters: CTE params first, then main query params
all_parameters = cte_parameters + where_parameters
else:
query = self._build_simple_query(
metrics,
dimensions,
where_clause,
order,
limit,
offset,
)
all_parameters = where_parameters
return query, all_parameters
def _alias_element(self, element: Metric | Dimension) -> str:
"""
Generate an aliased column expression for a metric or dimension.
"""
return f"{element.id} AS {self._quote(element.id)}"
def _build_order_clause(
self,
order: list[OrderTuple] | None = None,
) -> str:
"""
Build the ORDER BY clause from a list of (element, direction) tuples.
Note that for adhoc expressions, Superset will still add `ASC` or `DESC` to the
end, which means adhoc expressions can contain multiple columns as long as the
last one has no direction specified.
This is fine:
gender ASC, COUNT(*)
But this is not
gender ASC, COUNT(*) DESC
The latter will produce a query that looks like this:
... ORDER BY gender ASC, COUNT(*) DESC DESC
"""
if not order:
return ""
def build_element(element: Metric | Dimension | AdhocExpression) -> str:
if isinstance(element, AdhocExpression):
validate_order_by(element.definition)
return element.definition
return self._quote(element.id)
return ", ".join(
f"{build_element(element)} {direction.value}"
for element, direction in order
)
def _build_simple_query(
self,
metrics: list[Metric],
dimensions: list[Dimension],
where_clause: str,
order: list[OrderTuple] | None,
limit: int | None,
offset: int | None,
) -> str:
"""
Build a query without group limiting.
"""
dimension_arguments = ", ".join(
self._alias_element(dimension) for dimension in dimensions
)
metric_arguments = ", ".join(self._alias_element(metric) for metric in metrics)
order_clause = self._build_order_clause(order)
return dedent(
f"""
SELECT * FROM SEMANTIC_VIEW(
{self.uid()}
{"DIMENSIONS " + dimension_arguments if dimension_arguments else ""}
{"METRICS " + metric_arguments if metric_arguments else ""}
{"WHERE " + where_clause if where_clause else ""}
)
{"ORDER BY " + order_clause if order_clause else ""}
{"LIMIT " + str(limit) if limit is not None else ""}
{"OFFSET " + str(offset) if offset is not None else ""}
"""
).strip()
def _build_top_groups_cte(
self,
group_limit: GroupLimit,
where_clause: str,
) -> tuple[str, tuple[FilterValues, ...]]:
"""
Build a CTE that finds the top N combinations of limited dimensions.
If group_limit.filters is set, it uses those filters instead of the main
query's where clause. This allows using different time bounds for finding top
groups vs showing data.
Returns:
Tuple of (CTE SQL, parameters for the CTE)
"""
limited_dimension_arguments = ", ".join(
self._alias_element(dimension) for dimension in group_limit.dimensions
)
limited_dimension_names = ", ".join(
self._quote(dimension.id) for dimension in group_limit.dimensions
)
# Use separate filters for group limit if provided (Option 2)
# Otherwise use the same filters as the main query (Option 1)
if group_limit.filters is not None:
group_where_clause, group_where_params = self._build_predicates(
sorted(
filter_
for filter_ in group_limit.filters
if filter_.type == PredicateType.WHERE
)
)
if any(
filter_.type == PredicateType.HAVING for filter_ in group_limit.filters
):
raise ValueError(
"HAVING filters are not supported in group limit filters"
)
cte_params = group_where_params
else:
group_where_clause = where_clause
cte_params = () # No additional params - using main query params
# Build METRICS clause and ORDER BY based on whether metric is provided
if group_limit.metric is not None:
metrics_clause = (
f"METRICS {group_limit.metric.id}"
f" AS {self._quote(group_limit.metric.id)}"
)
order_by_clause = (
f"{self._quote(group_limit.metric.id)} {group_limit.direction.value}"
)
else:
# No metric provided - order by first dimension
metrics_clause = ""
order_by_clause = (
f"{self._quote(group_limit.dimensions[0].id)} "
f"{group_limit.direction.value}"
)
# Build SEMANTIC_VIEW arguments
semantic_view_args = [
f"DIMENSIONS {limited_dimension_arguments}",
]
if metrics_clause:
semantic_view_args.append(metrics_clause)
if group_where_clause:
semantic_view_args.append(f"WHERE {group_where_clause}")
semantic_view_args_str = "\n ".join(semantic_view_args)
# Add trailing blank line if there's no WHERE clause
# This matches the original template behavior
if not group_where_clause:
semantic_view_args_str += "\n"
cte_sql = dedent(
f"""
WITH top_groups AS (
SELECT {limited_dimension_names}
FROM SEMANTIC_VIEW(
{self.uid()}
{semantic_view_args_str}
)
ORDER BY
{order_by_clause}
LIMIT {group_limit.top}
)
"""
).strip()
return cte_sql, cte_params
def _build_group_filter(self, group_limit: GroupLimit) -> str:
"""
Build a WHERE filter that restricts results to top N groups.
"""
if len(group_limit.dimensions) == 1:
dimension_id = self._quote(group_limit.dimensions[0].id)
return f"{dimension_id} IN (SELECT {dimension_id} FROM top_groups)"
# Multi-column IN clause
dimension_tuple = ", ".join(
self._quote(dim.id) for dim in group_limit.dimensions
)
return f"({dimension_tuple}) IN (SELECT {dimension_tuple} FROM top_groups)"
def _build_case_expression(
self,
dimension: Dimension,
group_condition: str,
) -> str:
"""
Build a CASE expression that replaces non-top values with 'Other'.
Args:
dimension: The dimension to build the CASE for
group_condition: The condition to check if value is in top groups
(e.g., "staff_id IN (SELECT staff_id FROM top_groups)")
Returns:
SQL CASE expression
"""
dimension_id = self._quote(dimension.id)
return f"""CASE
WHEN {group_condition} THEN {dimension_id}
ELSE CAST('Other' AS VARCHAR)
END"""
def _build_query_with_others(
self,
metrics: list[Metric],
dimensions: list[Dimension],
where_clause: str,
order: list[OrderTuple] | None,
limit: int | None,
offset: int | None,
group_limit: GroupLimit,
) -> tuple[str, tuple[FilterValues, ...]]:
"""
Build a query that groups non-top N values as 'Other'.
This uses a two-stage approach:
1. CTE to find top N groups
2. Subquery with CASE expressions to replace non-top values with 'Other'
3. Outer query to re-aggregate with the new grouping
Returns:
Tuple of (SQL query, CTE parameters)
"""
top_groups_cte, cte_params = self._build_top_groups_cte(
group_limit,
where_clause,
)
# Determine which dimensions are limited vs non-limited
limited_dimension_ids = {dim.id for dim in group_limit.dimensions}
non_limited_dimensions = [
dim for dim in dimensions if dim.id not in limited_dimension_ids
]
# Build the group condition for CASE expressions
if len(group_limit.dimensions) == 1:
dimension_id = self._quote(group_limit.dimensions[0].id)
group_condition = (
f"{dimension_id} IN (SELECT {dimension_id} FROM top_groups)"
)
else:
dimension_tuple = ", ".join(
self._quote(dim.id) for dim in group_limit.dimensions
)
group_condition = (
f"({dimension_tuple}) IN (SELECT {dimension_tuple} FROM top_groups)"
)
# Build CASE expressions for limited dimensions
case_expressions = []
case_expressions_for_groupby = []
for dim in group_limit.dimensions:
case_expr = self._build_case_expression(dim, group_condition)
alias = self._quote(dim.id)
case_expressions.append(f"{case_expr} AS {alias}")
# Store the full CASE expression for GROUP BY (not just alias)
case_expressions_for_groupby.append(case_expr)
# Build SELECT for non-limited dimensions (pass through)
non_limited_selects = [
f"{self._quote(dim.id)} AS {self._quote(dim.id)}"
for dim in non_limited_dimensions
]
# Build metric aggregations
metric_aggregations = [
f"SUM({self._quote(metric.id)}) AS {self._quote(metric.id)}"
for metric in metrics
]
# Build the subquery that gets raw data from SEMANTIC_VIEW
dimension_arguments = ", ".join(
self._alias_element(dimension) for dimension in dimensions
)
metric_arguments = ", ".join(self._alias_element(metric) for metric in metrics)
subquery = dedent(
f"""
raw_data AS (
SELECT * FROM SEMANTIC_VIEW(
{self.uid()}
DIMENSIONS {dimension_arguments}
METRICS {metric_arguments}
{"WHERE " + where_clause if where_clause else ""}
)
)
"""
).strip()
# Build GROUP BY clause (full CASE expressions + non-limited dimensions)
# We need to repeat the full CASE expressions, not use aliases, because
# Snowflake may interpret the alias as the original column reference
group_by_columns = case_expressions_for_groupby + [
self._quote(dim.id) for dim in non_limited_dimensions
]
group_by_clause = ", ".join(group_by_columns)
# Build final SELECT columns
select_columns = case_expressions + non_limited_selects + metric_aggregations
select_clause = ",\n ".join(select_columns)
# Build ORDER BY clause (need to reference the aliased columns)
order_clause = self._build_order_clause(order)
query = dedent(
f"""
{top_groups_cte},
{subquery}
SELECT
{select_clause}
FROM raw_data
GROUP BY {group_by_clause}
{"ORDER BY " + order_clause if order_clause else ""}
{"LIMIT " + str(limit) if limit is not None else ""}
{"OFFSET " + str(offset) if offset is not None else ""}
"""
).strip()
return query, cte_params
def _build_query_with_group_limit(
self,
metrics: list[Metric],
dimensions: list[Dimension],
where_clause: str,
order: list[OrderTuple] | None,
limit: int | None,
offset: int | None,
group_limit: GroupLimit,
) -> tuple[str, tuple[FilterValues, ...]]:
"""
Build a query with group limiting (top N groups).
If group_others is True, groups non-top values as 'Other'.
Otherwise, filters to show only top N groups.
Returns:
Tuple of (SQL query, CTE parameters)
"""
if group_limit.group_others:
return self._build_query_with_others(
metrics,
dimensions,
where_clause,
order,
limit,
offset,
group_limit,
)
# Standard group limiting: just filter to top N groups
# We can't use CTE references inside SEMANTIC_VIEW(), so we wrap it
dimension_arguments = ", ".join(
self._alias_element(dimension) for dimension in dimensions
)
metric_arguments = ", ".join(self._alias_element(metric) for metric in metrics)
order_clause = self._build_order_clause(order)
top_groups_cte, cte_params = self._build_top_groups_cte(
group_limit,
where_clause,
)
group_filter = self._build_group_filter(group_limit)
query = dedent(
f"""
{top_groups_cte}
SELECT * FROM SEMANTIC_VIEW(
{self.uid()}
{"DIMENSIONS " + dimension_arguments if dimension_arguments else ""}
{"METRICS " + metric_arguments if metric_arguments else ""}
{"WHERE " + where_clause if where_clause else ""}
) AS subquery
WHERE {group_filter}
{"ORDER BY " + order_clause if order_clause else ""}
{"LIMIT " + str(limit) if limit is not None else ""}
{"OFFSET " + str(offset) if offset is not None else ""}
"""
).strip()
return query, cte_params
__repr__ = uid
+123
View File
@@ -0,0 +1,123 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ruff: noqa: S608
from __future__ import annotations
from typing import Any, Sequence
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from superset.exceptions import SupersetParseError
from superset.semantic_layers.snowflake.schemas import (
PrivateKeyAuth,
SnowflakeConfiguration,
UserPasswordAuth,
)
from superset.sql.parse import SQLStatement
def substitute_parameters(query: str, parameters: Sequence[Any] | None) -> str:
"""
Substitute parametereters in templated query.
This is used to convert bind query parameters so that we can return the executed
query for logging/auditing purposes. With Snowflake the binding happens on the
server, so the only way to get the true executed query would be to query the
database, which is innefficient.
"""
if not parameters:
return query
result = query
for parameter in parameters:
if parameter is None:
replacement = "NULL"
elif isinstance(parameter, bool):
# Check bool before int/float since bool is a subclass of int
replacement = str(parameter).upper()
elif isinstance(parameter, (int, float)):
replacement = str(parameter)
else:
# String - escape single quotes
quoted = str(parameter).replace("'", "''")
replacement = f"'{quoted}'"
result = result.replace("?", replacement, 1)
return result
def validate_order_by(definition: str) -> None:
"""
Validate that an ORDER BY expression is safe to use.
Note that `definition` could contain multiple expressions separated by commas.
"""
try:
# this ensures that we have a single statement, preventing SQL injection via a
# semicolon in the order by clause
SQLStatement(f"SELECT 1 ORDER BY {definition}", "snowflake")
except SupersetParseError as ex:
raise ValueError("Invalid ORDER BY expression") from ex
def get_connection_parameters(configuration: SnowflakeConfiguration) -> dict[str, Any]:
"""
Convert the configuration to connection parameters for the Snowflake connector.
"""
params = {
"account": configuration.account_identifier,
"application": "Apache Superset",
"paramstyle": "qmark",
"insecure_mode": True,
}
if configuration.role:
params["role"] = configuration.role
if configuration.warehouse:
params["warehouse"] = configuration.warehouse
if configuration.database:
params["database"] = configuration.database
if configuration.schema_:
params["schema"] = configuration.schema_
auth = configuration.auth
if isinstance(auth, UserPasswordAuth):
params["user"] = auth.username
params["password"] = auth.password.get_secret_value()
elif isinstance(auth, PrivateKeyAuth):
pem_private_key = serialization.load_pem_private_key(
auth.private_key.get_secret_value().encode(),
password=(
auth.private_key_password.get_secret_value().encode()
if auth.private_key_password
else None
),
backend=default_backend(),
)
params["private_key"] = pem_private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
else:
raise ValueError("Unsupported authentication method")
return params
+443
View File
@@ -0,0 +1,443 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import enum
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from functools import total_ordering
from typing import Any, Protocol, runtime_checkable, TypeVar
from pandas import DataFrame
from pydantic import BaseModel
__all__ = [
"BINARY",
"BOOLEAN",
"DATE",
"DATETIME",
"DECIMAL",
"DateGrain",
"Dimension",
"INTEGER",
"INTERVAL",
"NUMBER",
"OBJECT",
"STRING",
"TIME",
"TimeGrain",
]
class Type:
"""
Base class for types.
"""
class INTEGER(Type):
"""
Represents an integer type.
"""
class NUMBER(Type):
"""
Represents a number type.
"""
class DECIMAL(Type):
"""
Represents a decimal type.
"""
class STRING(Type):
"""
Represents a string type.
"""
class BOOLEAN(Type):
"""
Represents a boolean type.
"""
class DATE(Type):
"""
Represents a date type.
"""
class TIME(Type):
"""
Represents a time type.
"""
class DATETIME(DATE, TIME):
"""
Represents a datetime type.
"""
class INTERVAL(Type):
"""
Represents an interval type.
"""
class OBJECT(Type):
"""
Represents an object type.
"""
class BINARY(Type):
"""
Represents a binary type.
"""
@total_ordering
class ComparableEnum(enum.Enum):
def __eq__(self, other: object) -> bool:
if isinstance(other, enum.Enum):
return self.value == other.value
return NotImplemented
def __lt__(self, other: object) -> bool:
if isinstance(other, enum.Enum):
return self.value < other.value
return NotImplemented
def __hash__(self) -> int:
return hash((self.__class__, self.name))
class TimeGrain(ComparableEnum):
PT1S = timedelta(seconds=1)
PT1M = timedelta(minutes=1)
PT1H = timedelta(hours=1)
class DateGrain(ComparableEnum):
P1D = timedelta(days=1)
P1W = timedelta(weeks=1)
P1M = timedelta(days=30)
P3M = timedelta(days=90)
P1Y = timedelta(days=365)
@dataclass(frozen=True)
class Dimension:
id: str
name: str
type: type[Type]
definition: str | None = None
description: str | None = None
grain: DateGrain | TimeGrain | None = None
@dataclass(frozen=True)
class Metric:
id: str
name: str
type: type[Type]
definition: str | None
description: str | None = None
@dataclass(frozen=True)
class AdhocExpression:
id: str
definition: str
class Operator(str, enum.Enum):
EQUALS = "="
NOT_EQUALS = "!="
GREATER_THAN = ">"
LESS_THAN = "<"
GREATER_THAN_OR_EQUAL = ">="
LESS_THAN_OR_EQUAL = "<="
IN = "IN"
NOT_IN = "NOT IN"
LIKE = "LIKE"
NOT_LIKE = "NOT LIKE"
IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL"
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
class PredicateType(enum.Enum):
WHERE = "WHERE"
HAVING = "HAVING"
@dataclass(frozen=True, order=True)
class Filter:
type: PredicateType
column: Dimension | Metric
operator: Operator
value: FilterValues | set[FilterValues]
@dataclass(frozen=True, order=True)
class AdhocFilter:
type: PredicateType
definition: str
class OrderDirection(enum.Enum):
ASC = "ASC"
DESC = "DESC"
OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection]
@dataclass(frozen=True)
class GroupLimit:
"""
Limit query to top/bottom N combinations of specified dimensions.
The `filters` parameter allows specifying separate filter constraints for the
group limit subquery. This is useful when you want to determine the top N groups
using different criteria (e.g., a different time range) than the main query.
For example, you might want to find the top 10 products by sales over the last
30 days, but then show daily sales for those products over the last 7 days.
"""
dimensions: list[Dimension]
top: int
metric: Metric | None
direction: OrderDirection = OrderDirection.DESC
group_others: bool = False
filters: set[Filter | AdhocFilter] | None = None
@dataclass(frozen=True)
class SemanticRequest:
"""
Represents a request made to obtain semantic results.
This could be a SQL query, an HTTP request, etc.
"""
type: str
definition: str
@dataclass(frozen=True)
class SemanticResult:
"""
Represents the results of a semantic query.
This includes any requests (SQL queries, HTTP requests) that were performed in order
to obtain the results, in order to help troubleshooting.
"""
requests: list[SemanticRequest]
results: DataFrame
@dataclass(frozen=True)
class SemanticQuery:
"""
Represents a semantic query.
"""
metrics: list[Metric]
dimensions: list[Dimension]
filters: set[Filter | AdhocFilter] | None = None
order: list[OrderTuple] | None = None
limit: int | None = None
offset: int | None = None
group_limit: GroupLimit | None = None
class SemanticViewFeature(enum.Enum):
"""
Custom features supported by semantic layers.
"""
ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY"
GROUP_LIMIT = "GROUP_LIMIT"
GROUP_OTHERS = "GROUP_OTHERS"
ConfigT = TypeVar("ConfigT", bound=BaseModel, contravariant=True)
SemanticViewT = TypeVar("SemanticViewT", bound="SemanticViewImplementation")
@runtime_checkable
class SemanticLayerImplementation(Protocol[ConfigT, SemanticViewT]):
"""
A protocol for semantic layers.
"""
@classmethod
def from_configuration(
cls,
configuration: dict[str, Any],
) -> SemanticLayerImplementation[ConfigT, SemanticViewT]:
"""
Create a semantic layer from its configuration.
"""
@classmethod
def get_configuration_schema(
cls,
configuration: ConfigT | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the configuration needed to add the semantic layer.
A partial configuration `configuration` can be sent to improve the schema,
allowing for progressive validation and better UX. For example, a semantic
layer might require:
- auth information
- a database
If the user provides the auth information, a client can send the partial
configuration to this method, and the resulting JSON schema would include
the list of databases the user has access to, allowing a dropdown to be
populated.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are populated based on the provided connection info.
"""
@classmethod
def get_runtime_schema(
cls,
configuration: ConfigT,
runtime_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the runtime parameters needed to load semantic views.
This returns the schema needed to connect to a semantic view given the
configuration for the semantic layer. For example, a semantic layer might
be configured by:
- auth information
- an optional database
If the user does not provide a database when creating the semantic layer, the
runtime schema would require the database name to be provided before loading any
semantic views. This allows users to create semantic layers that connect to a
specific database (or project, account, etc.), or that allow users to select it
at query time.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are required if they were not provided in the initial
configuration.
"""
def get_semantic_views(
self,
runtime_configuration: dict[str, Any],
) -> set[SemanticViewT]:
"""
Get the semantic views available in the semantic layer.
The runtime configuration can provide information like a given project or
schema, used to restrict the semantic views returned.
"""
def get_semantic_view(
self,
name: str,
additional_configuration: dict[str, Any],
) -> SemanticViewT:
"""
Get a specific semantic view by its name and additional configuration.
"""
@runtime_checkable
class SemanticViewImplementation(Protocol):
"""
A protocol for semantic views.
"""
features: frozenset[SemanticViewFeature]
def uid(self) -> str:
"""
Returns a unique identifier for the semantic view.
"""
def get_dimensions(self) -> set[Dimension]:
"""
Get the dimensions defined in the semantic view.
"""
def get_metrics(self) -> set[Metric]:
"""
Get the metrics defined in the semantic view.
"""
def get_values(
self,
dimension: Dimension,
filters: set[Filter | AdhocFilter] | None = None,
) -> SemanticResult:
"""
Return distinct values for a dimension.
"""
def get_dataframe(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a semantic query and return the results as a DataFrame.
"""
def get_row_count(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a query and return the number of rows the result would have.
"""
+27 -2
View File
@@ -22,6 +22,7 @@ from typing import Any
from celery import Task
from celery.exceptions import SoftTimeLimitExceeded
from celery.signals import task_failure
from flask import current_app
from superset import is_feature_enabled
@@ -41,8 +42,32 @@ from superset.utils.log import get_logger_from_status
logger = logging.getLogger(__name__)
@celery_app.task(name="reports.scheduler")
def scheduler() -> None:
@task_failure.connect
def log_task_failure( # pylint: disable=unused-argument
sender: Task | None = None,
task_id: str | None = None,
exception: Exception | None = None,
args: tuple[Any, ...] | None = None,
kwargs: dict[str, Any] | None = None,
traceback: Any = None,
einfo: Any = None,
**kw: Any,
) -> None:
task_name = sender.name if sender else "Unknown"
logger.exception("Celery task %s failed: %s", task_name, exception, exc_info=einfo)
@celery_app.task(
name="reports.scheduler",
bind=True,
autoretry_for=(Exception,),
retry_kwargs={
"max_retries": 3,
"countdown": 60,
}, # Retry up to 3 times, wait 60s between
retry_backoff=True, # exponential backoff
)
def scheduler(self: Task) -> None: # pylint: disable=unused-argument
"""
Celery beat main scheduler for reports
"""
+3 -9
View File
@@ -550,15 +550,9 @@ class ThemeRestApi(BaseSupersetModelRestApi):
overwrite = request.form.get("overwrite") == "true"
try:
ImportThemesCommand(contents, overwrite=overwrite).run()
return self.response(200, message="Theme imported successfully")
except ValidationError as err:
logger.exception("Import themes validation error")
return self.response_400(message=str(err))
except Exception as ex:
logger.exception("Unexpected error importing themes")
return self.response_422(message=str(ex))
command = ImportThemesCommand(contents, overwrite=overwrite)
command.run()
return self.response(200, message="Theme imported successfully")
@expose("/<int:pk>/set_system_default", methods=("PUT",))
@protect()
@@ -751,10 +751,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "اغسطس"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "هامش عنوان المحور"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "موضع عنوان المحور"
msgid "About"
@@ -13452,7 +13452,7 @@ msgstr "اكتب وصفًا لاستعلامك"
msgid "Write a handlebars template to render the data"
msgstr "اكتب قالب المقاود لعرض البيانات"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "هامش عنوان المحور X"
msgid "X Axis"
@@ -13501,7 +13501,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "حدود Y 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "هامش عنوان المحور Y"
msgid "Y Axis"
@@ -780,10 +780,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AGO"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "MARGE DEL TÍTOL DE L'EIX"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "POSICIÓ DEL TÍTOL DE L'EIX"
msgid "About"
@@ -13231,7 +13231,7 @@ msgstr "Escriu una descripció per la teva consulta"
msgid "Write a handlebars template to render the data"
msgstr "Escriu una plantilla handlebars per renderitzar les dades"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "MARGE DEL TÍTOL DE L'EIX X"
msgid "X Axis"
@@ -13279,7 +13279,7 @@ msgstr "XYZ"
msgid "Y 2 bounds"
msgstr "Límits Y 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "MARGE DEL TÍTOL DE L'EIX Y"
msgid "Y Axis"
@@ -794,10 +794,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AUG"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "ABSTAND DES ACHSENTITELS"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "Y-ACHSE TITEL POSITION"
msgid "About"
@@ -13868,7 +13868,7 @@ msgstr "Beschreibung Ihrer Anfrage"
msgid "Write a handlebars template to render the data"
msgstr "Handlebars-Template zur Darstellung der Daten verfassen"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "X AXIS TITLE MARGIN"
msgid "X Axis"
@@ -13917,7 +13917,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 2 Grenzen"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Y-ACHSE TITEL RAND"
msgid "Y Axis"
@@ -689,10 +689,10 @@ msgstr ""
msgid "AUG"
msgstr ""
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr ""
msgid "About"
@@ -12368,7 +12368,7 @@ msgstr ""
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -12416,7 +12416,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
@@ -689,10 +689,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AGO"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "MARGEN DEL TÍTULO DEL EJE"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "POSICIÓN DEL TÍTULO DEL EJE"
msgid "About"
@@ -12368,7 +12368,7 @@ msgstr "Escribe una descripción para tu consulta"
msgid "Write a handlebars template to render the data"
msgstr "Elabora una plantilla de Handlebars para renderizar los datos"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "MARGEN DEL TÍTULO DEL EJE X"
msgid "X Axis"
@@ -12416,7 +12416,7 @@ msgstr "XYZ"
msgid "Y 2 bounds"
msgstr "Límites de Y 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "MARGEN DEL TÍTULO DEL EJE Y"
msgid "Y Axis"
@@ -767,10 +767,10 @@ msgstr ""
msgid "AUG"
msgstr "آگوست"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "حاشیه عنوان محور"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "محل عنوان محور"
msgid "About"
@@ -13425,7 +13425,7 @@ msgstr "توضیحی برای کوئری خود بنویسید."
msgid "Write a handlebars template to render the data"
msgstr "یک الگوی هندل‌بارز برای نمایش داده‌ها بنویسید."
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "حاشیه عنوان محور ایکس"
msgid "X Axis"
@@ -13474,7 +13474,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "بازه‌های Y ۲"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "حاشیه عنوان محور Y"
msgid "Y Axis"
@@ -852,10 +852,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AUG"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "MARGE DU TITRE DE L'AXE"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "POSITION DU TITRE DE L'AXE"
msgid "About"
@@ -16095,7 +16095,7 @@ msgstr "Écrire une description pour votre requête"
msgid "Write a handlebars template to render the data"
msgstr "Écrire un modèle handlebar pour afficher les données"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "MARGE DU TITRE DE L'AXE DES ABCISSES"
msgid "X Axis"
@@ -16148,7 +16148,7 @@ msgstr "XYZ"
msgid "Y 2 bounds"
msgstr "Limites ordonnées 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "MARGE DU TITRE DE L'AXE DES ORDONNÉES"
msgid "Y Axis"
@@ -718,11 +718,11 @@ msgstr ""
msgid "AUG"
msgstr ""
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
#, fuzzy
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "Testa la Connessione"
msgid "About"
@@ -13284,7 +13284,7 @@ msgstr ""
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -13336,7 +13336,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
@@ -725,10 +725,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "8月"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "軸のタイトルの余白"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "軸タイトルの位置"
msgid "About"
@@ -12622,7 +12622,7 @@ msgstr "クエリの説明を書いてください"
msgid "Write a handlebars template to render the data"
msgstr "データをレンダリングするハンドルバー テンプレートを作成する"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "X 軸のタイトルマージン"
msgid "X Axis"
@@ -12670,7 +12670,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 2 バウンド"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Y 軸のタイトルマージン"
msgid "Y Axis"
@@ -715,10 +715,10 @@ msgstr ""
msgid "AUG"
msgstr ""
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr ""
msgid "About"
@@ -13159,7 +13159,7 @@ msgstr ""
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -13209,7 +13209,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
+4 -4
View File
@@ -695,10 +695,10 @@ msgstr ""
msgid "AUG"
msgstr ""
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr ""
msgid "About"
@@ -12353,7 +12353,7 @@ msgstr ""
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -12401,7 +12401,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
@@ -757,10 +757,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AUG"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "AXIS TITEL MARGIN"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "AXIS TITEL POSITIE"
msgid "About"
@@ -13691,7 +13691,7 @@ msgstr "Schrijf een omschrijving voor uw query"
msgid "Write a handlebars template to render the data"
msgstr "Schrijf een handlebars sjabloon om de gegevens weer te geven"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "X AXIS TITEL MARGE"
msgid "X Axis"
@@ -13740,7 +13740,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 2 grenzen"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Y AXIS TITEL MARGE"
msgid "Y Axis"
@@ -796,11 +796,11 @@ msgstr "AQE"
msgid "AUG"
msgstr "SIE"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "MARGINES TYTUŁU OSI"
#, fuzzy
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "POZYCJA TYTUŁU OSI"
msgid "About"
@@ -14280,7 +14280,7 @@ msgstr "Napisz opis swojego zapytania"
msgid "Write a handlebars template to render the data"
msgstr "Napisz szablon handlebars do renderowania danych"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "MARGINES TYTUŁU OSI X"
msgid "X Axis"
@@ -14335,7 +14335,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Granice osi Y 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "MARGINES TYTUŁU OSI Y"
msgid "Y Axis"
@@ -726,10 +726,10 @@ msgstr ""
msgid "AUG"
msgstr ""
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr ""
msgid "About"
@@ -13486,7 +13486,7 @@ msgstr "Escreva uma descrição para sua consulta"
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -13537,7 +13537,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
@@ -769,10 +769,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AGO"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "MARGEM DO EIXO DO TÍTULO "
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "POSIÇÃO DO EIXO DO TÍTULO"
msgid "About"
@@ -13862,7 +13862,7 @@ msgstr "Escreva uma descrição para sua consulta"
msgid "Write a handlebars template to render the data"
msgstr "Escreva um modelo de guidão para renderizar os dados"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "MARGEM DO TÍTULO DO EIXO X"
msgid "X Axis"
@@ -13912,7 +13912,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 2 limites"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "MARGEM DO TÍTULO DO EIXO Y"
msgid "Y Axis"
@@ -799,10 +799,10 @@ msgstr "Асинхронные запросы"
msgid "AUG"
msgstr "АВГ"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "Отступ заголовка оси"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "Положение заголовка оси"
msgid "About"
@@ -14035,7 +14035,7 @@ msgstr "Заполните описание к вашему запросу"
msgid "Write a handlebars template to render the data"
msgstr "Напишите шаблон Handlebars для отображения данных"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "Отступ заголовка оси X"
msgid "X Axis"
@@ -14089,7 +14089,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Границы оси Y 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Отступ заголовка оси Y"
msgid "Y Axis"
@@ -693,10 +693,10 @@ msgstr ""
msgid "AUG"
msgstr ""
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr ""
msgid "About"
@@ -12502,7 +12502,7 @@ msgstr ""
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -12550,7 +12550,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
@@ -787,10 +787,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "AVG"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "OBROBA OZNAKE OSI"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "POLOŽAJ OZNAKE OSI"
msgid "About"
@@ -13457,7 +13457,7 @@ msgstr "Dodajte opis vaše poizvedbe"
msgid "Write a handlebars template to render the data"
msgstr "Napišite Handlebars-predlogo za prikaz podatkov"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "OBROBA NASLOVA X-OSI"
msgid "X Axis"
@@ -13506,7 +13506,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Meje Y-osi 2"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "OBROBA NASLOVA Y-OSI"
msgid "Y Axis"
@@ -691,10 +691,10 @@ msgstr ""
msgid "AUG"
msgstr "AĞU"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr ""
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr ""
msgid "About"
@@ -12543,7 +12543,7 @@ msgstr ""
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -12591,7 +12591,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr ""
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr ""
msgid "Y Axis"
@@ -759,10 +759,10 @@ msgstr "AQE"
msgid "AUG"
msgstr "Серпень"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "ЗАВДАННЯ ВІСІВ"
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "Позиція заголовка вісь"
msgid "About"
@@ -13662,7 +13662,7 @@ msgstr "Напишіть опис свого запиту"
msgid "Write a handlebars template to render the data"
msgstr "Напишіть шаблон ручки для надання даних"
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr ""
msgid "X Axis"
@@ -13712,7 +13712,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 2 межі"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Y Exis title Margin"
msgid "Y Axis"
@@ -730,11 +730,11 @@ msgstr "异步执行查询"
msgid "AUG"
msgstr "八月"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "轴标题边距"
#, fuzzy
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "轴标题的位置"
msgid "About"
@@ -13364,7 +13364,7 @@ msgstr "为您的查询写一段描述"
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "X 轴标题边距"
msgid "X Axis"
@@ -13416,7 +13416,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 界限"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Y 轴标题边距"
msgid "Y Axis"
@@ -729,11 +729,11 @@ msgstr "異步執行查詢"
msgid "AUG"
msgstr "八月"
msgid "AXIS TITLE MARGIN"
msgid "Axis title margin"
msgstr "軸標題邊距"
#, fuzzy
msgid "AXIS TITLE POSITION"
msgid "Axis title position"
msgstr "軸標題的位置"
msgid "About"
@@ -13378,7 +13378,7 @@ msgstr "為您的查詢寫一段描述"
msgid "Write a handlebars template to render the data"
msgstr ""
msgid "X AXIS TITLE MARGIN"
msgid "X axis title margin"
msgstr "X 軸標題邊距"
msgid "X Axis"
@@ -13430,7 +13430,7 @@ msgstr ""
msgid "Y 2 bounds"
msgstr "Y 界限"
msgid "Y AXIS TITLE MARGIN"
msgid "Y axis title margin"
msgstr "Y 軸標題邊距"
msgid "Y Axis"
+17 -3
View File
@@ -216,6 +216,13 @@ class WebDriverPlaywright(WebDriverProxy):
return error_messages
@staticmethod
def _get_screenshot(page: Page, element: Locator, element_name: str) -> bytes:
if element_name == "standalone":
return page.screenshot(full_page=True)
else:
return element.screenshot()
def get_screenshot( # pylint: disable=too-many-locals, too-many-statements # noqa: C901
self, url: str, element_name: str, user: User
) -> bytes | None:
@@ -363,11 +370,18 @@ class WebDriverPlaywright(WebDriverProxy):
"falling back to standard screenshot"
)
)
img = element.screenshot()
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
else:
img = element.screenshot()
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
else:
img = element.screenshot()
img = WebDriverPlaywright._get_screenshot(
page, element, element_name
)
except PlaywrightTimeout:
# raise again for the finally block, but handled above
pass
+13 -4
View File
@@ -46,7 +46,6 @@ from sqlalchemy.exc import SQLAlchemyError
from werkzeug.utils import safe_join
from superset import (
appbuilder,
db,
event_logger,
is_feature_enabled,
@@ -80,6 +79,7 @@ from superset.models.slice import Slice
from superset.models.sql_lab import Query
from superset.models.user_attributes import UserAttribute
from superset.superset_typing import FlaskResponse
from superset.tasks.utils import get_current_user
from superset.utils import core as utils, json
from superset.utils.cache import etag_cache
from superset.utils.core import (
@@ -108,6 +108,7 @@ from superset.views.utils import (
get_form_data,
get_viz,
loads_request_json,
redirect_to_login,
sanitize_datasource_data,
)
from superset.viz import BaseViz
@@ -765,13 +766,21 @@ class Superset(BaseSupersetView):
dashboard = Dashboard.get(dashboard_id_or_slug)
if not dashboard:
if not get_current_user():
return redirect_to_login()
abort(404)
# Redirect anonymous users to login for unpublished dashboards,
# in the edge case where a dataset has been shared with public
if not get_current_user() and not dashboard.published:
return redirect_to_login()
try:
dashboard.raise_for_access()
except SupersetSecurityException:
# Return 404 to avoid revealing dashboard existence
return Response(status=404)
if not get_current_user():
return redirect_to_login()
abort(404)
add_extra_log_payload(
dashboard_id=dashboard.id,
dashboard_version="v2",
@@ -882,7 +891,7 @@ class Superset(BaseSupersetView):
def welcome(self) -> FlaskResponse:
"""Personalized welcome page"""
if not g.user or not get_user_id():
return redirect(appbuilder.get_url_for_login)
return redirect_to_login()
if welcome_dashboard_id := (
db.session.query(UserAttribute.welcome_dashboard_id)
+12 -5
View File
@@ -25,7 +25,6 @@ from typing import Any, Callable, cast
from flask import (
Flask,
redirect,
request,
Response,
send_file,
@@ -34,7 +33,6 @@ from flask_wtf.csrf import CSRFError
from sqlalchemy import exc
from werkzeug.exceptions import HTTPException
from superset import appbuilder
from superset.commands.exceptions import CommandException, CommandInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
@@ -46,6 +44,7 @@ from superset.exceptions import (
from superset.superset_typing import FlaskResponse
from superset.utils import core as utils, json
from superset.utils.log import get_logger_from_status
from superset.views.utils import redirect_to_login
if typing.TYPE_CHECKING:
from superset.views.base import BaseSupersetView
@@ -153,7 +152,7 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
if request.is_json:
return show_http_exception(ex)
return redirect(appbuilder.get_url_for_login)
return redirect_to_login()
@app.errorhandler(HTTPException)
def show_http_exception(ex: HTTPException) -> FlaskResponse:
@@ -165,7 +164,11 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
and ex.code in {404, 500}
):
path = files("superset") / f"static/assets/{ex.code}.html"
return send_file(path, max_age=0), ex.code
# Try to serve HTML file; fall back to JSON if not built
try:
return send_file(path, max_age=0), ex.code
except FileNotFoundError:
pass
return json_error_response(
[
@@ -189,7 +192,11 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]:
path = files("superset") / "static/assets/500.html"
return send_file(path, max_age=0), 500
# Try to serve HTML file; fall back to JSON if not built
try:
return send_file(path, max_age=0), 500
except FileNotFoundError:
pass
extra = ex.normalized_messages() if isinstance(ex, CommandInvalidError) else {}
return json_error_response(
+31 -3
View File
@@ -19,16 +19,17 @@ import logging
from collections import defaultdict
from functools import wraps
from typing import Any, Callable, DefaultDict, Optional, Union
from urllib import parse
import msgpack
import pyarrow as pa
from flask import current_app as app, g, has_request_context, request
from flask import current_app as app, g, has_request_context, redirect, request
from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.models import User
from flask_babel import _
from sqlalchemy.exc import NoResultFound
from superset import dataframe, db, result_set, viz
from superset import appbuilder, dataframe, db, result_set, viz
from superset.common.db_query_status import QueryStatus
from superset.daos.datasource import DatasourceDAO
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -44,7 +45,7 @@ from superset.models.core import Database
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import Query
from superset.superset_typing import FormData
from superset.superset_typing import FlaskResponse, FormData
from superset.utils import json
from superset.utils.core import DatasourceType
from superset.utils.decorators import stats_timing
@@ -58,6 +59,33 @@ if not feature_flag_manager.is_feature_enabled("ENABLE_JAVASCRIPT_CONTROLS"):
REJECTED_FORM_DATA_KEYS = ["js_tooltip", "js_onclick_href", "js_data_mutator"]
def redirect_to_login(next_target: str | None = None) -> FlaskResponse:
"""Return a redirect response to the login view, preserving target URL.
When ``next_target`` is ``None`` the current request path (including query
string) is used, provided a request context is available. The resulting URL
always remains relative, mirroring Flask-AppBuilder expectations.
"""
login_url = appbuilder.get_url_for_login
parsed = parse.urlparse(login_url)
query = parse.parse_qs(parsed.query, keep_blank_values=True)
target = next_target
if target is None and has_request_context():
if request.query_string:
target = request.full_path.rstrip("?")
else:
target = request.path
if target:
query["next"] = [target]
encoded_query = parse.urlencode(query, doseq=True)
redirect_url = parse.urlunparse(parsed._replace(query=encoded_query))
return redirect(redirect_url)
def sanitize_datasource_data(datasource_data: dict[str, Any]) -> dict[str, Any]:
if datasource_data:
datasource_database = datasource_data.get("database")
+68 -6
View File
@@ -18,8 +18,8 @@
"""Unit tests for Superset"""
import re
import unittest
from random import random
from urllib.parse import parse_qs, urlparse
import pytest
from flask import Response, escape, url_for
@@ -52,7 +52,7 @@ from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_data, # noqa: F401
)
from .base_tests import SupersetTestCase
from .base_tests import DEFAULT_PASSWORD, SupersetTestCase
class TestDashboard(SupersetTestCase):
@@ -186,6 +186,72 @@ class TestDashboard(SupersetTestCase):
# Cleanup
self.revoke_public_access_to_table(table)
@pytest.mark.usefixtures(
"load_energy_table_with_slice",
"load_dashboard",
)
def test_anonymous_user_redirects_to_login_with_next(self):
self.logout()
target_path = f"/superset/dashboard/{pytest.hidden_dash_slug}/"
response = self.client.get(target_path, follow_redirects=False)
assert response.status_code == 302
redirect_location = response.headers["Location"]
parsed = urlparse(redirect_location)
assert parsed.path.rstrip("/") == "/login"
next_values = parse_qs(parsed.query).get("next")
assert next_values is not None
assert next_values[0].endswith(target_path)
login_target = (
f"{parsed.path}{'?' + parsed.query if parsed.query else ''}"
if parsed.scheme or parsed.netloc
else redirect_location
)
login_response = self.client.post(
login_target,
data={"username": ADMIN_USERNAME, "password": DEFAULT_PASSWORD},
follow_redirects=False,
)
assert login_response.status_code == 302
assert login_response.headers["Location"].endswith(target_path)
target_response: Response = self.client.get(target_path, follow_redirects=False)
assert target_response.status_code == 200
def test_anonymous_user_redirects_to_login_for_missing_dashboard(self):
self.logout()
target_path = "/superset/dashboard/nonexistent-dashboard/"
response = self.client.get(target_path, follow_redirects=False)
assert response.status_code == 302
parsed = urlparse(response.headers["Location"])
assert parsed.path.rstrip("/") == "/login"
next_values = parse_qs(parsed.query).get("next")
assert next_values is not None
assert next_values[0].endswith(target_path)
@pytest.mark.usefixtures(
"public_role_like_gamma",
"load_energy_table_with_slice",
"load_dashboard",
)
def test_authenticated_user_without_access_gets_404(self):
self.login(GAMMA_USERNAME)
target_path = f"/superset/dashboard/{pytest.hidden_dash_slug}/"
response = self.client.get(
target_path,
follow_redirects=False,
headers={"Accept": "text/html"},
)
assert response.status_code == 404
@pytest.mark.usefixtures(
"public_role_like_gamma",
"load_energy_table_with_slice",
@@ -248,7 +314,3 @@ class TestDashboard(SupersetTestCase):
db.session.commit()
assert f"/superset/dashboard/{slug}/" not in resp
if __name__ == "__main__":
unittest.main()
@@ -108,7 +108,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
# act
response = self.get_dashboard_view_response(dashboard_to_access)
assert response.status_code == 404
assert response.status_code == 404 # Authenticated users without access get 404
request_payload = get_query_context("birth_names")
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
@@ -221,7 +221,8 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
assert response.status_code == 404
# Anonymous users are redirected to login instead of getting 404
assert response.status_code == 302
@pytest.mark.usefixtures("public_role_like_gamma")
def test_get_dashboard_view__public_user_with_dashboard_permission_can_not_access_draft( # noqa: E501
@@ -234,7 +235,8 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
response = self.get_dashboard_view_response(dashboard_to_access)
# assert
assert response.status_code == 404
# Anonymous users are redirected to login for unpublished dashboards
assert response.status_code == 302
# post
revoke_access_to_dashboard(dashboard_to_access, "Public") # noqa: F405
@@ -190,6 +190,8 @@ def delete_all_inserted_objects() -> None:
def delete_all_inserted_dashboards():
try:
# Expire all objects to ensure fresh state after potential rollbacks
db.session.expire_all()
dashboards_to_delete: list[Dashboard] = (
db.session.query(Dashboard)
.filter(Dashboard.id.in_(inserted_dashboards_ids))
@@ -16,7 +16,7 @@
# under the License.
from random import randint
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from flask_appbuilder.security.sqla.models import User
@@ -25,7 +25,7 @@ from freezegun.api import FakeDatetime
from superset.extensions import db
from superset.reports.models import ReportScheduleType
from superset.tasks.scheduler import execute, scheduler
from superset.tasks.scheduler import execute, log_task_failure, scheduler
from tests.integration_tests.reports.utils import insert_report_schedule
from tests.integration_tests.test_app import app
@@ -201,3 +201,48 @@ def test_execute_task_with_command_exception(
db.session.delete(report_schedule)
db.session.commit()
@patch("superset.tasks.scheduler.logger")
def test_log_task_failure_with_sender(logger_mock):
"""
Test that log_task_failure logs correctly when sender is provided
"""
mock_task = MagicMock()
mock_task.name = "test.task.name"
mock_exception = Exception("Test error")
mock_einfo = MagicMock()
log_task_failure(
sender=mock_task,
task_id="test-task-id",
exception=mock_exception,
einfo=mock_einfo,
)
logger_mock.exception.assert_called_once_with(
"Celery task %s failed: %s",
"test.task.name",
mock_exception,
exc_info=mock_einfo,
)
@patch("superset.tasks.scheduler.logger")
def test_log_task_failure_without_sender(logger_mock):
"""
Test that log_task_failure logs correctly when sender is None
"""
mock_exception = Exception("Test error")
mock_einfo = MagicMock()
log_task_failure(
sender=None,
task_id="test-task-id",
exception=mock_exception,
einfo=mock_einfo,
)
logger_mock.exception.assert_called_once_with(
"Celery task %s failed: %s", "Unknown", mock_exception, exc_info=mock_einfo
)
+122
View File
@@ -19,9 +19,14 @@
import pytest
import prison
import uuid
import yaml
from datetime import datetime
from freezegun import freeze_time
from io import BytesIO
from sqlalchemy.sql import func
from typing import Any
from zipfile import ZipFile
import tests.integration_tests.test_app # noqa: F401
from superset import db
@@ -399,3 +404,120 @@ class TestThemeApi(SupersetTestCase):
uri = f"api/v1/theme/?q={prison.dumps(theme_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 404
def create_theme_import_zip(self, theme_config: dict[str, Any]) -> BytesIO:
"""Helper method to create a theme import ZIP file"""
buf = BytesIO()
with ZipFile(buf, "w") as bundle:
# Use a root folder like the export does
root = "theme_import"
# Add metadata.yaml
metadata = {
"version": "1.0.0",
"type": "Theme",
"timestamp": datetime.now().isoformat(),
}
with bundle.open(f"{root}/metadata.yaml", "w") as fp:
fp.write(yaml.safe_dump(metadata).encode())
# Add theme YAML file
theme_yaml = yaml.safe_dump(theme_config)
with bundle.open(
f"{root}/themes/{theme_config['theme_name']}.yaml", "w"
) as fp:
fp.write(theme_yaml.encode())
buf.seek(0)
return buf
def test_import_theme(self):
"""
Theme API: Test import theme
"""
theme_config = {
"theme_name": "imported_theme",
"uuid": str(uuid.uuid4()),
"version": "1.0.0",
"json_data": {"colors": {"primary": "#007bff"}},
}
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/import/"
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert response == {"message": "Theme imported successfully"}
theme = db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).one()
assert theme.theme_name == "imported_theme"
# Cleanup
db.session.delete(theme)
db.session.commit()
def test_import_theme_overwrite(self):
"""
Theme API: Test import existing theme without and with overwrite
"""
theme_config = {
"theme_name": "overwrite_theme",
"uuid": str(uuid.uuid4()),
"version": "1.0.0",
"json_data": {"colors": {"primary": "#007bff"}},
}
self.login(ADMIN_USERNAME)
uri = "api/v1/theme/import/"
# First import
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert response == {"message": "Theme imported successfully"}
# Import again without overwrite flag - should fail with structured error
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 422
assert len(response["errors"]) == 1
error = response["errors"][0]
assert error["message"].startswith("Error importing theme")
assert error["error_type"] == "GENERIC_COMMAND_ERROR"
assert error["level"] == "warning"
assert f"themes/{theme_config['theme_name']}.yaml" in str(error["extra"])
assert "Theme already exists and `overwrite=true` was not passed" in str(
error["extra"]
)
# Import with overwrite flag - should succeed
buf = self.create_theme_import_zip(theme_config)
form_data = {
"formData": (buf, "theme_export.zip"),
"overwrite": "true",
}
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 200
assert response == {"message": "Theme imported successfully"}
# Cleanup
theme = db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).one()
db.session.delete(theme)
db.session.commit()
@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,166 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for CreateSemanticLayerCommand."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerExistsValidationError,
SemanticLayerInvalidError,
SemanticLayerRequiredFieldValidationError,
)
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
"""
Test successful semantic layer creation.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
dao.validate_uniqueness.return_value = True
dao.create.return_value = mock_layer
properties = {
"name": "test_layer",
"type": "cube",
"configuration": '{"url": "http://localhost:4000"}',
}
command = CreateSemanticLayerCommand(properties)
result = command.run()
assert result == mock_layer
dao.create.assert_called_once_with(attributes=properties)
def test_create_semantic_layer_missing_name(mocker: MockerFixture) -> None:
"""
Test create fails when name is missing.
"""
mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
properties = {
"type": "cube",
"configuration": '{"url": "http://localhost:4000"}',
}
command = CreateSemanticLayerCommand(properties)
with pytest.raises(SemanticLayerInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(
exc_info.value._exceptions[0], SemanticLayerRequiredFieldValidationError
)
def test_create_semantic_layer_missing_type(mocker: MockerFixture) -> None:
"""
Test create fails when type is missing.
"""
mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
properties = {
"name": "test_layer",
"configuration": '{"url": "http://localhost:4000"}',
}
command = CreateSemanticLayerCommand(properties)
with pytest.raises(SemanticLayerInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(
exc_info.value._exceptions[0], SemanticLayerRequiredFieldValidationError
)
def test_create_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
"""
Test create fails when name already exists.
"""
dao = mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
dao.validate_uniqueness.return_value = False
properties = {
"name": "existing_layer",
"type": "cube",
"configuration": '{"url": "http://localhost:4000"}',
}
command = CreateSemanticLayerCommand(properties)
with pytest.raises(SemanticLayerInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(exc_info.value._exceptions[0], SemanticLayerExistsValidationError)
def test_create_semantic_layer_multiple_errors(mocker: MockerFixture) -> None:
"""
Test create accumulates multiple validation errors.
"""
mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
properties = {
"configuration": '{"url": "http://localhost:4000"}',
}
command = CreateSemanticLayerCommand(properties)
with pytest.raises(SemanticLayerInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 2
def test_create_semantic_layer_with_optional_fields(mocker: MockerFixture) -> None:
"""
Test create with optional fields.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
dao.validate_uniqueness.return_value = True
dao.create.return_value = mock_layer
properties = {
"name": "test_layer",
"type": "cube",
"description": "Test description",
"configuration": '{"url": "http://localhost:4000"}',
"cache_timeout": 3600,
}
command = CreateSemanticLayerCommand(properties)
result = command.run()
assert result == mock_layer
dao.create.assert_called_once_with(attributes=properties)
@@ -0,0 +1,82 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for DeleteSemanticLayerCommand."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
"""
Test successful semantic layer deletion.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.delete.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.delete.return_value = None
command = DeleteSemanticLayerCommand("test-uuid")
result = command.run()
assert result is None
dao.delete.assert_called_once_with([mock_layer])
def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
"""
Test delete fails when semantic layer not found.
"""
dao = mocker.patch("superset.commands.semantic_layer.delete.SemanticLayerDAO")
dao.find_by_id.return_value = None
command = DeleteSemanticLayerCommand("nonexistent-uuid")
with pytest.raises(SemanticLayerNotFoundError):
command.run()
def test_delete_semantic_layer_cascades_views(mocker: MockerFixture) -> None:
"""
Test delete cascades to semantic views.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
# Mock semantic views that will be cascade deleted
mock_view1 = MagicMock()
mock_view2 = MagicMock()
mock_layer.semantic_views = [mock_view1, mock_view2]
dao = mocker.patch("superset.commands.semantic_layer.delete.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.delete.return_value = None
command = DeleteSemanticLayerCommand("test-uuid")
result = command.run()
assert result is None
dao.delete.assert_called_once_with([mock_layer])
@@ -0,0 +1,166 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for UpdateSemanticLayerCommand."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.exceptions import (
SemanticLayerExistsValidationError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
)
from superset.commands.semantic_layer.update import UpdateSemanticLayerCommand
def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
"""
Test successful semantic layer update.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.validate_update_uniqueness.return_value = True
dao.update.return_value = mock_layer
properties = {
"description": "Updated description",
"cache_timeout": 7200,
}
command = UpdateSemanticLayerCommand("test-uuid", properties)
result = command.run()
assert result == mock_layer
dao.update.assert_called_once_with(mock_layer, properties)
def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
"""
Test update fails when semantic layer not found.
"""
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
dao.find_by_id.return_value = None
properties = {"description": "Updated description"}
command = UpdateSemanticLayerCommand("nonexistent-uuid", properties)
with pytest.raises(SemanticLayerNotFoundError):
command.run()
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
"""
Test update fails when new name already exists.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.validate_update_uniqueness.return_value = False
properties = {"name": "existing_layer"}
command = UpdateSemanticLayerCommand("test-uuid", properties)
with pytest.raises(SemanticLayerInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(exc_info.value._exceptions[0], SemanticLayerExistsValidationError)
def test_update_semantic_layer_name_unchanged(mocker: MockerFixture) -> None:
"""
Test update with same name doesn't trigger uniqueness validation.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.update.return_value = mock_layer
properties = {"description": "Updated description"}
command = UpdateSemanticLayerCommand("test-uuid", properties)
result = command.run()
assert result == mock_layer
dao.validate_update_uniqueness.assert_not_called()
def test_update_semantic_layer_name_changed(mocker: MockerFixture) -> None:
"""
Test update with new name triggers uniqueness validation.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.validate_update_uniqueness.return_value = True
dao.update.return_value = mock_layer
properties = {"name": "new_layer_name"}
command = UpdateSemanticLayerCommand("test-uuid", properties)
result = command.run()
assert result == mock_layer
dao.validate_update_uniqueness.assert_called_once_with(
"test-uuid", "new_layer_name"
)
def test_update_semantic_layer_all_fields(mocker: MockerFixture) -> None:
"""
Test update with all fields.
"""
mock_layer = MagicMock()
mock_layer.uuid = "test-uuid"
mock_layer.name = "test_layer"
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
dao.find_by_id.return_value = mock_layer
dao.validate_update_uniqueness.return_value = True
dao.update.return_value = mock_layer
properties = {
"name": "updated_layer",
"description": "Updated description",
"type": "dbt",
"configuration": '{"token": "new-token"}',
"cache_timeout": 7200,
}
command = UpdateSemanticLayerCommand("test-uuid", properties)
result = command.run()
assert result == mock_layer
dao.update.assert_called_once_with(mock_layer, properties)
@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,210 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for CreateSemanticViewCommand."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
from superset.commands.semantic_view.create import CreateSemanticViewCommand
from superset.commands.semantic_view.exceptions import (
SemanticViewExistsValidationError,
SemanticViewInvalidError,
SemanticViewRequiredFieldValidationError,
)
def test_create_semantic_view_success(mocker: MockerFixture) -> None:
"""
Test successful semantic view creation.
"""
mock_layer = MagicMock()
mock_layer.uuid = "layer-uuid"
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
layer_dao.find_by_id.return_value = mock_layer
view_dao = mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
view_dao.validate_uniqueness.return_value = True
view_dao.create.return_value = mock_view
properties = {
"name": "test_view",
"semantic_layer_uuid": "layer-uuid",
"configuration": '{"columns": ["id", "name"]}',
}
command = CreateSemanticViewCommand(properties)
result = command.run()
assert result == mock_view
view_dao.create.assert_called_once_with(attributes=properties)
def test_create_semantic_view_missing_name(mocker: MockerFixture) -> None:
"""
Test create fails when name is missing.
"""
mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
properties = {
"semantic_layer_uuid": "layer-uuid",
"configuration": '{"columns": ["id"]}',
}
command = CreateSemanticViewCommand(properties)
with pytest.raises(SemanticViewInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(
exc_info.value._exceptions[0], SemanticViewRequiredFieldValidationError
)
def test_create_semantic_view_missing_semantic_layer_uuid(
mocker: MockerFixture,
) -> None:
"""
Test create fails when semantic_layer_uuid is missing.
"""
mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
properties = {
"name": "test_view",
"configuration": '{"columns": ["id"]}',
}
command = CreateSemanticViewCommand(properties)
with pytest.raises(SemanticViewInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(
exc_info.value._exceptions[0], SemanticViewRequiredFieldValidationError
)
def test_create_semantic_view_semantic_layer_not_found(mocker: MockerFixture) -> None:
"""
Test create fails when semantic layer not found.
"""
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
layer_dao.find_by_id.return_value = None
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
properties = {
"name": "test_view",
"semantic_layer_uuid": "nonexistent-uuid",
"configuration": '{"columns": ["id"]}',
}
command = CreateSemanticViewCommand(properties)
with pytest.raises(SemanticLayerNotFoundError):
command.run()
def test_create_semantic_view_duplicate_name(mocker: MockerFixture) -> None:
"""
Test create fails when name already exists in layer.
"""
mock_layer = MagicMock()
mock_layer.uuid = "layer-uuid"
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
layer_dao.find_by_id.return_value = mock_layer
view_dao = mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
view_dao.validate_uniqueness.return_value = False
properties = {
"name": "existing_view",
"semantic_layer_uuid": "layer-uuid",
"configuration": '{"columns": ["id"]}',
}
command = CreateSemanticViewCommand(properties)
with pytest.raises(SemanticViewInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(exc_info.value._exceptions[0], SemanticViewExistsValidationError)
def test_create_semantic_view_multiple_errors(mocker: MockerFixture) -> None:
"""
Test create accumulates multiple validation errors.
"""
mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
properties = {
"configuration": '{"columns": ["id"]}',
}
command = CreateSemanticViewCommand(properties)
with pytest.raises(SemanticViewInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 2
def test_create_semantic_view_with_optional_fields(mocker: MockerFixture) -> None:
"""
Test create with optional fields.
"""
mock_layer = MagicMock()
mock_layer.uuid = "layer-uuid"
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
layer_dao.find_by_id.return_value = mock_layer
view_dao = mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
view_dao.validate_uniqueness.return_value = True
view_dao.create.return_value = mock_view
properties = {
"name": "test_view",
"semantic_layer_uuid": "layer-uuid",
"configuration": '{"columns": ["id", "name"]}',
"cache_timeout": 1800,
}
command = CreateSemanticViewCommand(properties)
result = command.run()
assert result == mock_view
view_dao.create.assert_called_once_with(attributes=properties)
@@ -0,0 +1,58 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for DeleteSemanticViewCommand."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_view.delete import DeleteSemanticViewCommand
from superset.commands.semantic_view.exceptions import SemanticViewNotFoundError
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""
Test successful semantic view deletion.
"""
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
dao = mocker.patch("superset.commands.semantic_view.delete.SemanticViewDAO")
dao.find_by_id.return_value = mock_view
dao.delete.return_value = None
command = DeleteSemanticViewCommand("view-uuid")
result = command.run()
assert result is None
dao.delete.assert_called_once_with([mock_view])
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""
Test delete fails when semantic view not found.
"""
dao = mocker.patch("superset.commands.semantic_view.delete.SemanticViewDAO")
dao.find_by_id.return_value = None
command = DeleteSemanticViewCommand("nonexistent-uuid")
with pytest.raises(SemanticViewNotFoundError):
command.run()
@@ -0,0 +1,169 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Unit tests for UpdateSemanticViewCommand."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.commands.semantic_view.exceptions import (
SemanticViewExistsValidationError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
)
from superset.commands.semantic_view.update import UpdateSemanticViewCommand
def test_update_semantic_view_success(mocker: MockerFixture) -> None:
"""
Test successful semantic view update.
"""
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
mock_view.semantic_layer_uuid = "layer-uuid"
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
dao.find_by_id.return_value = mock_view
dao.validate_update_uniqueness.return_value = True
dao.update.return_value = mock_view
properties = {
"configuration": '{"columns": ["id", "name", "email"]}',
"cache_timeout": 3600,
}
command = UpdateSemanticViewCommand("view-uuid", properties)
result = command.run()
assert result == mock_view
dao.update.assert_called_once_with(mock_view, properties)
def test_update_semantic_view_not_found(mocker: MockerFixture) -> None:
"""
Test update fails when semantic view not found.
"""
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
dao.find_by_id.return_value = None
properties = {"configuration": '{"columns": ["id"]}'}
command = UpdateSemanticViewCommand("nonexistent-uuid", properties)
with pytest.raises(SemanticViewNotFoundError):
command.run()
def test_update_semantic_view_duplicate_name(mocker: MockerFixture) -> None:
"""
Test update fails when new name already exists in layer.
"""
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
mock_view.semantic_layer_uuid = "layer-uuid"
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
dao.find_by_id.return_value = mock_view
dao.validate_update_uniqueness.return_value = False
properties = {"name": "existing_view"}
command = UpdateSemanticViewCommand("view-uuid", properties)
with pytest.raises(SemanticViewInvalidError) as exc_info:
command.run()
assert len(exc_info.value._exceptions) == 1
assert isinstance(exc_info.value._exceptions[0], SemanticViewExistsValidationError)
def test_update_semantic_view_name_unchanged(mocker: MockerFixture) -> None:
"""
Test update with same name doesn't trigger uniqueness validation.
"""
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
mock_view.semantic_layer_uuid = "layer-uuid"
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
dao.find_by_id.return_value = mock_view
dao.update.return_value = mock_view
properties = {"configuration": '{"columns": ["id", "name"]}'}
command = UpdateSemanticViewCommand("view-uuid", properties)
result = command.run()
assert result == mock_view
dao.validate_update_uniqueness.assert_not_called()
def test_update_semantic_view_name_changed(mocker: MockerFixture) -> None:
"""
Test update with new name triggers uniqueness validation.
"""
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
mock_view.semantic_layer_uuid = "layer-uuid"
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
dao.find_by_id.return_value = mock_view
dao.validate_update_uniqueness.return_value = True
dao.update.return_value = mock_view
properties = {"name": "new_view_name"}
command = UpdateSemanticViewCommand("view-uuid", properties)
result = command.run()
assert result == mock_view
dao.validate_update_uniqueness.assert_called_once_with(
"view-uuid", "new_view_name", "layer-uuid"
)
def test_update_semantic_view_all_fields(mocker: MockerFixture) -> None:
"""
Test update with all fields.
"""
mock_view = MagicMock()
mock_view.uuid = "view-uuid"
mock_view.name = "test_view"
mock_view.semantic_layer_uuid = "layer-uuid"
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
dao.find_by_id.return_value = mock_view
dao.validate_update_uniqueness.return_value = True
dao.update.return_value = mock_view
properties = {
"name": "updated_view",
"configuration": '{"columns": ["id", "name", "email"]}',
"cache_timeout": 3600,
}
command = UpdateSemanticViewCommand("view-uuid", properties)
result = command.run()
assert result == mock_view
dao.update.assert_called_once_with(mock_view, properties)

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