Compare commits

..

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
0fdc637533 feat(themes): Add UUID reference system for dynamic theme configuration
Implements a UUID-based reference system that enables Superset themes to be
dynamically configured through external systems like Split.io, overcoming
payload size limitations.

## Problem
Split.io has a 1KB limit for JSON payloads, but Superset theme configurations
typically exceed 3KB. This prevented teams from managing themes dynamically
through feature flags without deployments.

## Solution
Instead of storing full theme objects, store lightweight UUID references:
- Before: `{"algorithm": "dark", "token": {...}, ...}` (3KB+)
- After: `{"uuid": "a7f3c8e2-4d1b-4c7a-9f8e-2b5d6c8a9e1f"}` (<100 bytes)

## Implementation Details

### Backend Changes
- **ResolveAndUpsertThemeCommand**: New command that resolves UUID references
  to full theme configurations and upserts them as system themes
- **Enhanced bootstrap data**: Modified `get_theme_bootstrap_data()` to
  dynamically resolve UUID references on every page load
- **Fallback support**: Graceful degradation to safe defaults if UUID
  resolution fails (empty object for default theme, dark algorithm for dark theme)

### Frontend Changes
- **UUID display in Theme Modal**: Added read-only UUID field with copy-to-clipboard
  functionality using existing CopyToClipboard component
- **Minimal styling**: Uses Label component with monospace font, following
  existing patterns

### How It Works
1. Store themes in Superset's Theme CRUD system (each gets a UUID)
2. Reference themes by UUID in Split.io or other configuration systems
3. On page load, the system:
   - Detects UUID references in theme configuration
   - Resolves them to full theme definitions from the database
   - Upserts as system themes for consistency
   - Falls back to safe defaults on errors

### Testing
Comprehensive test coverage including:
- UUID resolution scenarios (found, not found, invalid JSON)
- System theme upsert behavior (create new, update existing)
- Fallback configurations for different theme types
- UUID precedence over inline configuration

This architecture transforms Split.io's constraint into a feature, enabling
truly dynamic theme management with instant updates and no deployment required.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 13:51:09 -07:00
51 changed files with 1122 additions and 2411 deletions

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@v2
uses: actions/first-interaction@v1
continue-on-error: true
with:
repo-token: ${{ github.token }}

View File

@@ -28,9 +28,6 @@ const globals = require('globals');
const { defineConfig, globalIgnores } = require('eslint/config');
module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
},
globalIgnores(['build/**/*', '.docusaurus/**/*', 'node_modules/**/*']),
js.configs.recommended,
...ts.configs.recommended,
@@ -39,7 +36,7 @@ module.exports = defineConfig([
files: ['eslint.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
}
},
{
languageOptions: {
@@ -71,5 +68,5 @@ module.exports = defineConfig([
version: 'detect',
},
},
},
]);
}
])

View File

@@ -15,7 +15,7 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"eslint": "eslint ."
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
@@ -26,33 +26,33 @@
"@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23",
"antd": "^5.26.7",
"antd": "^5.26.3",
"docusaurus-plugin-less": "^2.0.2",
"less": "^4.4.0",
"less": "^4.3.0",
"less-loader": "^12.3.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-github-btn": "^1.4.0",
"react-svg-pan-zoom": "^3.13.1",
"swagger-ui-react": "^5.27.1"
"swagger-ui-react": "^5.26.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.8.1",
"@docusaurus/tsconfig": "^3.8.1",
"@eslint/js": "^9.32.0",
"@eslint/js": "^9.31.0",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3",
"typescript-eslint": "^8.37.0",
"webpack": "^5.101.0"
"webpack": "^5.99.9"
},
"browserslist": {
"production": [

View File

@@ -2205,16 +2205,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.31.0":
"@eslint/js@9.31.0", "@eslint/js@^9.31.0":
version "9.31.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.31.0.tgz#adb1f39953d8c475c4384b67b67541b0d7206ed8"
integrity sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==
"@eslint/js@^9.32.0":
version "9.32.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.32.0.tgz#a02916f58bd587ea276876cb051b579a3d75d091"
integrity sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==
"@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"
@@ -2517,10 +2512,10 @@
classnames "^2.3.2"
rc-util "^5.24.4"
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.3.0.tgz#9499ada078daca9dd99d01f0f0743ee1ab9e398b"
integrity sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.2.7":
version "2.2.7"
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.2.7.tgz#a2b97ecbb93280a3c424e51fa415b371b355d76a"
integrity sha512-Qggj4Z0AA2i5dJhzlfFSmg1Qrziu8dsdHOihROL5Kl18seO2Eh/ZaTYt2c8a/CyGaTChnFry7BEYew1+/fhSbA==
dependencies:
"@babel/runtime" "^7.23.2"
"@rc-component/portal" "^1.1.0"
@@ -3427,10 +3422,10 @@
dependencies:
"@types/estree" "*"
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
version "5.0.6"
@@ -3971,11 +3966,6 @@ accepts@~1.3.4, accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
acorn-import-phases@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
acorn-jsx@^5.0.0, acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -3988,7 +3978,12 @@ acorn-walk@^8.0.0:
dependencies:
acorn "^8.11.0"
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.8.2:
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.8.2:
version "8.14.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb"
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
acorn@^8.15.0:
version "8.15.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -4112,10 +4107,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.26.7:
version "5.26.7"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.7.tgz#e2f7e37330b27eec0de7a7789767975373f61602"
integrity sha512-iCyXN6+i2CUVEOSzzJKfbKeg115qoJhGvSkCh5uzAf9hANwHUOJQhsMn+KtN+Lx/2NQ6wfM7nGZ+7NPNO5Pn1w==
antd@^5.26.3:
version "5.26.3"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.3.tgz#cbbb7e1b48a972dc7b6ee8b6948f51cc91c263f8"
integrity sha512-M/s9Q39h/+G7AWnS6fbNxmAI9waTH4ti022GVEXBLq2j810V1wJ3UOQps13nEilzDNcyxnFN/EIbqIgS7wSYaA==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -4128,7 +4123,7 @@ antd@^5.26.7:
"@rc-component/mutate-observer" "^1.1.0"
"@rc-component/qrcode" "~1.0.0"
"@rc-component/tour" "~1.15.1"
"@rc-component/trigger" "^2.3.0"
"@rc-component/trigger" "^2.2.7"
classnames "^2.5.1"
copy-to-clipboard "^3.3.3"
dayjs "^1.11.11"
@@ -4158,7 +4153,7 @@ antd@^5.26.7:
rc-switch "~4.1.0"
rc-table "~7.51.1"
rc-tabs "~15.6.1"
rc-textarea "~1.10.1"
rc-textarea "~1.10.0"
rc-tooltip "~6.4.0"
rc-tree "~5.13.1"
rc-tree-select "~5.27.0"
@@ -4513,7 +4508,17 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.25.0:
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4:
version "4.24.4"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b"
integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==
dependencies:
caniuse-lite "^1.0.30001688"
electron-to-chromium "^1.5.73"
node-releases "^2.0.19"
update-browserslist-db "^1.1.1"
browserslist@^4.25.0:
version "4.25.0"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c"
integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==
@@ -4615,7 +4620,7 @@ 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.0, caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
version "1.0.30001714"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz#cfd27ff07e6fa20a0f45c7a10d28a0ffeaba2122"
integrity sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==
@@ -5898,6 +5903,11 @@ electron-to-chromium@^1.5.160:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz#9f6697de4339e24da8b234e4492a9ecb91f5989c"
integrity sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==
electron-to-chromium@^1.5.73:
version "1.5.138"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.138.tgz#319e775179bd0889ed96a04d4390d355fb315a44"
integrity sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -5942,10 +5952,10 @@ encodeurl@~2.0.0:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
enhanced-resolve@^5.17.2:
version "5.18.2"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464"
integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==
enhanced-resolve@^5.17.1:
version "5.18.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf"
integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@@ -6151,10 +6161,10 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
eslint-config-prettier@^10.1.8:
version "10.1.8"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-config-prettier@^10.1.5:
version "10.1.5"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782"
integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==
eslint-plugin-prettier@^5.5.1:
version "5.5.1"
@@ -8053,10 +8063,10 @@ less-loader@^12.3.0:
resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.3.0.tgz#d4a00361568be86a97da3df4f16954b0d4c15340"
integrity sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==
less@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/less/-/less-4.4.0.tgz#deaf881f4880ee80691beae925b8fac699d3a76d"
integrity sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==
less@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/less/-/less-4.3.0.tgz#ef0cfc260a9ca8079ed8d0e3512bda8a12c82f2a"
integrity sha512-X9RyH9fvemArzfdP8Pi3irr7lor2Ok4rOttDXBhlwDg+wKQsXOXgHWduAJE1EsF7JJx0w0bcO6BC6tCKKYnXKA==
dependencies:
copy-anything "^2.0.1"
parse-node-version "^1.0.1"
@@ -10704,10 +10714,10 @@ rc-tabs@~15.6.1:
rc-resize-observer "^1.0.0"
rc-util "^5.34.1"
rc-textarea@~1.10.0, rc-textarea@~1.10.1:
version "1.10.2"
resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.2.tgz#459e3574a95c32939c6793045a1e4db04cb514cc"
integrity sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==
rc-textarea@~1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.0.tgz#f8f962ef83be0b8e35db97cf03dbfb86ddd9c46c"
integrity sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==
dependencies:
"@babel/runtime" "^7.10.1"
classnames "^2.2.1"
@@ -12090,10 +12100,10 @@ swagger-client@^3.35.5:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
swagger-ui-react@^5.27.1:
version "5.27.1"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.27.1.tgz#315b59970c33933a5f62ca0f702789741dcedc7c"
integrity sha512-wwDoavIeJI/Pwiavn32FMJ5dfptz0BAOKjSrj7EdU22QdP3gdk9+MZHdzzjxWURmVj0kc0XoQfsFgjln0toJaw==
swagger-ui-react@^5.26.0:
version "5.26.0"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.26.0.tgz#b15a903d556cc0ec2a56a969beb9d5bc9ea52910"
integrity sha512-4e6bP9bdJyh+SqQW0lxulPn/SDno4+oWrKXsuon5Z9kjtV0zeoWEJ1c70Qxp8kN/c3caFwec8OyxDNhvo14pkw==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -12515,7 +12525,7 @@ unraw@^3.0.0:
resolved "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz"
integrity sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==
update-browserslist-db@^1.1.3:
update-browserslist-db@^1.1.1, update-browserslist-db@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420"
integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==
@@ -12784,27 +12794,26 @@ webpack-merge@^6.0.1:
flat "^5.0.2"
wildcard "^2.0.1"
webpack-sources@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723"
integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==
webpack-sources@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.101.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.0.tgz#4b81407ffad9857f81ff03f872e3369b9198cc9d"
integrity sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.9:
version "5.99.9"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.9.tgz#d7de799ec17d0cce3c83b70744b4aedb537d8247"
integrity sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
"@types/estree" "^1.0.6"
"@types/json-schema" "^7.0.15"
"@webassemblyjs/ast" "^1.14.1"
"@webassemblyjs/wasm-edit" "^1.14.1"
"@webassemblyjs/wasm-parser" "^1.14.1"
acorn "^8.15.0"
acorn-import-phases "^1.0.3"
acorn "^8.14.0"
browserslist "^4.24.0"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.17.2"
enhanced-resolve "^5.17.1"
es-module-lexer "^1.2.1"
eslint-scope "5.1.1"
events "^3.2.0"
@@ -12818,7 +12827,7 @@ webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
tapable "^2.1.1"
terser-webpack-plugin "^5.3.11"
watchpack "^2.4.1"
webpack-sources "^3.3.3"
webpack-sources "^3.2.3"
webpackbar@^6.0.1:
version "6.0.1"

View File

@@ -174,8 +174,6 @@ function Echart(
if (!chartRef.current) {
chartRef.current = init(divRef.current, null, { locale });
}
// did mount
handleSizeChange({ width, height });
setDidMount(true);
});
}, [locale]);
@@ -237,6 +235,9 @@ function Echart(
echartOptions,
);
chartRef.current?.setOption(themedEchartOptions, true);
// did mount
handleSizeChange({ width, height });
}
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme]);

View File

@@ -32,11 +32,13 @@ import {
Form,
Tooltip,
Alert,
Label,
} from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import { Typography } from '@superset-ui/core/components/Typography';
import { OnlyKeyWithType } from 'src/utils/types';
import { CopyToClipboard } from 'src/components/CopyToClipboard';
import { ThemeObject } from './types';
interface ThemeModalProps {
@@ -340,6 +342,27 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
/>
</Form.Item>
{currentTheme?.uuid && (
<Form.Item label={t('UUID')}>
<div
css={css`
display: flex;
align-items: center;
gap: ${supersetTheme.sizeUnit * 2}px;
`}
>
<Label monospace>{currentTheme.uuid}</Label>
<CopyToClipboard
text={currentTheme.uuid}
shouldShowText={false}
wrapped={false}
copyNode={<Icons.CopyOutlined iconSize="m" />}
tooltipText={t('Copy UUID to clipboard')}
/>
</div>
</Form.Item>
)}
<Form.Item label={t('JSON Configuration')} required={!isReadOnly}>
<Alert
type="info"

View File

@@ -0,0 +1,126 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Any
from superset.commands.base import BaseCommand
from superset.daos.theme import ThemeDAO
from superset.extensions import db
from superset.models.core import Theme
from superset.utils import json
from superset.utils.decorators import transaction
logger = logging.getLogger(__name__)
class ResolveAndUpsertThemeCommand(BaseCommand):
"""Command to resolve theme configuration and upsert to system theme."""
def __init__(self, theme_config: dict[str, Any], theme_name: str):
self._theme_config = theme_config
self._theme_name = theme_name
self._fallback_config = self._get_fallback_config()
def run(self) -> dict[str, Any]:
"""Resolve theme configuration and upsert to system theme."""
try:
self.validate()
# First resolve the theme configuration
resolved_config = self._resolve_theme_config()
# Then upsert to system theme
self._upsert_system_theme(resolved_config)
return resolved_config
except Exception as ex:
logger.error(
"Failed to resolve and upsert theme %s: %s. Using fallback.",
self._theme_name,
ex,
)
return self._fallback_config
def _get_fallback_config(self) -> dict[str, Any]:
"""Get fallback configuration based on theme name."""
if self._theme_name == "THEME_DARK":
return {"algorithm": "dark"}
return {}
def _resolve_theme_config(self) -> dict[str, Any]:
"""Resolve theme configuration, looking up UUID references if present."""
# Check if config contains a UUID reference
if isinstance(self._theme_config, dict) and "uuid" in self._theme_config:
uuid = self._theme_config["uuid"]
referenced_theme = ThemeDAO.find_by_uuid(uuid)
if referenced_theme and referenced_theme.json_data:
try:
resolved_config = json.loads(referenced_theme.json_data)
logger.debug(
"Resolved UUID reference %s for %s to theme definition",
uuid,
self._theme_name,
)
return resolved_config
except (ValueError, TypeError) as ex:
logger.error(
"Failed to parse theme JSON for UUID %s: %s",
uuid,
ex,
)
return self._fallback_config
else:
logger.error(
"Referenced theme with UUID %s not found for %s",
uuid,
self._theme_name,
)
return self._fallback_config
# Not a UUID reference, return as-is
return self._theme_config
@transaction()
def _upsert_system_theme(self, theme_config: dict[str, Any]) -> None:
"""Upsert the resolved theme configuration as a system theme."""
existing_theme = (
db.session.query(Theme)
.filter(Theme.theme_name == self._theme_name, Theme.is_system)
.first()
)
json_data = json.dumps(theme_config)
if existing_theme:
existing_theme.json_data = json_data
logger.info(f"Updated system theme: {self._theme_name}")
else:
new_theme = Theme(
theme_name=self._theme_name,
json_data=json_data,
is_system=True,
)
db.session.add(new_theme)
logger.info(f"Created system theme: {self._theme_name}")
def validate(self) -> None:
"""Validate that the theme config is a dictionary."""
if not isinstance(self._theme_config, dict):
self._theme_config = {}
if not self._theme_name:
raise ValueError("Theme name is required")

View File

@@ -145,7 +145,7 @@ def _get_samples(
query_obj = copy.copy(query_obj)
query_obj.is_timeseries = False
query_obj.orderby = []
query_obj.metrics = []
query_obj.metrics = None
query_obj.post_processing = []
qry_obj_cols = []
for o in datasource.columns:
@@ -168,7 +168,7 @@ def _get_drill_detail(
query_obj = copy.copy(query_obj)
query_obj.is_timeseries = False
query_obj.orderby = []
query_obj.metrics = []
query_obj.metrics = None
query_obj.post_processing = []
qry_obj_cols = []
for o in datasource.columns:

View File

@@ -86,8 +86,6 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
apply_fetch_values_predicate: bool
columns: list[Column]
datasource: BaseDatasource | None
columns_by_name: dict[str, Any]
metrics_by_name: dict[str, Any]
extras: dict[str, Any]
filter: list[QueryObjectFilterClause]
from_dttm: datetime | None
@@ -96,7 +94,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
inner_to_dttm: datetime | None
is_rowcount: bool
is_timeseries: bool
metrics: list[Metric]
metrics: list[Metric] | None
order_desc: bool
orderby: list[OrderBy]
post_processing: list[dict[str, Any]]
@@ -143,30 +141,6 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
self.apply_fetch_values_predicate = apply_fetch_values_predicate or False
self.columns = columns or []
self.datasource = datasource
# Build datasource mappings for easy lookup
self.columns_by_name: dict[str, Any] = {}
self.metrics_by_name: dict[str, Any] = {}
if datasource:
try:
if hasattr(datasource, "columns") and datasource.columns is not None:
self.columns_by_name = {
col.column_name: col for col in datasource.columns
}
except (TypeError, AttributeError):
# Handle mocked datasources or other non-iterable cases
pass
try:
if hasattr(datasource, "metrics") and datasource.metrics is not None:
self.metrics_by_name = {
metric.metric_name: metric for metric in datasource.metrics
}
except (TypeError, AttributeError):
# Handle mocked datasources or other non-iterable cases
pass
self.extras = extras or {}
self.filter = filters or []
self.granularity = granularity
@@ -218,12 +192,9 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def is_str_or_adhoc(metric: Metric) -> bool:
return isinstance(metric, str) or is_adhoc_metric(metric)
# Track whether metrics was originally None (for need_groupby logic)
self._metrics_is_not_none = metrics is not None
self.metrics = [
self.metrics = metrics and [
x if is_str_or_adhoc(x) else x["label"] # type: ignore
for x in (metrics or [])
for x in metrics
]
def _set_post_processing(
@@ -255,20 +226,15 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
field.new_name,
)
value = kwargs[field.old_name]
if value is not None:
# Only override if the new field is not already populated with data
current_value = getattr(self, field.new_name, None)
if (
current_value
): # If field already has truthy data, don't override
if value:
if hasattr(self, field.new_name):
logger.warning(
"The field `%s` is already populated, "
"not replacing with contents from deprecated `%s`.",
"replacing value with contents from `%s`.",
field.new_name,
field.old_name,
)
else:
setattr(self, field.new_name, value)
setattr(self, field.new_name, value)
def _move_deprecated_extra_fields(self, kwargs: dict[str, Any]) -> None:
# move deprecated extras fields to extras
@@ -281,8 +247,8 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
field.new_name,
)
value = kwargs[field.old_name]
if value is not None and value != "": # Don't add empty string values
if field.new_name in self.extras:
if value:
if hasattr(self.extras, field.new_name):
logger.warning(
"The field `%s` is already populated in "
"`extras`, replacing value with contents "
@@ -296,7 +262,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
def metric_names(self) -> list[str]:
"""Return metrics names (labels), coerce adhoc metrics to strings."""
return get_metric_names(
self.metrics,
self.metrics or [],
(
self.datasource.verbose_map
if self.datasource and hasattr(self.datasource, "verbose_map")
@@ -310,428 +276,6 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
and metrics are non-empty, otherwise returns column labels."""
return get_column_names(self.columns)
@property
def time_grain(self) -> str | None:
"""Get time grain from extras."""
return (self.extras or {}).get("time_grain_sqla")
@property
def need_groupby(self) -> bool:
"""Determine if GROUP BY is needed based on metrics and columns."""
# GROUP BY is needed when there are metrics or when metrics is explicitly
# provided (even as empty list). When metrics=None, columns are just for
# selection without aggregation, so no GROUP BY needed.
return self._metrics_is_not_none
@property
def groupby(self) -> list[Column]:
"""Alias for columns (for backward compatibility/clarity)."""
return self.columns or []
def get_series_limit_prequery_obj(
self,
granularity: str | None,
inner_from_dttm: datetime | None,
inner_to_dttm: datetime | None,
orderby: list[OrderBy] | None = None,
) -> dict[str, Any]:
"""Build prequery object for series limit queries.
This is used to determine top groups when series_limit is set.
Args:
granularity: The time column name
inner_from_dttm: Inner from datetime (if different from main query)
inner_to_dttm: Inner to datetime (if different from main query)
orderby: Optional orderby to override (for series_limit_metric)
Returns:
Dictionary suitable for passing to query()
"""
from superset.utils.core import get_non_base_axis_columns
return {
"is_timeseries": False,
"row_limit": self.series_limit,
"metrics": self.metrics,
"granularity": granularity,
"groupby": self.groupby,
"from_dttm": inner_from_dttm or self.from_dttm,
"to_dttm": inner_to_dttm or self.to_dttm,
"filter": self.filter,
"orderby": orderby or [],
"extras": self.extras or {},
"columns": get_non_base_axis_columns(self.columns),
"order_desc": True,
}
def build_select_expressions( # noqa: C901
self,
granularity: str | None,
series_column_labels: set[str],
datasource: Any, # BaseDatasource
template_processor: Any,
) -> tuple[list[Any], dict[str, Any], dict[str, Any]]:
"""Build SELECT expressions for the query.
Args:
granularity: The time column name
series_column_labels: Labels of series columns
datasource: The datasource being queried
template_processor: Template processor for SQL templating
Returns:
Tuple of (select_exprs, groupby_all_columns, groupby_series_columns)
"""
from sqlalchemy import literal_column
from superset.utils.core import (
DTTM_ALIAS,
is_adhoc_column,
)
select_exprs = []
groupby_all_columns = {}
groupby_series_columns = {}
# Filter out the pseudo column __timestamp from columns
columns = [col for col in self.columns if col != DTTM_ALIAS]
if self.need_groupby:
# dedup columns while preserving order
columns = self.groupby or self.columns
for selected in columns:
if isinstance(selected, str):
# if groupby field/expr equals granularity field/expr
if selected == granularity:
table_col = self.columns_by_name[selected]
outer = table_col.get_timestamp_expression(
time_grain=self.time_grain,
label=selected,
template_processor=template_processor,
)
# if groupby field equals a selected column
elif selected in self.columns_by_name:
outer = datasource.convert_tbl_column_to_sqla_col(
self.columns_by_name[selected],
template_processor=template_processor,
)
else:
# Import here to avoid circular imports
from superset.models.helpers import validate_adhoc_subquery
selected = validate_adhoc_subquery(
selected,
datasource.database,
datasource.catalog,
datasource.schema,
datasource.database.db_engine_spec.engine,
)
outer = literal_column(f"({selected})")
outer = datasource.make_sqla_column_compatible(outer, selected)
else:
outer = datasource.adhoc_column_to_sqla(
col=selected,
template_processor=template_processor,
)
groupby_all_columns[outer.name] = outer
if (
self.is_timeseries and not series_column_labels
) or outer.name in series_column_labels:
groupby_series_columns[outer.name] = outer
select_exprs.append(outer)
elif self.columns:
with datasource.database.get_sqla_engine() as engine:
quote = engine.dialect.identifier_preparer.quote
for selected in self.columns:
if is_adhoc_column(selected):
_sql = selected["sqlExpression"]
_column_label = selected["label"]
elif isinstance(selected, str):
_sql = quote(selected)
_column_label = selected
# Import here to avoid circular imports
from superset.models.helpers import validate_adhoc_subquery
selected = validate_adhoc_subquery(
_sql,
datasource.database,
datasource.catalog,
datasource.schema,
datasource.database.db_engine_spec.engine,
)
select_exprs.append(
datasource.convert_tbl_column_to_sqla_col(
self.columns_by_name[selected],
template_processor=template_processor,
label=_column_label,
)
if selected in self.columns_by_name
else datasource.make_sqla_column_compatible(
literal_column(selected), _column_label
)
)
return select_exprs, groupby_all_columns, groupby_series_columns
def build_filter_clauses( # noqa: C901
self,
datasource: Any, # BaseDatasource
template_processor: Any,
time_filters: list[Any],
removed_filters: set[str],
applied_adhoc_filters_columns: list[Any],
rejected_adhoc_filters_columns: list[Any],
is_timeseries: bool,
dttm_col: Any,
) -> tuple[list[Any], list[Any]]:
"""Build WHERE and HAVING filter clauses for the query.
Args:
datasource: The datasource being queried
template_processor: Template processor for SQL templating
time_filters: Time-based filters to apply
removed_filters: Set of filter column names handled by Jinja templates
applied_adhoc_filters_columns: List to track applied adhoc filters
rejected_adhoc_filters_columns: List to track rejected adhoc filters
is_timeseries: Whether this is a timeseries query
dttm_col: The datetime column object
Returns:
Tuple of (where_clause_and, having_clause_and)
"""
from flask import current_app
from sqlalchemy import or_
from superset import feature_flag_manager
from superset.common.utils.time_range_utils import (
get_since_until_from_time_range,
)
from superset.exceptions import QueryObjectValidationError
from superset.utils.core import (
DTTM_ALIAS,
FilterOperator,
GenericDataType,
get_column_name,
is_adhoc_column,
)
where_clause_and = []
having_clause_and = []
# Process regular filters
for flt in self.filter:
if not all(flt.get(s) for s in ["col", "op"]):
continue
flt_col = flt["col"]
val = flt.get("val")
flt_grain = flt.get("grain")
op = FilterOperator(flt["op"].upper())
col_obj = None
sqla_col = None
if flt_col == DTTM_ALIAS and is_timeseries and dttm_col:
col_obj = dttm_col
elif is_adhoc_column(flt_col):
try:
sqla_col = datasource.adhoc_column_to_sqla(
flt_col, force_type_check=True
)
applied_adhoc_filters_columns.append(flt_col)
except Exception: # ColumnNotFoundException
rejected_adhoc_filters_columns.append(flt_col)
continue
else:
col_obj = self.columns_by_name.get(str(flt_col))
filter_grain = flt.get("grain")
if get_column_name(flt_col) in removed_filters:
# Skip generating SQLA filter when the jinja template handles it.
continue
if col_obj or sqla_col is not None:
db_engine_spec = datasource.database.db_engine_spec
if sqla_col is not None:
pass
elif col_obj and filter_grain:
sqla_col = col_obj.get_timestamp_expression(
time_grain=filter_grain, template_processor=template_processor
)
elif col_obj:
sqla_col = datasource.convert_tbl_column_to_sqla_col(
tbl_column=col_obj, template_processor=template_processor
)
col_type = col_obj.type if col_obj else None
col_spec = db_engine_spec.get_column_spec(native_type=col_type)
is_list_target = op in (
FilterOperator.IN,
FilterOperator.NOT_IN,
)
col_advanced_data_type = col_obj.advanced_data_type if col_obj else ""
if col_spec and not col_advanced_data_type:
target_generic_type = col_spec.generic_type
else:
target_generic_type = GenericDataType.STRING
eq = datasource.filter_values_handler(
values=val,
operator=op,
target_generic_type=target_generic_type,
target_native_type=col_type,
is_list_target=is_list_target,
db_engine_spec=db_engine_spec,
)
# Get ADVANCED_DATA_TYPES from config when needed
ADVANCED_DATA_TYPES = current_app.config.get("ADVANCED_DATA_TYPES", {}) # noqa: N806
if (
col_advanced_data_type != ""
and feature_flag_manager.is_feature_enabled(
"ENABLE_ADVANCED_DATA_TYPES"
)
and col_advanced_data_type in ADVANCED_DATA_TYPES
and eq is not None
):
where_clause_and.append(
datasource._apply_advanced_data_type_filter(
sqla_col, col_advanced_data_type, op, eq
)
)
elif is_list_target:
assert isinstance(eq, (tuple, list))
if len(eq) == 0:
raise QueryObjectValidationError(
"Filter value list cannot be empty"
)
if len(eq) > len(
eq_without_none := [x for x in eq if x is not None]
):
is_null_cond = sqla_col.is_(None)
if eq:
cond = or_(is_null_cond, sqla_col.in_(eq_without_none))
else:
cond = is_null_cond
else:
cond = sqla_col.in_(eq)
if op == FilterOperator.NOT_IN:
cond = ~cond
where_clause_and.append(cond)
elif op in {
FilterOperator.IS_NULL,
FilterOperator.IS_NOT_NULL,
}:
where_clause_and.append(
db_engine_spec.handle_null_filter(sqla_col, op)
)
elif op == FilterOperator.IS_TRUE:
where_clause_and.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
)
elif op == FilterOperator.IS_FALSE:
where_clause_and.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, False)
)
else:
if (
op
not in {
FilterOperator.EQUALS,
FilterOperator.NOT_EQUALS,
}
and eq is None
):
raise QueryObjectValidationError(
"Must specify a value for filters with comparison operators"
)
if op in {
FilterOperator.EQUALS,
FilterOperator.NOT_EQUALS,
FilterOperator.GREATER_THAN,
FilterOperator.LESS_THAN,
FilterOperator.GREATER_THAN_OR_EQUALS,
FilterOperator.LESS_THAN_OR_EQUALS,
}:
where_clause_and.append(
db_engine_spec.handle_comparison_filter(sqla_col, op, eq)
)
elif op in {
FilterOperator.ILIKE,
FilterOperator.LIKE,
}:
if target_generic_type != GenericDataType.STRING:
import sqlalchemy as sa
sqla_col = sa.cast(sqla_col, sa.String)
if op == FilterOperator.LIKE:
where_clause_and.append(sqla_col.like(eq))
else:
where_clause_and.append(sqla_col.ilike(eq))
elif op in {FilterOperator.NOT_LIKE}:
if target_generic_type != GenericDataType.STRING:
import sqlalchemy as sa
sqla_col = sa.cast(sqla_col, sa.String)
where_clause_and.append(sqla_col.not_like(eq))
elif (
op == FilterOperator.TEMPORAL_RANGE
and isinstance(eq, str)
and col_obj is not None
):
_since, _until = get_since_until_from_time_range(
time_range=eq,
time_shift=self.time_shift,
extras=self.extras or {},
)
where_clause_and.append(
datasource.get_time_filter(
time_col=col_obj,
start_dttm=_since,
end_dttm=_until,
time_grain=flt_grain,
label=sqla_col.key,
template_processor=template_processor,
)
)
else:
raise QueryObjectValidationError(
f"Invalid filter operation type: {op}"
)
# Process WHERE and HAVING extras
if self.extras:
where = self.extras.get("where")
if where:
where = datasource._process_sql_expression(
expression=where,
database_id=datasource.database_id,
engine=datasource.database.backend,
schema=datasource.schema,
template_processor=template_processor,
)
where_clause_and += [datasource.text(where)]
having = self.extras.get("having")
if having:
having = datasource._process_sql_expression(
expression=having,
database_id=datasource.database_id,
engine=datasource.database.backend,
schema=datasource.schema,
template_processor=template_processor,
)
having_clause_and += [datasource.text(having)]
return where_clause_and, having_clause_and
def validate(
self, raise_exceptions: bool | None = True
) -> QueryObjectValidationError | None:
@@ -806,7 +350,7 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
"inner_to_dttm": self.inner_to_dttm,
"is_rowcount": self.is_rowcount,
"is_timeseries": self.is_timeseries,
"metrics": self.metrics if self.metrics else None,
"metrics": self.metrics,
"order_desc": self.order_desc,
"orderby": self.orderby,
"row_limit": self.row_limit,

View File

@@ -71,7 +71,6 @@ from sqlalchemy.types import JSON
from superset import db, is_feature_enabled, security_manager
from superset.commands.dataset.exceptions import DatasetNotFoundError
from superset.common.db_query_status import QueryStatus
from superset.common.query_object import QueryObject
from superset.connectors.sqla.utils import (
get_columns_description,
get_physical_table_metadata,
@@ -721,7 +720,7 @@ class AnnotationDatasource(BaseDatasource):
def query(self, query_obj: QueryObjectDict) -> QueryResult:
error_message = None
qry = db.session.query(Annotation)
qry = qry.filter(Annotation.layer_id == query_obj["filters"][0]["val"])
qry = qry.filter(Annotation.layer_id == query_obj["filter"][0]["val"])
if query_obj["from_dttm"]:
qry = qry.filter(Annotation.start_dttm >= query_obj["from_dttm"])
if query_obj["to_dttm"]:
@@ -1515,19 +1514,18 @@ class SqlaTable(
def _get_series_orderby(
self,
series_limit_metric: Metric,
query_obj: QueryObject,
metrics_by_name: dict[str, SqlMetric],
columns_by_name: dict[str, TableColumn],
template_processor: BaseTemplateProcessor | None = None,
) -> Column:
if utils.is_adhoc_metric(series_limit_metric):
assert isinstance(series_limit_metric, dict)
ob = self.adhoc_metric_to_sqla(
series_limit_metric, query_obj.columns_by_name
)
ob = self.adhoc_metric_to_sqla(series_limit_metric, columns_by_name)
elif (
isinstance(series_limit_metric, str)
and series_limit_metric in query_obj.metrics_by_name
and series_limit_metric in metrics_by_name
):
ob = query_obj.metrics_by_name[series_limit_metric].get_sqla_col(
ob = metrics_by_name[series_limit_metric].get_sqla_col(
template_processor=template_processor
)
else:
@@ -1859,10 +1857,7 @@ class SqlaTable(
"""
extra_cache_keys = super().get_extra_cache_keys(query_obj)
if self.has_extra_cache_key_calls(query_obj):
from superset.common.query_object import QueryObject
query_object = QueryObject(datasource=self, **query_obj)
sqla_query = self.get_sqla_query(query_object)
sqla_query = self.get_sqla_query(**query_obj)
extra_cache_keys += sqla_query.extra_cache_keys
return list(set(extra_cache_keys))

View File

@@ -940,7 +940,7 @@ def dataset_macro(
metrics = [metric.metric_name for metric in dataset.metrics]
query_obj = {
"is_timeseries": False,
"filters": [],
"filter": [],
"metrics": metrics if include_metrics else None,
"columns": columns,
"from_dttm": from_dttm,

View File

@@ -37,7 +37,7 @@ from superset.migrations.shared.security_converge import (
)
from superset.models.core import Database
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
Base: Type[Any] = declarative_base()

View File

@@ -28,7 +28,7 @@ from superset.migrations.shared.utils import paginated_update, try_load_json
from superset.utils import json
from superset.utils.date_parser import get_since_until
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
Base = declarative_base()

View File

@@ -29,7 +29,7 @@ from sqlalchemy import (
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Load, relationship, Session
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
Base = declarative_base()

View File

@@ -49,7 +49,7 @@ YELLOW = "\033[33m"
RED = "\033[31m"
LRED = "\033[91m"
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
DEFAULT_BATCH_SIZE = int(os.environ.get("BATCH_SIZE", 1000))

View File

@@ -33,8 +33,6 @@ from superset.utils.core import generic_find_constraint_name
revision = "1226819ee0e3"
down_revision = "956a063c52b3"
logger = logging.getLogger("alembic.env")
naming_convention = {
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s"
@@ -63,7 +61,7 @@ def upgrade():
["datasource_name"],
)
except: # noqa: E722
logger.warning("Could not find or drop constraint on `columns`")
logging.warning("Could not find or drop constraint on `columns`")
def downgrade():

View File

@@ -38,8 +38,6 @@ from superset.utils.core import generic_find_constraint_name
revision = "3b626e2a6783"
down_revision = "eca4694defa7"
logger = logging.getLogger("alembic.env")
def upgrade():
# cleanup after: https://github.com/airbnb/superset/pull/1078
@@ -62,7 +60,7 @@ def upgrade():
batch_op.drop_column("druid_datasource_id")
batch_op.drop_column("table_id")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
# fixed issue: https://github.com/airbnb/superset/issues/466
try:
@@ -71,18 +69,18 @@ def upgrade():
None, "datasources", ["datasource_name"], ["datasource_name"]
)
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
with op.batch_alter_table("query") as batch_op:
batch_op.create_unique_constraint("client_id", ["client_id"])
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
with op.batch_alter_table("query") as batch_op:
batch_op.drop_column("name")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
def downgrade():
@@ -90,7 +88,7 @@ def downgrade():
with op.batch_alter_table("tables") as batch_op:
batch_op.create_index("table_name", ["table_name"], unique=True)
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
with op.batch_alter_table("slices") as batch_op:
@@ -115,7 +113,7 @@ def downgrade():
)
batch_op.create_foreign_key("slices_ibfk_2", "tables", ["table_id"], ["id"])
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
fk_columns = generic_find_constraint_name(
@@ -127,11 +125,11 @@ def downgrade():
with op.batch_alter_table("columns") as batch_op:
batch_op.drop_constraint(fk_columns, type_="foreignkey")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
op.add_column("query", sa.Column("name", sa.String(length=256), nullable=True))
try:
with op.batch_alter_table("query") as batch_op:
batch_op.drop_constraint("client_id", type_="unique")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))

View File

@@ -31,8 +31,6 @@ import logging # noqa: E402
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
logger = logging.getLogger("alembic.env")
def upgrade():
op.add_column("tables", sa.Column("params", sa.Text(), nullable=True))
@@ -42,4 +40,4 @@ def downgrade():
try:
op.drop_column("tables", "params")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))

View File

@@ -31,8 +31,6 @@ import logging # noqa: E402
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
logger = logging.getLogger("alembic.env")
def upgrade():
op.add_column(
@@ -46,7 +44,7 @@ def upgrade():
op.create_unique_constraint(None, "dbs", ["verbose_name"])
op.create_unique_constraint(None, "clusters", ["verbose_name"])
except Exception:
logger.info("Constraint not created, expected when using sqlite")
logging.info("Constraint not created, expected when using sqlite")
def downgrade():
@@ -54,4 +52,4 @@ def downgrade():
op.drop_column("dbs", "verbose_name")
op.drop_column("clusters", "verbose_name")
except Exception as ex:
logger.exception(ex)
logging.exception(ex)

View File

@@ -37,8 +37,6 @@ from superset.utils.core import (
revision = "4736ec66ce19"
down_revision = "f959a6652acd"
logger = logging.getLogger("alembic.env")
conv = {
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
@@ -121,13 +119,13 @@ def upgrade():
type_="unique",
)
except Exception as ex:
logger.warning(
logging.warning(
"Constraint drop failed, you may want to do this "
"manually on your database. For context, this is a known "
"issue around nondeterministic constraint names on Postgres "
"and perhaps more databases through SQLAlchemy."
)
logger.exception(ex)
logging.exception(ex)
def downgrade():

View File

@@ -22,16 +22,12 @@ Create Date: 2018-05-09 23:45:14.296283
"""
import logging
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
# revision identifiers, used by Alembic.
revision = "e502db2af7be"
down_revision = "5ccf602336a0"
logger = logging.getLogger("alembic.env")
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
def upgrade():
@@ -42,4 +38,4 @@ def downgrade():
try:
op.drop_column("tables", "template_params")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex)) # noqa: F821

View File

@@ -35,8 +35,6 @@ from superset.utils import json
revision = "fb13d49b72f9"
down_revision = "de021a1ca60d"
logger = logging.getLogger("alembic.env")
Base = declarative_base()
@@ -51,7 +49,7 @@ class Slice(Base):
def upgrade_slice(slc):
params = json.loads(slc.params)
logger.info(f"Upgrading {slc.slice_name}")
logging.info(f"Upgrading {slc.slice_name}")
cols = params.get("groupby")
metric = params.get("metric")
if cols:
@@ -81,8 +79,8 @@ def upgrade():
for slc in filter_box_slices.all():
try:
upgrade_slice(slc)
except Exception as e:
logger.exception(e)
except Exception:
logging.exception(e) # noqa: F821
session.commit()
session.close()
@@ -96,7 +94,7 @@ def downgrade():
for slc in filter_box_slices.all():
try:
params = json.loads(slc.params)
logger.info(f"Downgrading {slc.slice_name}")
logging.info(f"Downgrading {slc.slice_name}")
flts = params.get("filter_configs")
if not flts:
continue
@@ -104,7 +102,7 @@ def downgrade():
params["groupby"] = [o.get("column") for o in flts]
slc.params = json.dumps(params, sort_keys=True)
except Exception as ex:
logger.exception(ex)
logging.exception(ex)
session.commit()
session.close()

View File

@@ -41,8 +41,6 @@ from superset.utils.core import ( # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
class Slice(Base):
__tablename__ = "slices"
@@ -65,7 +63,7 @@ def upgrade():
if source != target:
slc.params = json.dumps(target, sort_keys=True)
except Exception as ex:
logger.warning(ex)
logging.warn(ex)
session.commit()
session.close()

View File

@@ -37,8 +37,6 @@ from superset.utils.dashboard_filter_scopes_converter import convert_filter_scop
revision = "3325d4caccc8"
down_revision = "e96dbf2cfef0"
logger = logging.getLogger("alembic.env")
Base = declarative_base()
@@ -88,7 +86,7 @@ def upgrade():
if filters:
filter_scopes = convert_filter_scopes(json_metadata, filters)
json_metadata["filter_scopes"] = filter_scopes
logger.info(
logging.info(
f"Adding filter_scopes for dashboard {dashboard.id}: {json.dumps(filter_scopes)}" # noqa: E501
)
@@ -102,7 +100,7 @@ def upgrade():
else:
dashboard.json_metadata = None
except Exception as ex:
logger.exception(f"dashboard {dashboard.id} has error: {ex}")
logging.exception(f"dashboard {dashboard.id} has error: {ex}")
session.commit()
session.close()

View File

@@ -38,8 +38,6 @@ from sqlalchemy_utils import UUIDType # noqa: E402
from superset import db # noqa: E402
logger = logging.getLogger("alembic.env")
add_uuid_column_to_import_mixin = import_module(
"superset.migrations.versions."
"2020-09-28_17-57_b56500de1855_add_uuid_column_to_import_mixin",
@@ -54,9 +52,9 @@ def has_uuid_column(table_name, bind):
columns = {column["name"] for column in inspector.get_columns(table_name)}
has_uuid_column = "uuid" in columns
if has_uuid_column:
logger.info("Table %s already has uuid column, skipping...", table_name)
logging.info("Table %s already has uuid column, skipping...", table_name)
else:
logger.info("Table %s doesn't have uuid column, adding...", table_name)
logging.info("Table %s doesn't have uuid column, adding...", table_name)
return has_uuid_column

View File

@@ -38,8 +38,6 @@ down_revision = "31b2a1039d4a"
Base = declarative_base()
logger = logging.getLogger("alembic.env")
class Database(Base):
__tablename__ = "dbs"
@@ -58,7 +56,7 @@ def upgrade():
try:
extra = json.loads(database.extra)
except json.JSONDecodeError as ex:
logger.warning(str(ex))
logging.warning(str(ex))
continue
schemas_allowed_for_csv_upload = extra.get("schemas_allowed_for_csv_upload")

View File

@@ -35,8 +35,6 @@ from sqlalchemy import func # noqa: E402
from superset import db # noqa: E402
from superset.connectors.sqla.models import SqlaTable # noqa: E402
logger = logging.getLogger("alembic.env")
def upgrade():
with op.batch_alter_table("tables") as batch_op:
@@ -64,12 +62,12 @@ def remove_value_if_too_long():
for row in rows:
row.fetch_values_predicate = None
logger.info("%d values deleted", len(rows))
logging.info("%d values deleted", len(rows))
session.commit()
session.close()
except Exception as ex:
logger.warning(ex)
logging.warning(ex)
def downgrade():

View File

@@ -37,7 +37,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
class Dashboard(Base):

View File

@@ -37,8 +37,6 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
class Database(Base):
__tablename__ = "dbs"
@@ -54,7 +52,7 @@ def upgrade():
try:
extra = json.loads(database.extra)
except json.JSONDecodeError as ex:
logger.warning(str(ex))
logging.warning(str(ex))
continue
if "schemas_allowed_for_csv_upload" in extra:
@@ -76,7 +74,7 @@ def downgrade():
try:
extra = json.loads(database.extra)
except json.JSONDecodeError as ex:
logger.warning(str(ex))
logging.warning(str(ex))
continue
if "schemas_allowed_for_file_upload" in extra:

View File

@@ -37,7 +37,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
class Slice(Base):

View File

@@ -38,7 +38,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
class Slice(Base):

View File

@@ -37,7 +37,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
class Slice(Base): # type: ignore

View File

@@ -38,7 +38,7 @@ from superset.migrations.shared.utils import paginated_update # noqa: E402
from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
class Dashboard(Base):

View File

@@ -41,7 +41,7 @@ from superset.utils.date_parser import get_since_until
revision = "f84fde59123a"
down_revision = "9621c6d56ffb"
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
Base = declarative_base()

View File

@@ -34,7 +34,7 @@ from superset.utils.core import generic_find_uq_constraint_name
revision = "df3d7e2eb9a4"
down_revision = "48cbb571fa3a"
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
def upgrade():

View File

@@ -32,7 +32,8 @@ from sqlalchemy.ext.declarative import declarative_base
from superset import db
from superset.migrations.shared.utils import paginated_update
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
logger.setLevel(logging.INFO)
# revision identifiers, used by Alembic.
revision = "363a9b1e8992"

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,7 @@ SQLGLOT_DIALECTS = {
# "denodo": ???
"dremio": Dremio,
"drill": Dialects.DRILL,
# "druid": Dialects.DRUID, # DRUID dialect not available in current sqlglot version
"druid": Dialects.DRUID,
"duckdb": Dialects.DUCKDB,
# "dynamodb": ???
# "elasticsearch": ???

View File

@@ -62,7 +62,6 @@ from superset.extensions import cache_manager
from superset.reports.models import ReportRecipientType
from superset.superset_typing import FlaskResponse
from superset.themes.utils import (
is_valid_theme,
is_valid_theme_settings,
)
from superset.utils import core as utils, json
@@ -307,29 +306,25 @@ def menu_data(user: User) -> dict[str, Any]:
def get_theme_bootstrap_data() -> dict[str, Any]:
"""
Returns the theme data to be sent to the client.
Resolves UUID references and upserts system themes.
"""
from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
# Get theme configs
default_theme_config = get_config_value("THEME_DEFAULT")
dark_theme_config = get_config_value("THEME_DARK")
theme_settings = get_config_value("THEME_SETTINGS")
# Validate theme configurations
default_theme = default_theme_config
if not is_valid_theme(default_theme):
logger.warning(
"Invalid THEME_DEFAULT configuration: %s, using empty theme",
default_theme_config,
)
default_theme = {}
# Resolve and upsert themes - command handles all error cases
default_theme = ResolveAndUpsertThemeCommand(
default_theme_config or {}, "THEME_DEFAULT"
).run()
dark_theme = dark_theme_config
if not is_valid_theme(dark_theme):
logger.warning(
"Invalid THEME_DARK configuration: %s, using empty theme",
dark_theme_config,
)
dark_theme = {}
dark_theme = ResolveAndUpsertThemeCommand(
dark_theme_config or {}, "THEME_DARK"
).run()
# Validate theme settings
if not is_valid_theme_settings(theme_settings):
logger.warning(
"Invalid THEME_SETTINGS configuration: %s, using defaults", theme_settings

View File

@@ -411,7 +411,7 @@ class BaseViz: # pylint: disable=too-many-public-methods
"groupby": groupby,
"metrics": metrics,
"row_limit": row_limit,
"filters": self.form_data.get("filters", []),
"filter": self.form_data.get("filters", []),
"timeseries_limit": limit,
"extras": extras,
"timeseries_limit_metric": timeseries_limit_metric,

View File

@@ -29,8 +29,8 @@ query_birth_names = {
"row_limit": 100,
"granularity": "ds",
"time_range": "100 years ago : now",
"series_limit": 0,
"series_limit_metric": None,
"timeseries_limit": 0,
"timeseries_limit_metric": None,
"order_desc": True,
"filters": [
{"col": "gender", "op": "==", "val": "boy"},

View File

@@ -902,9 +902,6 @@ class TestPostChartDataApi(BaseTestChartDataApi):
request_payload["queries"][0]["columns"] = ["foo", "bar", "state"]
request_payload["queries"][0]["where"] = "':abc' != ':xyz:qwerty'"
request_payload["queries"][0]["orderby"] = None
request_payload["queries"][0]["granularity"] = (
None # Virtual table has no time column
)
request_payload["queries"][0]["metrics"] = [
{
"expressionType": AdhocMetricExpressionType.SQL,
@@ -1015,7 +1012,7 @@ class TestGetChartDataApi(BaseTestChartDataApi):
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"series_limit": 0,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},
@@ -1068,7 +1065,7 @@ class TestGetChartDataApi(BaseTestChartDataApi):
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"series_limit": 0,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},
@@ -1122,7 +1119,7 @@ class TestGetChartDataApi(BaseTestChartDataApi):
"orderby": [["sum__num", False]],
"annotation_layers": [],
"row_limit": 50000,
"series_limit": 0,
"timeseries_limit": 0,
"order_desc": True,
"url_params": {},
"custom_params": {},

View File

@@ -124,6 +124,17 @@ class TestDatasource(SupersetTestCase):
else:
return
query_obj = {
"columns": ["metric"],
"filter": [],
"from_dttm": datetime.now() - timedelta(days=1),
"granularity": "additional_dttm",
"orderby": [],
"to_dttm": datetime.now() + timedelta(days=1),
"series_columns": [],
"row_limit": 1000,
"row_offset": 0,
}
table = SqlaTable(
table_name="dummy_sql_table",
database=database,
@@ -138,28 +149,13 @@ class TestDatasource(SupersetTestCase):
sql=sql,
)
from superset.common.query_object import QueryObject
query_obj = QueryObject(
columns=["metric"],
filters=[],
from_dttm=datetime.now() - timedelta(days=1),
granularity="additional_dttm",
orderby=[],
to_dttm=datetime.now() + timedelta(days=1),
series_columns=[],
row_limit=1000,
row_offset=0,
datasource=table,
)
with create_and_cleanup_table(table):
table.always_filter_main_dttm = False
result = str(table.get_sqla_query(query_obj).sqla_query.whereclause)
result = str(table.get_sqla_query(**query_obj).sqla_query.whereclause)
assert "default_dttm" not in result and "additional_dttm" in result # noqa: PT018
table.always_filter_main_dttm = True
result = str(table.get_sqla_query(query_obj).sqla_query.whereclause)
result = str(table.get_sqla_query(**query_obj).sqla_query.whereclause)
assert "default_dttm" in result and "additional_dttm" in result # noqa: PT018
def test_external_metadata_for_virtual_table(self):
@@ -588,11 +584,7 @@ def test_get_samples_with_incorrect_cc(test_client, login_as_admin, virtual_data
)
rv = test_client.post(uri, json={})
assert rv.status_code == 422
# The error handling returns a simple error message for CommandInvalidError
assert "error" in rv.json
assert (
"DUMMY CC" in rv.json["error"]
) # Check the error mentions the problematic column
assert rv.json["errors"][0]["error_type"] == "INVALID_SQL_ERROR"
@with_feature_flags(ALLOW_ADHOC_SUBQUERY=True)

View File

@@ -186,6 +186,6 @@ def _get_energy_slices():
"xscale_interval": "1",
"yscale_interval": "1",
},
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"timeseries_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
},
]

View File

@@ -591,7 +591,7 @@ chart_config: dict[str, Any] = {
},
"viz_type": "deck_path",
},
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"series_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
"query_context": '{"datasource":{"id":12,"type":"table"},"force":false,"queries":[{"time_range":" : ","filters":[],"extras":{"time_grain_sqla":null,"having":"","where":""},"applied_time_extras":{},"columns":[],"metrics":[],"annotation_layers":[],"row_limit":5000,"timeseries_limit":0,"order_desc":true,"url_params":{},"custom_params":{},"custom_form_data":{}}],"result_format":"json","result_type":"full"}', # noqa: E501
"cache_timeout": None,
"uuid": "0c23747a-6528-4629-97bf-e4b78d3b9df1",
"version": "1.0.0",

View File

@@ -294,17 +294,14 @@ class TestQueryContext(SupersetTestCase):
payload = get_query_context("birth_names")
columns = payload["queries"][0]["columns"]
payload["queries"][0]["groupby"] = columns
payload["queries"][0]["series_limit"] = 99
payload["queries"][0]["series_limit_metric"] = "sum__num"
payload["queries"][0]["timeseries_limit"] = 99
payload["queries"][0]["timeseries_limit_metric"] = "sum__num"
del payload["queries"][0]["columns"]
# Remove granularity so granularity_sqla can be used
del payload["queries"][0]["granularity"]
payload["queries"][0]["granularity_sqla"] = "timecol"
payload["queries"][0]["having_filters"] = [{"col": "a", "op": "==", "val": "b"}]
query_context = ChartDataQueryContextSchema().load(payload)
assert len(query_context.queries) == 1
query_object = query_context.queries[0]
# granularity should be set from granularity_sqla since granularity was not set
assert query_object.granularity == "timecol"
assert query_object.columns == columns
assert query_object.series_limit == 99
@@ -523,7 +520,7 @@ class TestQueryContext(SupersetTestCase):
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["groupby"] = ["name"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = ["1 year ago", "1 year later"]
payload["queries"][0]["time_range"] = "1990 : 1991"
query_context = ChartDataQueryContextSchema().load(payload)
@@ -559,7 +556,7 @@ class TestQueryContext(SupersetTestCase):
# due to "name" is random generated, each time_offset slice will be empty
payload["queries"][0]["groupby"] = ["name"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = []
payload["queries"][0]["time_range"] = "1990 : 1991"
payload["queries"][0]["granularity"] = "ds"
@@ -612,7 +609,7 @@ class TestQueryContext(SupersetTestCase):
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["groupby"] = ["state"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = []
payload["queries"][0]["time_range"] = "1980 : 1991"
payload["queries"][0]["granularity"] = "ds"
@@ -641,11 +638,9 @@ class TestQueryContext(SupersetTestCase):
def test_time_offsets_accuracy(self):
payload = get_query_context("birth_names")
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["columns"] = [
"state"
] # Use columns instead of deprecated groupby
payload["queries"][0]["groupby"] = ["state"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["series_limit"] = 5
payload["queries"][0]["timeseries_limit"] = 5
payload["queries"][0]["time_offsets"] = []
payload["queries"][0]["time_range"] = "1980 : 1991"
payload["queries"][0]["granularity"] = "ds"
@@ -718,10 +713,10 @@ class TestQueryContext(SupersetTestCase):
"sqlExpression": "ds",
"label": "ds",
"expressionType": "SQL",
},
"name", # Add name to columns instead of using deprecated groupby
}
]
payload["queries"][0]["metrics"] = ["sum__num"]
payload["queries"][0]["groupby"] = ["name"]
payload["queries"][0]["is_timeseries"] = True
payload["queries"][0]["row_limit"] = 100
payload["queries"][0]["row_offset"] = 10

View File

@@ -33,7 +33,6 @@ from sqlalchemy.sql import text
from sqlalchemy.sql.elements import TextClause
from superset import db
from superset.common.query_object import QueryObject
from superset.connectors.sqla.models import SqlaTable, TableColumn, SqlMetric
from superset.constants import EMPTY_STRING, NULL_STRING
from superset.db_engine_specs.bigquery import BigQueryEngineSpec
@@ -163,7 +162,7 @@ class TestDatabaseModel(SupersetTestCase):
"count_timegrain",
],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {"time_grain_sqla": "P1D"},
}
@@ -187,8 +186,7 @@ class TestDatabaseModel(SupersetTestCase):
)
db.session.commit()
query_object = QueryObject(datasource=table, **base_query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**base_query_obj)
query = table.database.compile_sqla_query(sqla_query.sqla_query)
# assert virtual dataset
@@ -236,13 +234,12 @@ class TestDatabaseModel(SupersetTestCase):
},
],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {"time_grain_sqla": "P1D"},
}
mock_dataset_id_from_context.return_value = table.id
query_object = QueryObject(datasource=table, **base_query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**base_query_obj)
query = table.database.compile_sqla_query(sqla_query.sqla_query)
database = table.database
@@ -270,7 +267,7 @@ class TestDatabaseModel(SupersetTestCase):
}
],
"is_timeseries": False,
"filters": [],
"filter": [],
}
table = SqlaTable(
@@ -278,9 +275,8 @@ class TestDatabaseModel(SupersetTestCase):
)
db.session.commit()
query_object = QueryObject(datasource=table, **base_query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**base_query_obj)
# Cleanup
db.session.delete(table)
db.session.commit()
@@ -314,7 +310,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["gender"],
"metrics": ["count"],
"is_timeseries": False,
"filters": [
"filter": [
{
"col": filter_.column,
"op": filter_.operator,
@@ -323,8 +319,7 @@ class TestDatabaseModel(SupersetTestCase):
],
"extras": {},
}
query_object = QueryObject(datasource=table, **query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**query_obj)
sql = table.database.compile_sqla_query(sqla_query.sqla_query)
if isinstance(filter_.expected, list):
assert any([candidate in sql for candidate in filter_.expected]) # noqa: C419
@@ -349,7 +344,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["boolean_gender"],
"metrics": ["count"],
"is_timeseries": False,
"filters": [
"filter": [
{
"col": "boolean_gender",
"op": FilterOperator.IN,
@@ -358,8 +353,7 @@ class TestDatabaseModel(SupersetTestCase):
],
"extras": {},
}
query_object = QueryObject(datasource=table, **query_obj)
sqla_query = table.get_sqla_query(query_object)
sqla_query = table.get_sqla_query(**query_obj)
sql = table.database.compile_sqla_query(sqla_query.sqla_query)
dialect = table.database.get_dialect()
operand = "(true, false)"
@@ -377,7 +371,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["user"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {},
}
@@ -389,9 +383,8 @@ class TestDatabaseModel(SupersetTestCase):
)
# TODO(villebro): make it work with presto
if get_example_database().backend != "presto":
query_object = QueryObject(datasource=table, **query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**query_obj)
def test_query_format_strip_trailing_semicolon(self):
query_obj = {
@@ -401,7 +394,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["user"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {},
}
@@ -410,8 +403,7 @@ class TestDatabaseModel(SupersetTestCase):
sql="SELECT * from test_table;",
database=get_example_database(),
)
query_object = QueryObject(datasource=table, **query_obj)
sqlaq = table.get_sqla_query(query_object)
sqlaq = table.get_sqla_query(**query_obj)
sql = table.database.compile_sqla_query(sqlaq.sqla_query)
assert sql[-1] != ";"
@@ -423,7 +415,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["grp"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
table = SqlaTable(
@@ -433,9 +425,8 @@ class TestDatabaseModel(SupersetTestCase):
)
query_obj = dict(**base_query_obj, extras={})
query_object = QueryObject(datasource=table, **query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**query_obj)
def test_dml_statement_raises_exception(self):
base_query_obj = {
@@ -445,7 +436,7 @@ class TestDatabaseModel(SupersetTestCase):
"groupby": ["grp"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
table = SqlaTable(
@@ -455,9 +446,8 @@ class TestDatabaseModel(SupersetTestCase):
)
query_obj = dict(**base_query_obj, extras={})
query_object = QueryObject(datasource=table, **query_obj)
with pytest.raises(QueryObjectValidationError):
table.get_sqla_query(query_object)
table.get_sqla_query(**query_obj)
def test_fetch_metadata_for_updated_virtual_table(self):
table = SqlaTable(
@@ -517,7 +507,7 @@ class TestDatabaseModel(SupersetTestCase):
}
],
"is_timeseries": False,
"filters": [],
"filter": [],
"extras": {},
}
@@ -526,8 +516,7 @@ class TestDatabaseModel(SupersetTestCase):
db.session.add(database)
db.session.add(table)
db.session.commit()
query_object = QueryObject(datasource=table, **query_obj)
sqlaq = table.get_sqla_query(query_object)
sqlaq = table.get_sqla_query(**query_obj)
assert sqlaq.labels_expected == ["user", "COUNT_DISTINCT(user)"]
sql = table.database.compile_sqla_query(sqlaq.sqla_query)
assert "COUNT_DISTINCT_user__00db1" in sql
@@ -596,7 +585,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [NULL_STRING], "op": "IN"}],
"filter": [{"col": "foo", "val": [NULL_STRING], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -606,7 +595,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [None], "op": "IN"}],
"filter": [{"col": "foo", "val": [None], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -616,7 +605,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [EMPTY_STRING], "op": "IN"}],
"filter": [{"col": "foo", "val": [EMPTY_STRING], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -626,7 +615,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [{"col": "foo", "val": [""], "op": "IN"}],
"filter": [{"col": "foo", "val": [""], "op": "IN"}],
"is_timeseries": False,
}
)
@@ -636,7 +625,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": [EMPTY_STRING, NULL_STRING, "null", "foo"],
@@ -652,7 +641,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ['"text in double quotes"'],
@@ -668,7 +657,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ["'text in single quotes'"],
@@ -684,7 +673,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ['double quotes " in text'],
@@ -700,7 +689,7 @@ def test_filter_on_text_column(text_column_table):
result_object = table.query(
{
"metrics": ["count"],
"filters": [
"filter": [
{
"col": "foo",
"val": ["single quotes ' in text"],
@@ -737,7 +726,7 @@ def test_should_generate_closed_and_open_time_filter_range(login_as_admin):
{
"metrics": ["count"],
"is_timeseries": False,
"filters": [],
"filter": [],
"from_dttm": datetime(2022, 1, 1),
"to_dttm": datetime(2023, 1, 1),
"granularity": "datetime_col",
@@ -774,7 +763,7 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset):
result = physical_dataset.query(
{
"metrics": ["count"],
"filters": [{"col": "col4", "val": None, "op": expected["operator"]}],
"filter": [{"col": "col4", "val": None, "op": expected["operator"]}],
"is_timeseries": False,
}
)
@@ -793,7 +782,7 @@ def test_none_operand_in_filter(login_as_admin, physical_dataset):
physical_dataset.query(
{
"metrics": ["count"],
"filters": [{"col": "col4", "val": None, "op": flt.value}],
"filter": [{"col": "col4", "val": None, "op": flt.value}],
"is_timeseries": False,
}
)
@@ -882,7 +871,7 @@ def test_extra_cache_keys(
"groupby": ["id", "username", "email"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
query_obj = dict(**base_query_obj, extras={})
@@ -928,7 +917,7 @@ def test_extra_cache_keys_in_sql_expression(
"groupby": ["id", "username", "email"],
"metrics": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
query_obj = dict(**base_query_obj, extras={"where": sql_expression})
@@ -971,7 +960,7 @@ def test_extra_cache_keys_in_adhoc_metrics_and_columns(
"metrics": [],
"columns": [],
"is_timeseries": False,
"filters": [],
"filter": [],
}
items: dict[str, Any] = {
@@ -1025,7 +1014,7 @@ def test_extra_cache_keys_in_dataset_metrics_and_columns(
"columns": ["username"],
"metrics": ["variable_profit"],
"is_timeseries": False,
"filters": [],
"filter": [],
}
extra_cache_keys = table.get_extra_cache_keys(query_obj)
@@ -1129,7 +1118,7 @@ def test__temporal_range_operator_in_adhoc_filter(physical_dataset):
result = physical_dataset.query(
{
"columns": ["col1", "col2"],
"filters": [
"filter": [
{
"col": "col5",
"val": "2000-01-05 : 2000-01-06",

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.

View File

@@ -0,0 +1,184 @@
# 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 unittest.mock import MagicMock, patch
import pytest
from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
from superset.models.core import Theme
from superset.utils import json
@pytest.fixture
def mock_theme_dao():
with patch("superset.commands.theme.resolve.ThemeDAO") as mock:
yield mock
@pytest.fixture
def mock_db():
with patch("superset.commands.theme.resolve.db") as mock:
yield mock
class TestResolveAndUpsertThemeCommand:
def test_resolve_uuid_reference_found(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference when theme is found."""
# Setup
uuid = "test-uuid-123"
theme_config = {"uuid": uuid}
expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = json.dumps(expected_config)
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert
assert result == expected_config
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
def test_resolve_uuid_reference_not_found(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference when theme is not found."""
# Setup
uuid = "missing-uuid-123"
theme_config = {"uuid": uuid}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return empty dict fallback
assert result == {}
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
def test_resolve_uuid_reference_invalid_json(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference with invalid JSON data."""
# Setup
uuid = "test-uuid-123"
theme_config = {"uuid": uuid}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = "invalid json"
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return empty dict fallback
assert result == {}
def test_resolve_non_uuid_config(self, mock_theme_dao, mock_db):
"""Test resolving a regular theme config (not UUID reference)."""
# Setup
theme_config = {"algorithm": "default", "token": {"colorPrimary": "#ff0000"}}
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return config as-is
assert result == theme_config
mock_theme_dao.find_by_uuid.assert_not_called()
def test_upsert_creates_new_system_theme(self, mock_theme_dao, mock_db):
"""Test upserting creates a new system theme when none exists."""
# Setup
theme_config = {"algorithm": "default"}
mock_db.session.query.return_value.filter.return_value.first.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
command.run()
# Assert
mock_db.session.add.assert_called_once()
added_theme = mock_db.session.add.call_args[0][0]
assert added_theme.theme_name == "THEME_DEFAULT"
assert added_theme.is_system is True
assert added_theme.json_data == json.dumps(theme_config)
def test_upsert_updates_existing_system_theme(self, mock_theme_dao, mock_db):
"""Test upserting updates an existing system theme."""
# Setup
theme_config = {"algorithm": "dark"}
existing_theme = MagicMock(spec=Theme)
mock_db.session.query.return_value.filter.return_value.first.return_value = (
existing_theme
)
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
command.run()
# Assert
assert existing_theme.json_data == json.dumps(theme_config)
mock_db.session.add.assert_not_called()
def test_fallback_for_theme_default(self, mock_theme_dao, mock_db):
"""Test fallback returns empty dict for THEME_DEFAULT."""
# Setup - simulate UUID lookup failure
theme_config = {"uuid": "non-existent-uuid"}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert
assert result == {}
def test_fallback_for_theme_dark(self, mock_theme_dao, mock_db):
"""Test fallback returns dark algorithm for THEME_DARK."""
# Setup - simulate UUID lookup failure
theme_config = {"uuid": "non-existent-uuid"}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
result = command.run()
# Assert
assert result == {"algorithm": "dark"}
def test_uuid_with_additional_fields(self, mock_theme_dao, mock_db):
"""Test that UUID takes precedence even with additional fields."""
# Setup
uuid = "test-uuid-123"
theme_config = {
"uuid": uuid,
"algorithm": "ignored", # This should be ignored
"token": {"ignored": True}, # This should be ignored
}
expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = json.dumps(expected_config)
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return the resolved config, not the input
assert result == expected_config
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from superset.common.query_object import QueryObject
def test_get_series_limit_prequery_obj():
"""
Test get_series_limit_prequery_obj method
"""
# Create a QueryObject with series limit settings
query_object = QueryObject(
columns=["country", "year"],
metrics=["sum__sales"],
series_limit=10,
from_dttm=datetime(2020, 1, 1),
to_dttm=datetime(2021, 1, 1),
filters=[{"col": "region", "op": "IN", "val": ["US", "EU"]}],
extras={"time_grain_sqla": "P1D"},
order_desc=False,
)
# Test basic prequery object creation
prequery_obj = query_object.get_series_limit_prequery_obj(
granularity="ds",
inner_from_dttm=None,
inner_to_dttm=None,
)
assert prequery_obj["is_timeseries"] is False
assert prequery_obj["row_limit"] == 10
assert prequery_obj["metrics"] == ["sum__sales"]
assert prequery_obj["granularity"] == "ds"
assert prequery_obj["groupby"] == ["country", "year"]
assert prequery_obj["from_dttm"] == datetime(2020, 1, 1)
assert prequery_obj["to_dttm"] == datetime(2021, 1, 1)
assert prequery_obj["filter"] == [
{"col": "region", "op": "IN", "val": ["US", "EU"]}
]
assert prequery_obj["orderby"] == []
assert prequery_obj["extras"] == {"time_grain_sqla": "P1D"}
assert prequery_obj["order_desc"] is True # Always True for prequery
def test_get_series_limit_prequery_obj_with_overrides():
"""
Test get_series_limit_prequery_obj with inner dates and orderby override
"""
query_object = QueryObject(
columns=["country"],
metrics=["count"],
series_limit=5,
from_dttm=datetime(2020, 1, 1),
to_dttm=datetime(2021, 1, 1),
)
# Test with inner dates and custom orderby
inner_from = datetime(2020, 6, 1)
inner_to = datetime(2020, 12, 31)
custom_orderby = [("sum__revenue", False)]
prequery_obj = query_object.get_series_limit_prequery_obj(
granularity="date_col",
inner_from_dttm=inner_from,
inner_to_dttm=inner_to,
orderby=custom_orderby,
)
assert prequery_obj["from_dttm"] == inner_from
assert prequery_obj["to_dttm"] == inner_to
assert prequery_obj["orderby"] == custom_orderby
def test_get_series_limit_prequery_obj_base_axis_filtering():
"""
Test that base axis columns are filtered out in prequery
"""
# Mock the x-axis column with proper structure for base axis
query_object = QueryObject(
columns=[
{
"label": "__timestamp",
"sqlExpression": "__timestamp",
"columnType": "BASE_AXIS",
},
"country",
"city",
],
metrics=["revenue"],
series_limit=20,
)
prequery_obj = query_object.get_series_limit_prequery_obj(
granularity=None,
inner_from_dttm=None,
inner_to_dttm=None,
)
# The columns in prequery should exclude the base axis column
assert prequery_obj["columns"] == ["country", "city"]